通过回调表达程序异步和管理并发的两个主要缺陷:缺乏顺序性 和 可信任性。
首先要解决的是控制反转问题:之前用回调函数来封装程序中的 continuation
,然后把回调交给第三方(甚至可能是外部代码),接着期待其能够调用回调,实现正确的功能。
但是,如果我们能够把控制反转再反转回来,会怎样呢?如果我们不把自己程序的 continuation
传给第三方,而是希望第三方给我们 提供了解其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么,那将会怎样呢?
这种范式就称为 Promise
。
一、什么是 Promise
在解释什么是 Promise
之前,先来看关于 Promise
定义的两个不同类比:未来值 和 完成事件。
1.1 未来值
未来值就是未来将要得到的值:它有可能成功也有可能失败。
在具体解释 Promise
的工作方式之前,先来推导通过我们已经理解的方式——回调——如何处理未来值。
当编写代码要得到某个值的时候,比如通过数学计算,不管你有没有意识到,你都已经对这个值做出了一些非常基本的假设,那就是,它已经是一个具体的现在值:
let x, y = 2
console.log(x + y) // NaN --> 因为 x 还没有赋值
期望运算符 + 本身能够神奇地检测并等待 x 和 y 都决议好(也就是准备好)再进行运算是没有意义的。
我们回到 x + y 这个算术运算。设想如果可以通过一种方式表达:“把 x 和 y 加起来,但如果它们中的任何一个还没有准备好,就等待两者都准备好。一旦可以就马上执行加运算。”
先看回调解决方案:
function add(getX, getY, cb) {
let x, y
getX(function(xVal) {
x = xVal
// 当两个都准备好了
if (y != undefined) {
cb(x + y)
}
})
getY(function(yVal) {
y = yVal
// 当两个都准备好了
if (x != undefined) {
cb(x + y)
}
})
}
// fetchX() 和 fetchY() 是同步或者异步函数
add(fetchX, fetchY, function(sum) {
console.log('sum: ', sum)
})
在这段代码中,我们把 x 和 y 当作未来值,并且表达了一个运算 add(...)
。这个运算(从外部看)不在意 x 和 y 现在是否都已经可用。换句话说,它把现在和将来归一化了,因此我们可以确保这个 add(...)
运算的输出是可预测的。
为了统一处理现在和将来,我们把它们都变成了将来,即所有的操作都成了异步的。
再来看 Promise
解决方案
function add(xPromise, yPromise) {
// Promise.all([ .. ]) 接受一个 promise 数组并返回一个新的 promise,
// 这个新 promise 等待数组中的所有 promise 完成
return Promise.all([xPromise, yPromise]).then(values => {
// 这个 promise 决议之后,我们取得收到的X和Y值并加在一起
return values[0] + values[1]
})
}
// fetchX() 和 fetchY() 返回相应值的 promise
// 我们得到一个这两个数组的和的 promise
// 现在链式调用 then(..) 来等待返回 promise 的决议
add(fetchX, fetchY).then(sum => {
console.log('sum: ', sum)
})
这段代码中有两层 Promise
。
第一层是 fetchX()
和 fetchY()
,它们是直接调用的,返回值(promise
)被传给 add(..)
。
第二层是 add(..)
创建并返回的 promise
,我们通过调 then(..)
等待这个 promise
。
说明:在 add(..)
内部,Promise.all([ .. ])
调用创建了一个 promise
(这个 promise
等待 promiseX
和 promiseY
的决议)。链式调用 .then(..)
创建了另外一个 promise
。这个 promise
由 return values[0] + values[1]
这一行立即决议(得到加运算的结果)。因此,链 add(..)
调用终止处的调用 then(..)
——在代码结尾处——实际上操作的是返回的第二个 promise
,而不是由 Promise.all([ .. ])
创建的第一个 promise
。还有,尽管第二个 then(..)
后面没有链接任何东西,但它实际上也创建了一个新的 promise
。
Promise
的决议结果可能是拒绝而不是完成。拒绝值和完成的 Promise
不一样:完成值总是编程给出的,而拒绝值,通常称为拒绝原因,可能是程序逻辑直接设置的,也可能是从运行异常隐式得出的值。
通过 Promise
,调用 then(..)
实际上可以接受两个参数,第一个用于完成情况,第二个用于拒绝情况。
add(fetchX, fetchY)
.then(
// 完成处理函数
function(sum) {
console.log(sum)
},
// 拒绝处理函数
function(err) {
console.error(err)
}
)
如果在获取 X 或 Y 的过程中出错,或者在加法过程中出错, add(..)
返回的就是一个被拒绝的 promise
,传给 then(..)
的第二个错误处理回调就会从这个 promise
中得到拒绝值。
从外部看,由于 Promise
封装了依赖于时间的状态——等待底层值的完成或拒绝,所以 Promise
本身是与时间无关的。因此,Promise
可以按照可预测的方式组成(组合),而不用关心时序或底层的结果。
另外,一旦 Promise 决议,它就永远保持在这个状态。此时它就成为了不变值,可以根据需求多次查看。(这是关于 Promise 需要理解的最强大也最重要的一个概念)
Promise
是一种封装和组合未来值的易于复用的机制。
1.2 完成事件
如上所述,单独的 Promise 展示了未来值的特性。也可以从另外一个角度看待 Promise
的决议:一种在异步任务中作为两个或更多步骤的流程控制机制。
假定要调用一个函数 foo(..)
执行某个任务。我们不知道也不关心它的任何细节。这个函数可能立即完成任务,也可能需要一段时间才能完成。我们只需要知道 foo(..)
什么时候结束,这样就可以进行下一个任务。
在典型的 JavaScript
风格中,如果需要侦听某个通知,你可能就会想到事件。因此,可以把对通知的需求重新组织为对 foo(..)
发出的一个 完成事件 的侦听。
使用回调的话,通知就是任务调用的回调。而使用 Promise
的话,我们把这个关系反转了过来,侦听来自 foo(..)
的事件,然后在得到通知的时候,根据情况继续。
先看一个示例:
function foo(x) {
// 异步操作
// 构造一个 listener 事件通知处理对象来返回
return listener
}
let evt = foo(24)
evt.on('completion', function() {
// 执行下一步
})
evt.on('failure', function(err) {
// 出错了
})
foo(..)
显式创建并返回了一个事件订阅对象,调用代码得到这个对象,并在其上注册了两个事件处理函数。
相对于面向回调的代码,这里的反转是显而易见的。这里没有把回调传给 foo(..)
,而是返回一个名为 evt 的事件注册对象,由它来接受回调。
回调本身就表达了一种控制反转,所以对回调模式的反转实际上是对反转的反转,或者称为反控制反转——把控制返还给调用代码。
Promise “事件”
示例中的事件侦听对象 evt 就是 Promise
的一个模拟。
Promise 示例:
function foo(x) {
// ...
// 构造并返回一个 promise
return new Promise((resolve, reject) ={
// 最终调用 resolve(..) 或者 reject(..)
// 这是这个promise的决议回调
})
}
let p = foo(24)
bar(p)
baz(p)
说明:在new Promise(function(..) {..})
模式中,传入的函数会立即执行(不会像 then(..)
中的回调一样异步延时),它有两个参数,分别为 resolve
和 reject
。这些是 promise
的决议函数。resolve(..)
通常标识完成,而 reject(..)
则标识拒绝。
// bar(..) 内部实现,baz(..) 内部也一样
function bar(fooPromise) {
fooPromise().then(
// 侦听foo(..)完成
function() {
// foo(..)已经完毕,所以执行bar(..)的任务
},
function(err) {
// 啊,foo(..)中出错了!
}
)
}
Promise
决议并不一定要像前面样将 Promise
作为未来值查看。它也可以只作为一种 流程控制信号,就像上面这段代码中的用法一样。从 foo(..)
返回的 promise p
来控制接下来的步骤。
另外,示例代码中使用 promise p
调用 then(..)
两次结束。这个事实说明了前面的观点,就是 Promise
(一旦决议)一直保持其决议结果(完成或拒绝)不变,可以按照需要多次查看。
参考资料
- 《你不知道的JavaScript(中卷)》