手写 Promise 大概是前端面试里出现频率最高的一道代码题。多数人能背出「三种状态、状态不可逆、then 返回新 Promise」,但真拿笔写,写到第三步就卡住了,卡的地方几乎都一样:then 里返回一个 Promise 时,外层那个 Promise 怎么知道该等它。
这道题的价值不在于背,而在于每一步都是被一个具体问题逼出来的。为什么要用 setTimeout 包一层?因为同步 resolve 会跑在 then 注册之前。为什么要引入状态?因为异步完成之后再注册的回调也得能执行。为什么 resolve 里要检查参数有没有 then 方法?因为链式调用要靠它衔接。这篇按这个顺序,从一个十行的雏形一路加到完整实现,每加一段都先说清楚它在解决什么。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Promise 的三种状态和 A+ 规范里最关键的几条约束
- 十行代码的极简雏形:
then注册 +resolve触发 - 为什么必须加延时机制,
setTimeout和微任务的差别 - 状态机解决了什么,为什么状态只能转一次
- 链式 Promise 的衔接机制,bridge promise 是什么
- 失败处理与错误冒泡是怎么自然实现出来的
resolutionProcedure逐段对照 A+ 规范条款- 观察者模式视角下的 Promise
# 一、先把要实现的行为定下来
动手写之前得先明确目标行为。这一节把原生 Promise 的用法过一遍,后面每实现一步都能对回来验证。
# 1.1 基本用法
new Promise(function(resolve, reject) {
//待处理的异步逻辑
//处理结束后,调用resolve或reject方法
})
新建一个 promise 很简单,只需要 new 一个 promise 对象即可。Promise 是一个构造函数,它接受一个函数作为参数,并且会返回 promise 对象,这就给链式调用提供了基础。
Promise 函数的使命,就是构建出它的实例,并且负责帮我们管理这些实例。而这些实例有以下三种状态:
pending:初始状态,未履行也未拒绝fulfilled:操作成功完成rejected:操作失败
原文这里把 pending 写成了「位履行或拒绝」,是个错别字,应为「未履行」。
pending 状态的 Promise 对象可能以 fulfilled 状态返回了一个值,也可能被某种理由(异常信息)拒绝(reject)了。当其中任一种情况出现时,Promise 对象的 then 方法绑定的处理方法(handlers)就会被调用。then 方法分别指定了成功和失败两个回调函数。

