二、检测 Promise 类型
在 Promise
领域,一个重要的细节是如何确定某个值是不是真正的 Promise
。或者更直接地说,它是不是一个行为方式类似于 Promise
的值?
既然 Promise
是通过 new Promise(..)
语法创建的,那你可能就认为可以通过 p instanceof Promise
来检查。但遗憾的是,这并不足以作为检查方法,原因有许多。
其中最主要的是,Promise
值可能是从其他浏览器窗口(iframe
等)接收到的。这个浏览器窗口自己的 Promise
可能和当前窗口 /frame
的不同,因此这样的检查无法识别 Promise
实例。
因此,识别 Promise
(或者行为类似于 Promise
的东西)就是定义某种称为 thenable
的东西,将其定义为任何具有 then(..)
方法的对象和函数。我们认为,任何这样的值就是 Promise
一致的 thenable
。
于是,对 thenable
值的类型检测就类似于:
if (p !== null && (typeof p === 'object' || typeof p === 'function') && typeof p.then === 'function') {
// 假定这是一个 thenable
} else {
// 不是 thenable
}
有个更深层次的麻烦,如果你试图使用恰好有 then(..)
函数的一个对象或函数值完成一个 Promise
,但并不希望它被当作 Promise
或 thenable
,那就有点麻烦了,因为它会自动被识别为 thenable
,并被按照特定的规则处理。
es6 标准决定劫持之前未保留的——听起来是完全通用的——属性名 then
。这意味着所有值(或其委托),不管是过去的、现存的还是未来的,都不能拥有 then(..)
函数,不管是有意的还是无意的;否则这个值在 Promise
系统中就会被误认为是一个 thenable
,这可能会导致非常难以追踪的 bug
。
三、Promise 信任问题
未来值和完成事件这两个类比在我们之前探讨的代码模式中很明显。但是,我们还不能一眼就看出 Promise
为什么以及如何用于解决 控制反转信任 问题。
先回顾一下只用回调编码的信任问题,把一个回调传入工具函数时可能出现的问题:
- 调用回调过早;
- 调用回调过晚(或不被调用);
- 调用回调次数过少或过多;
- 未能传递所需的环境和参数;
- 吞掉可能出现的错误和异常。
Promise
的特性就是专门用来为这些问题提供一个有效的可复用的答案。
3.1 调用过早
在这类问题中,一个任务有时同步完成,有时异步完成,这可能会导致竞态条件。
根据定义,Promise
就不必担心这个问题,因为即使是立即完成的 promise
(类似于 new Promise(function(resolve) { resolve(42) })
)也无法被同步观察到。
对一个 Promise
调用 then(..)
的时候,即使这个 Promise
已经决议,提供给 then(..)
的回调也总会被异步调用。
3.2 调用过晚
Promise
创建对象调用 resolve(..)
或 reject(..)
时,这个 Promise
的 then(..)
注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事件点上一定会被触发。
3.3 回调未调用
首先,没有任何东西(甚至 JavaScript
错误)能阻止 Promise
向你通知它的决议(如果它决议了的话)。如果你对一个 Promise
注册了一个完成回调和一个拒绝回调,那么 Promise
在决议时总是会调用其中的一个。
但是,如果 Promise
本身永远不被决议呢?即使这样,Promise
也提供了解决方案,其使用了一种称为 竞态 的高级抽象机制:
// 用于超时一个 Promise 的工具
function timeoutPromise(delay) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('Timeout!')
})
})
}
// 设置 foo() 超时
Promise.race([
foo(),
timeoutPromise()
]).then(
function() {
// foo(..) 按时完成
},
function(err) {
// foo(..) 没能按时完成,或者被拒绝
}
)
上面示例中,很重要的一点是,我们可以保证有一个输出信号,防止永远挂住程序。
3.4 调用次数过少或过多
根据定义,回调被调用的正确次数应该是 1。“过少”的情况就是调用 0 次,和前面解释过的“未被”调用是同一种情况。
“过多”的情况很容易解释。Promise
的定义方式使得它只能被决议一次。如果出于某种原因,Promise
创建代码试图调用 resolve(..)
或 reject(..)
多次,或者试图两者都调用,那么这个 Promise
将 只会接受第一次决议,并默默地忽略任何后续调用。
由于 Promise
只能被决议一次,所以任何通过 then(..)
注册的回调就只会被调用一次。如果你把同一个回调注册了不止一次(比如 p.then(f); p.then(f);
),那它被调用的次数就会和注册次数相同。
3.5 未能传递参数 / 环境值
Promise
至多只能有一个决议值(完成或拒绝)。
如果你没有用任何值显式决议,那么这个值就是 undefined
,这是 JavaScript
常见的处理方式。但不管这个值是什么,无论 当前或未来,它都会被传给所有注册的回调。
还有一点需要清楚:如果使用多个参数调用 resovle(..)
或者 reject(..)
,第一个参数之后的所有参数都会被默默忽略。
3.6 吞掉错误或异常
如果拒绝一个 Promise
并给出一个理由(也就是一个出错消息),这个值就会被传给拒绝回调。
从细节上看,如果在 Promise
的创建过程中或在查看其决议结果过程中的任何时间点上出现了一个 JavaScript
异常错误,比如一个 TypeError
或 ReferenceError
,那这个异常就会被捕捉,并且会使这个 Promise
被拒绝。
看一个例子:
let p = new Promise((resolve, reject) => {
foo.bar() // foo 为定义,所以会出错
resolve(24) // 永远不会到达这里
})
p.then(
function fulfilled() {
// 永远不会到达这里
},
function rejected(err) {
// err 将会是一个 TypeError 异常对象来自 foo.bar() 这一行
}
)
foo.bar()
中发生的 JavaScript
异常导致了 Promise
拒绝,你可以捕捉并对其作出响应。
一个重要的细节是:Promise
甚至把 JavaScript
异常也变成了异步行为。
但是,如果 Promise
完成后在查看结果时(then(..)
注册的回调中)出现了 JavaScript
异常错误会怎样呢?
let p = new Promise((resolve, reject) => {
resolve(24)
})
p.then(
function fulfilled(res) {
foo.bar() // foo 为定义,所以会出错
console.log('res: ', res) // 永远不会到达这里
},
function rejected(err) {
// 永远也不会到达这里
}
)
这看起来像是 foo.bar()
产生的异常真的被吞掉了。不过,实际上并不是这样。但是这里有一个深藏的问题,就是我们没有侦听到它。p.then(..)
调用本身返回了另外一个 promise
,正是这个 promise
将会因 TypeError
异常而被拒绝。
为什么它不是简单地调用我们定义的错误处理函数呢?表面上的逻辑应该是这样啊。如果这样的话就违背了 promise
的一条基本原则,即 promise
一旦决议就不可再变。p 已经完成为值 24,所以之后查看 p 的决议时,并不能因为出错就把 p 再变为一个拒绝。
小结
- 检测
Promise
类型就是定义某种称为thenable
的东西,将其定义为任何具有then(..)
方法的对象和函数。我们认为,任何这样的值就是Promise
一致的thenable
。 Promise
这种模式通过可信任的语义把回调作为参数传递,使得这种行为更可靠更合理。通过把回调的控制反转反转回来,我们把控制权放在了一个可信任的系统(Promise
)中。
参考资料
- 《你不知道的JavaScript(中卷)》