在日常写代码的过程中,我很经常会用到 promises 语法。当我自以为了解 promises 详细用法时,却在一次讨论中被问住了:“你知道 promises 内部的实现过程是怎样的么?” 是的,回想起来,我只是知道该如何使用它,却不知道其内部真正的实现原理。这篇文章正是我自己的关于 promises 的回顾与总结。如果你看完了整篇文章,希望你也会更加理解 promises 的实现与原理。
我们将会从零开始,逐步实现一个自己的 promises。最终的代码将会和 Promises/A+ 规范相似,并且将会明白 promises 在异步编程中的重要性。当然,本文会假设读者已经拥有了关于 promises 的基础知识。
最简单的 Promises
让我们从最简单的 promises 实现开始吧。当我们想要将下面的代码
doSomething(function(value) {
console.log(‘Got a value:’ + value);
});
复制代码转变为
doSomething().then(function(value) {
console.log(‘Got a value:’ + value);
});
复制代码这个时候,我们需要怎么做呢?非常简单的方式就是,将原来的 doSomething()函数从原来的写法
function doSomething(callback) {
var value = 42;
callback(value);
}
复制代码转变为如下这种 ‘promise’ 写法:
function doSomething() {
return {
then: function(callback) {
var value = 42;
callback(value);
}
};
}
复制代码上面只是一个 callback 写法的一种语法糖包装而已,看起来毫无意义。不过,这是个非常重要的转变,我们已经开始触达了 promises 的一个核心理念:
Promises 捕获最终值( eventual values ),并将其放入到一个 Object 中。
Ps: 这里有必要解释“最终值”的概念。它是异步函数的返回值,状态是不确定的,有可能成功,也有可能失败(如下图)。
关于 Promises 与最终值( eventual values ),下文会包含更多的讨论。
定义一个简单的 Promise 函数
上面简单的改写并不足以对 promise 的特性做任何的说明,让我们来定义一个真正的 promise 函数吧:
function Promise(fn) {
var callback = null;
this.then = function(cb) {
callback = cb;
};
function resolve(value) {
callback(value);
}
fn(resolve);
}
复制代码代码解析:将then的写法拆分,同时引入了resolve函数,方便处理 Promise 的传入对象(函数)。同时,使用callback作为沟通then函数与resolve函数的桥梁。这个代码实现,有一点 Promise 该有的样子了,不是么?
在此基础上,我们的doSomething()函数将会写成这种形式:
function doSomething() {
return new Promise(function(resolve) {
var value = 42;
resolve(value);
});
}
复制代码当我们尝试执行的时候,会发现执行会报错。这是因为,在上面的代码实现中,resolve()会比then更早被调用,此时的callback还是null。为了解决这个问题,我们使用setTimeout的方式 hack 一下:
function Promise(fn) {
var callback = null;
this.then = function(cb) {
callback = cb;
};
function resolve(value) {
// 强制此处的 callback 在 event loop 的下一个
// 迭代中调用,这样 then()将会在其之前执行
setTimeout(function() {
callback(value);
}, 1);
}
fn(resolve);
}
复制代码经过这样的修改之后,我们的代码将可以成功运行。
这样的代码糟糕透了
我们设想的实现,是可以在异步情况下也可以正常工作的。但是此时的代码,是非常脆弱的。只要我们的then()函数中包含有异步的情况,那么变量callback将会再次变成null。既然这个代码这么渣渣,为什么还要写下来呢?因为上面的模式很方便我们待会的拓展,同时,这个简单的写法,也可以让大脑对then、resolve的工作方式有一个初步的了解。下面我们考虑在此基础上做一定的改进。
Promises 拥有状态
Promises 是拥有状态的,我们需要先了解 Promises 中都有哪些状态:
一个 promise 在等待最终值的时候,将会是 pending 状态,当得到最终值的时候,将会是 resolved 状态。
当一个 promise 成功得到最终值的时候,它将会一直保持这个值,不会再次 resolve。
(当然,一个 promise 的状态也可以是 rejected,下文会细述)
为了将状态引入到我们的代码实现中,我们将原来的代码改写为下面:
function Promise(fn) {
var state = ‘pending’;
//value 表示通过resolve函数传递的参数
var value;
//deferred 用于保存then()里面的函数参数
var deferred;
function resolve(newValue) {
value = newValue;
state = ‘resolved’;
if(deferred) {
handle(deferred);
}
}
function handle(onResolved) {
if(state === ‘pending’) {
deferred = onResolved;
return;
}
onResolved(value);
}
this.then = function(onResolved) {
handle(onResolved);
};
fn(resolve);
}
复制代码这个代码看起来更加复杂了。不过此时的代码可以让调用方任意调用then()方法,也可以任意使用resolve()方法了。它也可以同时运行在同步、异步的情况下。
代码解析:代码中使用了state这个flag。同时,then()与resolve()将公共的逻辑提取到了一个新的函数handle()中:
当then()比resolve()更早被调用的时候,此时的状态是 pending,对应的 value 值并没有准备好。我们将then()里面对应的回调参数保存在 deferred 中,方便 promise 在获取到 resolved 的时候调用。
当resolve()比then()更早被调用的时候,此时的状态设置为 resolved,对应的 value 值也已经得到。当then()被调用的时候,直接调用then()里面对应的回调参数即可。
由于then()与resolve()将公共的逻辑提取到了一个新的函数handle()中,因此不管上面的两个 case 谁被触发,最终都会执行 handle 函数。
如果你仔细看会发现,此时的setTimeout已经不见了。我们通过 state 的状态控制,已经得到了正确的执行顺序。当然,下面的文章中,还有会使用到setTimeout的时候。
通过使用 promise,我们调用对应方法的顺序将不会受到任何影响。只要符合我们的需求,在任何时刻调用resolve()比then()都不会影响其内部逻辑。
此时,我们可以尝试多次调用then方法,会发现每一次得到的都是相同的 value 值。
var promise = doSomething();
promise.then(function(value) {
console.log(‘Got a value:’, value);
});
promise.then(function(value) {
console.log(‘Got the same value again:’, value);
});
复制代码
链式 Promises
在我们日常针对 promises 的编程中,下面的链式模式是常见的:
getSomeData()
.then(filterTheData)
.then(processTheData)
.then(displayTheData);
复制代码getSomeData()返回的是一个 promise,此时可以通过调用then()方法。但值得注意的是,第一个then()方法的返回值也必须是一个 promise,这样才可以让我们的链式 promises 一直延续下去。
then()方法必须永远返回一个 promise。
为了实现这个目的,我们将代码做进一步的改造:
function Promise(fn) {
var state = ‘pending’;
var value;
var deferred = null;
function resolve(newValue) {
value = newValue;
state = ‘resolved’;
if(deferred) {
handle(deferred);
}
}
function handle(handler) {
if(state === ‘pending’) {
deferred = handler;
return;
}
if(!handler.onResolved) {
handler.resolve(value);
return;
}
var ret = handler.onResolved(value);
handler.resolve(ret);
}
this.then = function(onResolved) {
return new Promise(function(resolve) {
handle({
onResolved: onResolved,
resolve: resolve
});
});
};
fn(resolve);
}
复制代码呼啦~ 现在的代码让人看起来似乎有点抓狂😩。哈哈哈,你是否会庆幸一开始的时候我们代码不是那么复杂呢?这里面真正的一个关键点在于:then()方法永远返回一个新的 promise。
doSomething().then(function(result){
console.log("first result : ", result);
return 88;
}).then(function(secondResult){
console.log("second result : ", secondResult);
return 99;
})
复制代码让我们来详细看看第二个 promise 的 resolve 过程。它接收来自第一个 promise 的 value 值。详细的过程发生在 handle()方法的底部。入参handler带有两个参数:一个是 onResolved回调,一个是对resolve()方法的引用。在这里,每一个新的 promise 都会有一个对内部方法resolve()的拷贝以及对应的运行时闭包。这是连接第一个 promise 与第二个 promise 的桥梁。
在代码中,我们可以得到第一个 promise 的 value 值:
var ret = handler.onResolved(value);
复制代码在上面的例子中,handler.onResolved表示的是:
function(result){
console.log("first result : ", result);
return 88;
}
复制代码也就是说,handler.onResolved实际上返回的是第一个 promise 的 then 被调用时候的传入参数(函数)。第一个 handler 的返回值被用于第二个 promise 的 resolve 传入参数。
这就是整个链式 promise 的工作方式。
如果我们想要将所有的 then 返回的结果,该怎么做呢?我们可以使用一个数组,来存放每一次的返回值:
doSomething().then(function(result) {
var results = [result];
results.push(88);
return results;
}).then(function(results) {
results.push(99);
return results;
}).then(function(results) {
console.log(results.join(’, ');
});
// the output is
//
// 42, 88, 99
复制代码
promises 永远 resolve 返回的是一个值。当你想要返回多个值的时候,可以通过创建某些符合结构来实现(如数组、object等)。
then 中的传入参数是可选的
then() 中的传入参数(回调函数)是并不是必填的。如果为空,在链式 promise 中,将会返回前一个 promise 的返回值。
doSomething().then().then(function(result) {
console.log(‘got a result’, result);
});
// the output is
//
// got a result 42
复制代码你可以查看handle()中的实现方式,当前一个 promise 没有 then 的传入参数的时候,它会 resolve 前一个 promise 的value 值:
if(!handler.onResolved) {
handler.resolve(value);
return;
}
复制代码在链式 promise 中返回新的 promise
我们的链式 promise 实现,依然显得有些简单。这里的 resolve 返回的是一个简单的值。假如想要 resolve 返回的是一个新的 promise 呢?比如下面的方式:
doSomething().then(function(result) {
// doSomethingElse 返回的是一个promise
return doSomethingElse(result);
}).then(function(finalResult) {
console.log(“the final result is”, finalResult);
});
复制代码如果是这样的情况,那么我们上面的代码似乎无法应对这样的情况。对于紧随其后的那个 promise 而言,它得到的 value 值将会是一个 promise。为了得到预期的值,我们需要这样做:
doSomething().then(function(result) {
// doSomethingElse 返回的是一个promise
return doSomethingElse(result);
}).then(function(anotherPromise) {
anotherPromise.then(function(finalResult) {
console.log(“the final result is”, finalResult);
});
});
复制代码OMG… 这样的实现实在是太糟糕了。难道作为使用者,我还要每一次都需要自己来手动书写这些冗余的代码么?是否可以在 promise 代码内部处理一下这些逻辑呢?实际上,我们只需要在已有代码中的 resolve()中增加一点判断即可:
function resolve(newValue) {
if(newValue && typeof newValue.then === ‘function’) {
newValue.then(resolve);
return;
}
state = ‘resolved’;
value = newValue;
if(deferred) {
handle(deferred);
}
}
复制代码上面的代码逻辑中我们看到,resolve()中如果遇到的是 promise,将会一直迭代调用resolve()。直到最后获得的值不再是一个 promise,才会依照已有的逻辑继续执行。
还有一个值得注意的点:看看代码中是如何判定一个对象是不是具有 promise 属性的?通过判定这个对象是否有then方法。这种判定方法被称为 “鸭子类型”(我们并不关心对象是什么类型,到底是不是鸭子,只关心行为)。
这种宽松的界定方式,可以使得具体的不同 promise 实现彼此之间有一个很好地兼容。
Promises 的 rejecting
在链式 promise 章节中,我们的实现已经相对而言是非常完整的。但是我们并没有讨论到 promises 中的错误处理。
在 promise 的决议过程中,如果发生了错误,那么 promise 将会抛出一个拒绝决议,同时给出对应的理由。对于调用者,怎么知道错误发生了呢?可以通过 then()方法的第二个传入参数(函数):
doSomething().then(function(value) {
console.log(‘Success!’, value);
}, function(error) {
console.log(‘Uh oh’, error);
});
复制代码
正如上面提到的,一个 promise 会从初始状态 pending 转换为要么是resolved 状态,要么是 rejected 状态。这两者,只能有一个作为最终的状态。对应到then()的两个参数,只有一个会被真正执行。
在 promise 内部实现中,同样允许有一个reject()函数来处理 reject 状态,可以看做是 resolve()函数的孪生兄弟。此时,doSomething()函数也将会被改写为支持错误处理的方式:
function doSomething() {
return new Promise(function(resolve, reject) {
var result = somehowGetTheValue();
if(result.error) {
reject(result.error);
} else {
resolve(result.value);
}
});
}
复制代码对于此,我们的代码该做如何的对应改造呢?来看代码:
function Promise(fn) {
var state = ‘pending’;
var value;
var deferred = null;
function resolve(newValue) {
if(newValue && typeof newValue.then === ‘function’) {
newValue.then(resolve, reject);
return;
}
state = ‘resolved’;
value = newValue;
if(deferred) {
handle(deferred);
}
}
function reject(reason) {
state = ‘rejected’;
value = reason;
if(deferred) {
handle(deferred);
}
}
function handle(handler) {
if(state === ‘pending’) {
deferred = handler;
return;
}
var handlerCallback;
if(state === 'resolved') {
handlerCallback = handler.onResolved;
} else {
handlerCallback = handler.onRejected;
}
if(!handlerCallback) {
if(state === 'resolved') {
handler.resolve(value);
} else {
handler.reject(value);
}
return;
}
var ret = handlerCallback(value);
handler.resolve(ret);
}
this.then = function(onResolved, onRejected) {
return new Promise(function(resolve, reject) {
handle({
onResolved: onResolved,
onRejected: onRejected,
resolve: resolve,
reject: reject
});
});
};
fn(resolve, reject);
}
复制代码代码解析:不仅仅新增了一个reject()函数,而且handle()方法内部也增加了对 reject的逻辑处理:通过对state的判断,来决定具体执行handler的 reject/resolved。
不可知的错误,同样应该引发rejection
上面的代码,只对已知的错误进行了处理。当发生某些不可知错误的时候,同样应该引发 rejection。需要在对应的处理函数中增加try…catch :
首先是在resolve()方法中:
function resolve(newValue) {
try {
// … as before
} catch(e) {
reject(e);
}
}
复制代码同样的,在 handle()执行具体 callback的时候,也可能发生未知的错误:
function handle(handler) {
// … as before
var ret;
try {
ret = handlerCallback(value);
} catch(e) {
handler.reject(e);
return;
}
handler.resolve(ret);
}
复制代码Promises 会吞下错误
有时候,对于 promises 的错误解读,将会导致 promises 吞下错误。这是个经常坑开发者的点。
让我们来考虑这个例子:
function getSomeJson() {
return new Promise(function(resolve, reject) {
var badJson = “
resolve(badJson);
});
}
getSomeJson().then(function(json) {
var obj = JSON.parse(json);
console.log(obj);
}, function(error) {
console.log(‘uh oh’, error);
});
复制代码这段代码将会如何进行呢?在then()中的 resolve 执行的是对 JSON 的解析。它以为能够执行,结果却抛出了异常,因为传入的 value 值并不是 JSON 格式。我们写了一个 error callback 来捕获这个错误。这样是没有问题,对吧?
不,结果可能并不符合你的期望。此时的 error callback 并不会触发。结果将会是:控制台上没有任何的 log 输出。这个错误就这样被平静地吞掉了。
为什么会这样?因为我们的错误发生在then()的 resolve 回调内部,源码上看是发生在 handle()方法内部。这将会导致的是,then()返回的新的 promise 将会被触发 reject,而不是现有的这个 promise 会触发 reject:
function handle(handler) {
// … as before
var ret;
try {
ret = handlerCallback(value);
} catch(e) {
// 到达这里,触发的是handler.reject()
// 这是then()返回的新的promise的reject()
// 如果改成 handler.onRejected(ex),将会触发本promise的reject()
handler.reject(e);
return;
}
handler.resolve(ret);
}
复制代码如果将上面代码中的catch部分改写成:handler.onRejected(ex);将会触发的是本 promise 的reject()。但这就违背了 promises 的原则:
一个 promise 会从初始状态 pending 转换为要么是 resolved 状态,要么是 rejected 状态。这两者,只能有一个作为最终的状态。对应到then()的两个参数,只有一个会被真正执行。
因为已经触发了 resolved 状态,那么久不可能再次触发 rejected 状态。错误是在具体执行 resolved 函数的时候发生的,那么这个 error,将会被下一个 promise 捕获。
我们可以这样验证:
getSomeJson().then(function(json) {
var obj = JSON.parse(json);
console.log(obj);
}).then(null, function(error) {
console.log("an error occured: ", error);
});
复制代码这可能是 promises 中最坑人的一个点了。当然,只要理解了其中的缘由,那么就可以很好地避免。为了更好地体验,我们有什么解决方法来规避这个坑呢?请看下一节:
done()来帮忙
大部分的 promise 库都包含有一个 done()方法。它实现的功能和then()方法相似,只是很好的规避了刚刚提到的then()的坑。
done()方法可以像then()那样被调用。两者之间主要有两点不同:
done()方法返回的不是一个 promise
done()中的任何错误将不会被 promise 实现捕获(直接抛出)
在我们的例子中,如果使用done()方法,将会更加保险:
getSomeJson().done(function(json) {
// when this throws, it won’t be swallowed
var obj = JSON.parse(json);
console.log(obj);
});
复制代码从rejection中恢复
从 promise 中的 rejection 恢复是有可能的。如果在一个包含有 rejection 的 promise 中增加更多的then()方法,那么从这个then() 开始,将会延续链式 promise 的正常处理流程:
aMethodThatRejects().then(function(result) {
// won’t get here
}, function(err) {
// since aMethodThatRejects calls reject()
// we end up here in the errback
return “recovered!”;
}).then(function(result) {
console.log("after recovery: ", result);
}, function(err) {
// we won’t actually get here
// since the rejected promise had an errback
});
// the output is
// after recovery: recovered!
复制代码Promise 决议必须是异步的
在本文的开头,我们使用了一个 hack 来让我们的简单代码能够正确允许。还记得么?使用了一个 setTimeout。当我们完善了对应的逻辑之后,这个 hack 就没有再使用了。但事实是:Promises/A+ 规范要求 promise 决议必须是一步的。为了实现这个需求,最简单的做法就是再次使用 setTimeout将我们的handle()方法包装一层:
function handle(handler) {
if(state === ‘pending’) {
deferred = handler;
return;
}
setTimeout(function() {
// … as before
}, 1);
}
复制代码非常简单的实现。但是,实际上的 promises 库并不倾向于使用setTimeout。如果对应的库是用于 NodeJS,那么它们倾向于使用 process.nextTick,如果对应的库是用于浏览器,那么它们倾向于使用setImmediate。
为什么
具体的做法我们知道了,但是为什么规范中会有这样的要求呢?
为了确保一致性与可信赖的执行过程。让我们考虑这样的情况:
var promise = doAnOperation();
invokeSomething();
promise.then(wrapItAllUp);
invokeSomethingElse();
复制代码上面的代码会被怎样执行呢?基于命名,你可能设想这个执行过程会是这样的:invokeSomething() -> invokeSomethingElse() -> wrapItAllUp()。但实际上,这取决于在我们当前的实现过程中,promise 的 resolve 过程是同步的还是异步的。如果doAnOperation()的 promise 执行过程是异步的,那么其执行过程将会是设想的流程。如果doAnOperation()的 promise 执行过程是同步的,它真实的执行过程将会是invokeSomething() -> wrapItAllUp() -> invokeSomethingElse()。这时,可能会导致某些意想不到的后果。
因此,为了确保一致性与可信赖的执行过程。promise 的 resolve 过程被要求是异步的,即使本身可能只是简单的同步过程。这样做,可以让所有的使用体验都是一直的,开发者在使用过程中,也不再需要担心各种不同的情况的兼容。
结论
如果读到了这里,那么可以确定是真爱了!我们将 promises 的核心概念都讲了一遍。当然,文章中的代码实现,大部分都是简陋的。可能也会和真正的代码库实现有一定的出入。但希望不妨碍您对整体 promises 的理解。更多的关于 promises 的实现细节(如:all()、race等),可以查看更多的文档与源码实现。
当真正理解了 promises 的工作原理以及它的一些边界情况,我才真正喜欢上它。从此我的项目中关于 promises 的代码也变得更加简洁。关于 promises,还有很多内容值得去探讨,本文只是一个开始。