var promise = new Promise(function(resolve, reject) {
if (/* 异步操作成功 */){
resolve(value);
} else {
reject(error);
}
});
promise.then(function(value) {
// 如果调用了resolve方法,执行此函数
}, function(value) {
// 如果调用了reject方法,执行此函数
});
这段代码展示了 promise 对象运行的基本骨架,new 的时候把异步逻辑放进去,成功调 resolve,失败调 reject,外面用 then 接。
下面这个例子更接近真实用法,把一个基于回调的 XMLHttpRequest 包成 Promise:
var getJSON = function(url) {
var promise = new Promise(function(resolve, reject){
var client = new XMLHttpRequest();
client.open("GET", url);
client.onreadystatechange = handler;
client.responseType = "json";
client.setRequestHeader("Accept", "application/json");
client.send();
function handler() {
if (this.status === 200) {
resolve(this.response);
} else {
reject(new Error(this.statusText));
}
};
});
return promise;
};
getJSON("/posts.json").then(function(json) {
console.log('Contents: ' + json);
}, function(error) {
console.error('出错了', error);
});
这段值得多看两眼,因为它是 Promise 最原始的用途,把回调风格的 API 转成 Promise 风格。注意 handler 里成功走 resolve、失败走 reject,一次只会调一个。这也是「Promise 化」这个词的来源,Node 的 util.promisify 干的就是这件事。
resolve 方法和 reject 方法调用时都带有参数,它们的参数会被传递给回调函数。reject 方法的参数通常是 Error 对象的实例,而 resolve 方法的参数除了正常的值以外,还可能是另一个 Promise 实例,比如像下面这样:
var p1 = new Promise(function(resolve, reject){
// ... some code
});
var p2 = new Promise(function(resolve, reject){
// ... some code
resolve(p1);
})
上面代码中,p1 和 p2 都是 Promise 的实例,但是 p2 的 resolve 方法将 p1 作为参数,这时 p1 的状态就会传递给 p2。如果调用的时候 p1 的状态是 pending,那么 p2 的回调函数就会等待 p1 的状态改变;如果 p1 的状态已经是 fulfilled 或者 rejected,那么 p2 的回调函数将会立刻执行。
这一条先在这里记下,它是后面第二部分最难实现的一块。「resolve 一个 Promise 就等于把状态权交给它」这个行为,在规范里对应 resolutionProcedure,也是链式调用能成立的根本原因。
# 1.2 promise 捕获错误
Promise.prototype.catch 方法是 Promise.prototype.then(null, rejection) 的别名,用于指定发生错误时的回调函数。
这句「别名」很重要。catch 不是什么特殊机制,它就是 then 的第二个参数,只不过换了个位置写。理解这一点,第二部分实现 catch 就只是一行转发的事。
getJSON("/visa.json").then(function(result) {
// some code
}).catch(function(error) {
// 处理前一个回调函数运行时发生的错误
console.log('出错啦!', error);
});
Promise 对象的错误具有「冒泡」性质,会一直向后传递,直到被捕获为止。错误总是会被下一个 catch 语句捕获。
getJSON("/visa.json").then(function(json) {
return json.name;
}).then(function(name) {
// proceed
}).catch(function(error) {
//处理前面任一个then函数抛出的错误
});
冒泡这个特性看着像是额外设计的,实际上它是「then 没传失败回调时把错误原样往下传」的自然结果,第二部分的 2.7 节实现完你会发现它是白送的。
# 1.3 常用的 promise 方法
这一节把要实现的静态方法行为定下来。这几个组合方法在真实业务里怎么选、allSettled 和 any 又补上了什么,我单独整理在 Promise.all、race、allSettled、any 四个组合方法怎么选 这篇里,这里只讲实现所需要的规则。
Promise.all 方法
Promise.all 方法用于将多个 Promise 实例包装成一个新的 Promise 实例。
var p = Promise.all([p1,p2,p3]);
上面代码中,Promise.all 方法接受一个数组作为参数,p1、p2、p3 都是 Promise 对象的实例。参数不一定是数组,但必须具有 iterator 接口。
p 的状态由 p1、p2、p3 决定,分成两种情况:
- 只有
p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时三者的返回值组成一个数组传递给p的回调函数 - 只要
p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值会传递给p的回调函数
自己实现 all 的时候,这两条规则对应两个要点。一是要维护一个计数器和一个结果数组,结果按下标存而不是按完成顺序 push,这样才能保证顺序和输入一致。二是失败要直接 reject 整个外层 Promise,而且因为状态只能转一次,后续失败会被自动忽略,不需要额外加锁。
// 生成一个Promise对象的数组
var promises = [2, 3, 5, 7, 11, 13].map(function(id){
return getJSON("/get/addr" + id + ".json");
});
Promise.all(promises).then(function(posts) {
// ...
}).catch(function(reason){
// ...
});
Promise.race 方法
Promise.race 方法同样是将多个 Promise 实例包装成一个新的 Promise 实例。
var p = Promise.race([p1,p2,p3]);
只要 p1、p2、p3 之中有一个实例率先改变状态,p 的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 p。
race 的实现比 all 简单得多,遍历一遍,每个都挂上 then(resolve, reject) 就完事了。全靠「状态只能转一次」这条规则兜着,第一个 settle 的赢,后面的调用全部无效。这也是为什么状态机那一步必须做对,做对了后面很多东西就是免费的。
如果 Promise.all 和 Promise.race 的参数不是 Promise 实例,就会先调用下面讲到的 Promise.resolve 方法,将参数转为 Promise 实例再进一步处理。
Promise.resolve
有时需要将现有对象转为 Promise 对象,Promise.resolve 方法就起到这个作用。
var jsPromise = Promise.resolve($.ajax('/whatever.json'));
上面代码将 jQuery 生成的 deferred 对象转为一个新的 ES6 的 Promise 对象。这一步能成,靠的就是 deferred 上有 then 方法,也就是它是个 thenable。