Promise
不把自己程序的 continuation 传给第三方,而是希望第三方给我们提供了解其任务何时结束的能力,然后由我们自己的代码来决定下一步做什么。这种范式就称为 Promise。
未来值
运算 x + y 假定了 x 和 y 都已经设定。
var x, y = 2;
console.log(x + y);
把 x
和 y
当作未来值,并且表达了一个运算 add()
。这个运算(从外部看)不在意 x
和 y
现在是否都已经可用。
function add (getX, getY, cb) {
var x, y;
getX (function (xVal) {
x = xVal;
if (y != undefined) {
cb (x + y);
}
});
getY (function (yVal) {
y = yVal;
if (x != undefined) {
cb (x + y);
}
});
}
add(fetchX, fetchY, function (sum) {
console.log(sum);
});
为了统一处理现在 和将来 ,我们把它们都变成了将来 ,即所有的操作都成了异步的。
试试用 Promise 来表达 x +
:
function add (xPromise, yPromise) {
return Promise.all([xPromise, yPromise])
.then(function (values) {
return values[0] + values[1];
});
}
add(fetchX(), fetchY())
.then(
function (sum) {
console.log(sum);
},
function (err) {
console.log(err);
}
);
从外部看,由于 Promise 封装了依赖于时间的状态——等待底层值的完成或拒绝,所以 Promise 本身是与时间无关的。因此,Promise 可以按照可预测的方式组成(组合),而不用关心时序或底层的结果。
一旦 Promise 决议,它就永远保持在这个状态。此时它就成为了不变值 (immutable value),可以根据需求多次查看。
Promise 是一种封装和组合未来值的易于复用的机制。
完成事件
单独的 Promise 展示了未来值的特性。也可以从另外一个角度看待 Promise 的决议:一种在异步任务中作为两个或更多步骤的流程控制机制,时序上的 this-then-that。
在典型的 JavaScript 风格中,如果需要侦听某个通知,可以把对通知的需求重新组织为对 foo()
发出的一个完成事件 (completion event, 或 continuation 事件)的侦听。
使用回调的话,通知就是任务(foo()
)调用的回调。而使用 Promise 的话,把这个关系反转了过来,侦听来自 foo()
的事件,然后在得到通知的时候,根据情况继续。
foo (x) {
// 一些耗时的操作
}
foo(2022);
on (foo 'completion') {
// 一些操作
}
on (foo 'error') {
// 一些操作
}
调用 foo()
,然后建立了两个事件侦听 器,一个用于"completion" ,一个用于 "error" —— foo()
调用的两种可能结果。从本质上讲,foo()
并不需要了解调用代码订阅了这些事件,这样就很好地实现了关注点分离 。
function foo (x) {
// 一些耗时的操作
return listener;
}
var evt = foo(2022);
evt.on('completion', function () {
// 一些操作
});
evt.on('error', function () {
// 一些操作
});
所以对回调模式的反转实际上是对反转的反转,或者称为反控制反转 ——把控制返还给调用代码,这也是最开始想要的效果。
var evt = foo(42);
bar(evt);
baz(evt);
从本质上说,evt 对象就是分离的关注点之间一个中立的第三方协商机制。
Promise “事件”
事件侦听对象 evt 就是 Promise 的一个模拟。
在基于 Promise 的方法中,前面的代码片段会让 foo()
创建并返回一个 Promise 实例,而且这个 Promise 会被传递到 bar()
和 baz()
。
function foo (x) {
// 一些耗时的操作
return new Promise(function (resolve, reject) {
// 一些操作
});
}
var p = foo();
bar(p);
baz(p);
Promise 决议并不一定要像前面将 Promise 作为未来值查看时一样会涉及发送消息。它也可以只作为一种流程控制信号。
Promise 信任问题
把一个回调传入工具 foo()
时可能出现如下问题:
- 调用回调过早;
- 调用回调过晚(或不被调用);
- 调用回调次数过少或过多;
- 未能传递所需的环境和参数;
- 吞掉可能出现的错误和异常。
Promise 的特性就是专门用来为这些问题提供一个有效的可复用的答案。
调用过早
在这类问题中,一个任务有时同步完成,有时异步完成,这可能会导致竞态条件。
根据定义,Promise 就不必担心这种问题,因为即使是立即完成的 Promise(类似于 new Promise(function(resolve){ resolve(42); })
)也无法被同步观察到。
对一个 Promise 调用 then()
的时候,即使这个 Promise 已经决议,提供给 then()
的回调也总会被异步调用。
调用过晚
Promise 创建对象调用 resolve()
或 reject()
时,这个 Promise 的 then()
注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事件点上一定会被触发。
同步查看是不可能的,所以一个同步任务链无法以这种方式运行来实现按照预期有效延迟另一个回调的发生。也就是说,一个 Promise 决议后,这个 Promise 上所有的通过 then()
注册的回调都会在下一个异步时机点上依次被立即调用。这些回调中的任意一个都无法影响或延误对其他回调的调用。
p.then(function() {
p.then(function () {
console.log('C');
});
console.log('A');
});
p.then(function () {
console.log('B');
});
// A B C
::: warning
两个独立 Promise 上链接的回调的相对顺序无法可靠预测。永远都不应该依赖于不同 Promise 间回调的顺序和调度。
:::
回调未调用
没有任何东西(甚至 JavaScript 错误)能阻止 Promise 通知它的决议(如果它决议了的话)。如果对一个 Promise 注册了一个完成回调和一个拒绝回调,那么 Promise 在决议时总是会调用其中的一个。
但是,如果 Promise 本身永远不被决议呢?即使这样,Promise 也提供了解决方案,其使用了一种称为竞态的高级抽象机制:
function timeoutPromise (delay) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
reject(new Error('timeout'));
}, delay);
});
}
Promise.race([foo(), timeoutPromise(3000)])
.then(function () {
// foo done
}, function (err) {
// foo reject or timeout
});
保证一个 foo()
有一个输出信号,防止其永久挂住程序。
回调次数过少或过多
根据定义,回调被调用的正确次数应该是 1。“过少”的情况就是调用 0 次,和前面解释过的“未被”调用是同一种情况。
Promise 的定义方式使得它只能被决议一次。如果出于某种原因,Promise 创建代码试图调用resolve()
或 reject()
多次,或者试图两者都调用,那么这个 Promise 将只会接受第一次决议,并默默地忽略任何后续调用。
由于 Promise 只能被决议一次,所以任何通过 then()
注册的(每个)回调就只会被调用一次。
当然,如果把同一个回调注册了不止一次(比如 p.then(f); p.then(f);
),那它被调用的次数就会和注册次数相同。响应函数只会被调用一次,但这个保证并不能预防你搬起石头砸自己的脚。
未能传递参数 / 环境值
Promise 至多只能有一个决议值(完成或拒绝)。
如果没有用任何值显式决议,那么这个值就是 undefined
,这是 JavaScript 常见的处理方式。但不管这个值是什么,无论当前或未来,它都会被传给所有注册的(且适当的完成或拒绝)回调。
如果使用多个参数调用 resovle()
或者 reject()
,第一个参数之后的所有参数都会被默默忽略。
如果要传递多个值,就必须要把它们封装在单个值中传递,比如通过一个数组或对象。
吞掉错误或异常
如果拒绝一个 Promise 并给出一个理由(也就是一个出错消息),这个值就会被传给拒绝回调。
如果在 Promise 的创建过程中或在查看其决议结果过程中的任何时间点上出现了一个 JavaScript 异常错 误,比如一个 TypeError 或 ReferenceError ,那这个异常就会被捕捉,并且会使这个 Promise 被拒绝。
Promise 甚至把 JavaScript 异常也变成了异步行为,进而极大降低了竞态条件出现的可能。
是可信任的 Promise 吗
Promise 并没有完全摆脱回调。它们只是改变了传递回调的位置。并不是把回调传递给 foo()
,而是从 foo()
得到某个东西(外观上看是一个真正的Promise),然后把回调传给这个东西。
如何能够确定返回的这个东西实际上就是一个可信任的 Promise 呢?包含在原生 ES6 Promise 实现中的解决方案就是 Promise.resolve()
。
如果向 Promise.resolve()
传递一个非 Promise、非 thenable 的立即值,就会得到一个用这个值填充的 promise。
// p1 和 p2 的行为是一样的
var p1 = new Promise(function (resolve, reject) {
resolve(42);
});
var p2 = Promise.resolve(42);
如果向 Promise.resolve()
传递一个真正的 Promise,就只会返回同一个 promise:
var p1 = Promise.resolve(42);
var p2 = Promise.resolve(p1);
p1 === p2; // true
如果向 Promise.resolve()
传递了一个非 Promise 的 thenable 值,前者就会试图展开这个值,而且展开过程会持续到提取出一个具体的非类 Promise 的最终值。
Promise.resolve()
可以接受任何 thenable,将其解封为它的非 thenable 值。从 Promise.resolve()
得到的是一个真正的 Promise,是一个可以信任的值。如果传入的已经是真正的 Promise,那么你得到的就是它本身,所以通过 Promise.resolve()
过滤来获得可信任性完全没有坏处。
假设要调用一个工具 foo()
,且并不确定得到的返回值是否是一个可信任的行为良好的 Promise,但可以知道它至少是一个 thenable。Promise.resolve()
提供了可信任的 Promise 封装工具,可以链接使用:
foo(42)
.then(function (v) {
console.log(v);
});
// 更好的做法
Promise.resolve(foo(42))
.then(function (v) {
console.log(v);
});
建立信任
Promise 这种模式通过可信任的语义把回调作为参数传递,使得这种行为更可靠更合理。通过把回调的控制反转反转回来,我们把控制权放在了一个可信任的系统(Promise)中,这种系统的设计目的就是为了使异步编码更清晰。
链式流
可以把多个 Promise 连接到一起以表示一系列异步步骤。这种方式可以实现的关键在于以下两个 Promise 固有行为特性:
- 每次对 Promise 调用
then()
,它都会创建并返回一个新的 Promise,可以将其链接起来; - 不管从
then()
调用的完成回调(第一个参数)返回的值是什么,它都会被自动设置为被链接 Promise(第一点中的)的完成。
var p = Promise.resolve(21);
var p2 = p.then(function (v) {
console.log(v); // 21
return v * 2;
});
p2.then(function (v) {
console.log(v); // 42
});
如果必须创建一个临时变量 p2 (或 p3 等),还是有一点麻烦的。
var p = Promise.resolve(21);
p
.then(function (v) {
console.log(v); // 21
return v * 2;
})
.then(function (v) {
console.log(v); // 42
});
不管我们想要多少个异步步骤,每一步都能够根据需要等待下一步(或者不等!)。
一步步传递的值是可选的。如果不显式返回一个值,就会隐式返回 undefined ,并且这些 promise 仍然会以同样的方式链接在一起。这样,每个 Promise 的决议就成了继续下一个步骤的信号。
错误处理
错误处理最自然的形式就是同步的 try..catch
结构。遗憾的是,它只能是同步的,无法用于异步代码模式。
在回调中,一些模式化的错误处理方式已经出现,最值得一提的是 error-first 回调 风格:
function foo (cb) {
setTimeout(() => {
try {
var x = baz.bar();
cb(null, x);
} catch (error) {
cb(error);
}
}, 100);
}
foo (function (err, val) {
if (err) {
console.log('Error:', err);
} else {
console.log('Success:', val);
}
});
Promise 中的错误处理,其中拒绝处理函数被传递给 then()
。Promise 没有采用流行的 error-first 回调设计风格,而是使用了分离回调(split-callback)风格。一个回调用于完成情况,一个回调用于拒绝情况:
var p = Promise.reject('Oops');
p.then(
function fulfilled () {}, // never called
function rejected (err) {
console.log('Error:', err); // Error: Oops
}
);
尽管表面看来,这种出错处理模式很合理,但彻底掌握 Promise 错误处理的各种细微差别常常还是有些难度的。
var p = Promise.resolve(42);
p.then(
function fulfilled (msg) {
console.log(msg.toLowerCase()); // Error
},
function rejected (err) {} // never called
);
唯一可以被通知这个错误的 promise 是从 p.then()
返回的那一个,但在此例中没有捕捉。
绝望的陷阱
Promise 错误处理是一个“绝望的陷阱”设计。默认情况下,它假定你想要 Promise 状态吞掉所有的错误。如果你忘了查看这个状态,这个错误就会默默地(通常是绝望地)在暗处凋零死掉。
为了避免丢失被忽略和抛弃的 Promise 错误,一些开发者表示,Promise 链的一个最佳实践就是最后总以一个 catch()
结束,比如:
var p = Promise.resolve(42);
p.then(
function fulfilled (msg) {
console.log(msg.toLowerCase()); // Error
}
).catch(handleError);
因为没有为 then()
传入拒绝处理函数,所以默认的处理函数被替换掉了,而这仅仅是把错误传递给了链中的下一个 promise。因此,进入 p 的错误以及 p 之后进入其决议(就像 msg.toLowerCase()
)的错误都会传递到最后的 handleErrors()
。
如果 handleErrors()
本身内部也有错误怎么办呢?谁来捕捉它?还有一个没人处理的 promise:catch()
返回的那一个。并不能简单地在这个链尾端添加一个新的 catch()
,因为它很可能会失败。任何 Promise 链的最后一步,不管是什么,总是存在着在未被查看的 Promise 中出现未捕获错误的可能性,尽管这种可能性越来越低。
处理未捕获的情况
有些 Promise 库增加了一些方法,用于注册一个类似于“全局未处理拒绝”处理函数的东西,这样就不会抛出全局错误,而是调用这个函数。但它们辨识未捕获错误的方法是定义一个某个时长的定时器,比如 3 秒钟,在拒绝的时刻启动。如果 Promise 被拒绝,而在定时器触发之前都没有错误处理函数被注册,那它就会假定你不会注册处理函数,进而就是未被捕获错误。
更常见的一种看法是:Promsie 应该添加一个 done()
函数,从本质上标识 Promsie 链的结束。done()
不会创建和返回 Promise,所以传递给 done()
的回调显然不会报告一个并不存在的链接 Promise 的问题。
它的处理方式类似于你可能对未捕获错误通常期望的处理方式:done()
拒绝处理函数内部的任何异常都会被作为一个全局未处理错误抛出(基本上是在开发者终端上)。
var p = Promise.resolve(42);
p.then(
function fulfilled (msg) {
console.log(msg.toLowerCase()); // Error
}
)
.done(null, handleErrors);
// 如果 handleErrors () 引发了自身的异常,会被全局抛出到这里
相比没有结束的链接或者任意时长的定时器,这种方案看起来似乎更有吸引力。但最大的问题是,它并不是 ES6 标准的一部分,所以不管听起来怎么好,要成为可靠的普遍解决方案,它还有很长一段路要走。
浏览器有一个特有的功能是我们的代码所没有的:它们可以跟踪并了解所有对象被丢弃以及被垃圾回收的时机。所以,浏览器可以追踪 Promise 对象。如果在它被垃圾回收的时候其中有拒绝,浏览器就能够确保这是一个真正的未捕获错误,进而可以确定应该将其报告到开发者终端。
但是,如果一个 Promise 未被垃圾回收——各种不同的代码模式中很容易不小心出现这种情况——浏览器的垃圾回收嗅探就无法帮助你知晓和诊断一个被你默默拒绝的 Promise。
Promise 模式
Promise.all([])
Promise.all([])
需要一个参数,是一个数组,通常由 Promise 实例组成。从 Promise.all([])
调用返回的 promise 会收到一个完成消息。这是一个由所有传入 promise 的完成消息组成的数组,与指定的顺序一致(与完成顺序无关)。
var p1 = request('/api/1');
var p2 = request('/api/2');
Promise.all([p1, p2])
.then(function (msgs) {
return request('/api/3?v=' + msgs.join(','));
})
.then(function (msg) {
console.log(msg);
});
Promise.all([])
返回的主 promise 在且仅在所有的成员 promise 都完成后才会完成。如果这些 promise 中有任何一个被拒绝的话,主 Promise.all([])
promise 就会立即被拒绝,并丢弃来自其他所有 promise 的全部结果。
永远要记住为每个 promise 关联一个拒绝 / 错误处理函数,特别是从 Promise.all([])
返回的那一个。
Promise.race([])
尽管 Promise.all([ ])
协调多个并发 Promise 的运行,并假定所有 Promise 都需要完成,但有时候会想只响应“第一个跨过终点线的 Promise”,而抛弃其他 Promise。
这种模式传统上称为门闩,但在 Promise 中称为竞态。
与 Promise.all([])
类似,一旦有任何一个 Promise决议为完成,Promise.race([])
就会完成;一旦有任何一个 Promise 决议为拒绝,它就会拒绝。
var p1 = request('/api/1');
var p2 = request('/api/2');
Promise.race([p1, p2])
.then(function(msg) {
return request('/api/3?v=' + msg);
})
.then(function(msg) {
console.log(msg);
});
超时竞赛
Promise.race([
foo(),
timeoutPromise(3000)
])
.then(
function () {
// foo 正常完成
},
function (err) {
// foo 被拒绝 或 超时
}
);
finally
Promise 需要一个 finally()
回调注册,这个回调在 Promise 决议后总是会被调用,并且允许你执行任何必要的清理工作。
var p = Promise.resolve(42);
p.then(something)
.finally(cleanup)
.then(another)
.finally(cleanup);
all([]) 和 race([]) 的变体
- none([]): 都被拒绝后转化为完成值
- any([]): 忽略拒绝,只需要有一个完成即可
- first([]): 只要第一个 Promise 完成,它就会忽略后续的任何拒绝和完成
- last([]): 只要最后一个 Promise 完成,它就会忽略后续的任何拒绝和完成
并发迭代
有些时候会需要在一列 Promise 中迭代,并对所有 Promise都执行某个任务,非常类似于对同步数组可以做的那 样(比如 forEach()
、map()
、some()
和 every()
)。如果要对每个 Promise 执行的任务本身是同步的,那这些工具就可以工作,就像前面代码中的 forEach()
。
但如果这些任务从根本上是异步的,或者可以 / 应该并发执行,那可以使用这些工具的异步版本,许多库中提供了这样的工具。
Promise API
new Promise() 构造器
有启示性的构造器 Promise()
必须和 new
一起使用,并且必须提供一个函数回调。这个回调是同步的或立即调用的。这个函数接受两个函数回调,用以支持 promise 的决议。
var p = new Promise(function(resolve, reject) {
// 使用 resolve() 或 reject() 来决议 promise
});
reject()
就是拒绝这个 promise;但 resolve()
既可能完成 promise,也可能拒绝,要根据传入参数而定。如果传给 resolve()
的是一个非 Promise、非 thenable 的立即值,这个 promise 就会用这个值完成。
Promise.resolve() 和 Promise.reject()
创建一个已被拒绝的 Promise 的快捷方式是使用 Promise.reject()
:
// 以下两个等价
var p = new Promise(function(resolve, reject) {
reject(new Error('Whoops'));
});
var p2 = Promise.reject(new Error('Whoops'));
Promise.resolve()
常用于创建一个已完成的 Promise,使用方式与 Promise.reject()
类似。但是,Promise.resolve()
也会展开 thenable 值。在这种情况下,返回的 Promise 采用传入的这个 thenable 的最终决议值,可能是完成,也可能是拒绝。
如果传入的是真正的 Promise,Promise.resolve()
什么都不会做,只会直接把这个值返回。
then() 和 catch()
每个 Promise 实例(不是 Promise API 命名空间)都有 then()
和 catch()
方法,通过这两个方法可以为这个 Promise 注册完成和拒绝处理函数。Promise 决议之后,立即会调用这两个处理函数之一,但不会两个都调用,而且总是异步调用。
then()
接受一个或两个参数:第一个用于完成回调,第二个用于拒绝回调。如果两者中的任何一个被省略或者作为非函数值传入的话,就会替换为相应的默认回调。默认完成回调只是把消息传递下去,而默认拒绝回调则只是重新抛出(传播)其接收到的出错原因。
catch()
只接受一个拒绝回调作为参数,并自动替换默认完成回调。换句话说,它等价于 then(null, handleError)
:
p.then(fulfilled);
p.then(fulfilled, rejected);
p.catch(rejected); // 等价于 p.then(null, rejected)
then()
和 catch()
也会创建并返回一个新的 Promise,这个 promise 可以用于实现 Promise 链式流程控制。
Promise.all([]) 和 Promise.race([])
ES6 Promise API 静态辅助函数 Promise.all([])
和 Promise.race([])
都会创建一个 Promise 作为它们的返回值。这个 promise 的决议完全由传入的 promise 数组控制。
对 Promise.all([ ])
来说,只有传入的所有 promise 都完成,返回 promise 才能完成。如果有任何 promise 被拒绝,返回的主 promise 就立即会被拒绝(抛弃任何其他promise 的结果)。如果完成的话,会得到一个数组,其中包含传入的所有 promise 的完成值。对于拒绝的情况,只会得到第一个拒绝 promise 的拒绝理由值。
对 Promise.race([ ])
来说,只有第一个决议的 promise(完成或拒绝)取胜,并且其决议结果成为返回 promise 的决议。
Promise 局限性
顺序错误处理
由于一个 Promise 链仅仅是连接到一起的成员 Promise,没有把整个链标识为一个个体的实体,这意味着没有外部方法可以用于观察可能发生的错误。
如果构建了一个没有错误处理函数的 Promise 链,链中任何地方的任何错误都会在链中一直传播下去,直到被查看(通过在某个步骤注册拒绝处理函数)。
这意味着你可以在 p 上注册一个拒绝错误处理函数,对于链中任何位置出现的任何错误,这个处理函数都会得到通知:
p.catch(handleError);
但是,如果链中的任何一个步骤事实上进行了自身的错误处理(可能以隐藏或抽象的不可见的方式),那你的 handleErrors()
就不会得到通知。这可能是你想要的——毕竟这是一个“已处理的拒绝”——但也可能并不是。完全不能得到(对任何“已经处理”的拒绝错误的)错误通知也是一个缺陷,它限制了某些用例的功能。
很多时候并没有为 Promise 链序列的中间步骤保留的引用。因此,没有这样的引用,你就无法关联错误处理函数来可靠地检查错误。
单一值
根据定义,Promise 只能有一个完成值或一个拒绝理由。在简单的例子中,这不是什么问题,但是在更复杂的场景中,可能就会发现这是一种局限了。
一般的建议是构造一个值封装(比如一个对象或数组)来保持这样的多个信息。
- 分裂值
- 展开 / 传递参数
单决议
Promise 最本质的一个特征是:Promise 只能被决议一次(完成或拒绝)。在许多异步情况中,只会获取一个值一次,所以这可以工作良好。但是,还有很多异步的情况适合另一种模式——一种类似于事件和 / 或数据流的模式。
惯性
要在你自己的代码中开始使用 Promise 的话,一个具体的障碍是,现存的所有代码都还不理解 Promise。如果你已经有大量的基于回调的代码,那么保持编码风格不变要简单得多。
可以利用一些工具把需要回调的函数封装为支持 Promise 的函数,这个动作有时被称为“提升”或“Promise 工厂化”。
无法取消的 Promise
一旦创建了一个 Promise 并为其注册了完成和 / 或拒绝处理函数,如果出现某种情况使得这个任务悬而未决的话,你也没有办法从外部停止它的进程。
但如果没法避免的话,可以试试 Promise 之上更高级的抽象。
Promise 性能
把基本的基于回调的异步任务链与 Promise 链中需要移动的部分数量进行比较。很显然,Promise 进行的动作要多一些,这自然意味着它也会稍慢一些。
更多的工作,更多的保护。这些意味着 Promise 与不可信任的裸回调相比会更慢一些。