用 ES6 特性实现一个标准的轻量级 Promise
Promise 应该是目前 JavaScript 中最流行的异步流程控制解决方案,本文将介绍如何使用 JavaScript ES6 的语言特性,实现一个轻量级的通过了所有官方测例标准的 Promise 库。了解其原理,深入其实现。实际上,Promise 也早已被写入 JavaScript ES6 的标准,作为官方支持的标准异步流程控制解决方案之一。用 ES6 语法实现一个 ES6 中的 Promise,虽然看似没有必要,不过对于深入理解 Promise 以便更好的使用大有裨益。
为了您的最佳阅读体验,在阅读本文之前,建议您已经做到如下事情:
- 可以熟练使用至少任意一个 Promise 库,如 bluebird、Q 等;
- 了解 Promise 标准,可以看这里;
- 熟悉主要的 JavaScript ES6 特性;
构造函数之前的准备
我们都知道,Promise 总共有三种状态:pending、fullfilled(resolved)和 rejected。所以我们对于每一个 Promise 实例都需要一个变量记录其现有的状态。
然后还需要一个变量记录其 settled 之后的结果。另外,Promise 作为一个异步流程控制库,在上游的 Promise 还处于 pending 状态时下游 Promise 是不能执行的,所以我们至少需要一个数组来记录当前 Promise 还未 settled 时它后续的一些操作(你可以用两个数组分别记录 resolved 和 rejected 之后不同的操作;也可以用一个数组,然后每个元素都是包含两个属性的对象,分别记录 resolved 和 rejected 之后不同的操作)。
另外,借用面向对象的说法,这些变量对 Promise 而言应该是私有的,即不应该对外界暴露(这也是符合标准的)。所以待会儿构造函数之中应该定义一些私有变量,而 ES6 的 Symbol 类型则可以完美实现我们所需的私有变量。
代码如下:
1 | // 定义 Promise 状态 |
这里我们将 Promise 状态定义到一个对象之中,并且用属性名语义化状态,值其实没什么意义,但是这样写符合「让错误更早的暴露出来」的编程原则,让编译器(或解释器)去帮我们提前检测错误,总比写在字符串在运行时才把定位还不一定准确的错误暴露出来要好。
其次,还定义了几个用于定义私有变量的 Symbol。
构造函数
构造函数无非是给变量做一些初始化的工作,然后执行用户传入的函数。关键在于我们需要定义好传入的函数的参数,即resolve
和reject
函数。
先看代码:
1 | constructor(exec) { |
在构造函数里,首先限制了用户传入的参数必须为函数,然后将状态置为pending
,并初始化result
和callbacks
。
之后,尝试运行用户传入的函数,并提供我们自己定义的resolve
和reject
函数作为参数。因为运行函数可能会抛出难以预期的错误,所以外面用try...catch
包裹一层,并把错误用reject
处理,表示当前 Promise 被 reject。
这里的关键在于我们自己定义的resolve
和reject
函数。
其实resolve
和reject
的逻辑也很简单,首先因为标准里规定对于resolve
和reject
的调用必须是异步的,来避免阻塞(.then
方法的两个参数同理),所以外面套了一层nextTick
(这里对前后端做了兼容,前端指向setTimeout
,后端指向process.nextTick
)。内层首先保证当前 Promise 还处于pending
状态(标准规定状态只能由pending
转向其他两个,并且一经转变不能更改),然后分别将状态置为resolved
或rejected
,最后顺次执行之前由于还未 settled 时存储在callbacks
里面的后续回调函数集。
.then
.then
函数接受两个函数作为参数,分别对应前一个 Promise 被 resolve 或 reject 之后的回调。
首先来看一下函数的主逻辑:
1 | switch (self[_status]) { |
根据前一个 Promise 的状态,分三种情况,其中resolved
和rejected
时是类似的:new
一个新的 Promise 实例作为当前的 Promise,同理异步执行传入的函数(细节见下文childExec
函数)。而当状态是pending
时,我们只能先把回调函数先压入前一个 Promise 的回调函数队列,等settled
后再执行。
childExec
childExec
本身是为了最大化复用代码而重构独立出来的一个函数,代码很简单:
1 | function childExec(value, onDone, resolve, reject, childPromise) { |
解释一下:不管是前一个 Promise 是rejected
还是resolved
,用传入.then
的对应的回调函数执行前一个 Promise settled 之后的结果result
,然后执行solver
(见下文对solver
的分析)。同样的,外面需要包裹一层try...catch
。
solver
solver
函数是不同的 Promise 实现之间(比如 bluebird 对 Promise 的实现与 ES6 原生对 Promise 的实现)能够无缝调用的关键。对此,Promise 标准中也有详述,甚至已经把这个函数的逻辑和流转都已经完完本本的列出来了,任何 Promise 实现只要遵循这个标准,就可以实现不同 Promise 实现之间的交叉调用。
看代码:
1 | function solver(promise, result, resolve, reject) { |
分析:
- 如果上一个 Promise 的
result
跟子 Promise 还是同一个对象,显然是循环调用了,按标准规定抛出TypeError
; - 如果上一个 Promise 的
result
还是本 Promise 实现的实例,那挺好,直接调用它的then
就好。不过,上一个 Promise 返回的新 Promise 可能还没有 settled,所以如果没有 settled,我们在它的onResolved
函数中直接继续执行solver
直到它被 settled(看起来像用递归的形式表现循环迭代); - 如果返回的不是当前 Promise 实现的实例,但是它是一个有
.then
方法的对象或者函数,那我们直接用它的then
方法call
在result
上,并且在它的回调函数onResolved
中用.then
传递的s
继续调用solver
或者在onRejected
函数中reject
掉这个 Promise。值得注意的是,由于一个 Promise 只能被 settled 一次,而第三方的.then
方法我们不知道里面具体是什么内容,所以用一个Boolean
量settled
来标记,保证它只被 settled 一次,之后的调用都直接被忽略(标准也是这样规定的); - 如果只是一个简单值,直接
resolve
。
另外,尽管在childExec
中我们已经用try...catch
包裹了一层,但是异步调用的错误依然可能不会被捕获,所以这里依然需要try...catch
。
至此,一个符合标准的 Promise 最小化实现就已经完成了。
测试
在你的 Promise 写好后,需要进行测试,至少需要通过的官方测例在这里:Promises/A+ Compliance Test Suite。通过所有测例之后表明你的 Promise 实现是基本符合标准的。
按其说明,你需要提供一个这样的类静态函数:
1 | static deferred() { |
这个函数返回一个对象,并用resolve
和reject
属性引用构造函数中自定义的resolve
和reject
函数,用promise
属性引用一个 Promise 实例。
之后用promises-aplus-tests
命令运行测试(需要提前安装promises-aplus-tests
包)。
常用的其他辅助接口
一个成熟的 Promise 包肯定不会只有一个简单的.then
方法,不过其他的的辅助接口都是在此最简基础上慢慢迭代出来的,相当于是一些方便用户使用的语法糖。
你可以在下文给出的 GitHub 源码链接中查看我的其他辅助方法的实现,逻辑基本都很清晰,本文不赘述了。
Join Me
目前,我的这个 Promise 实现已经放在了 npm 和 GitHub 上:
Any issues and PRs are welcomed! :D
总结
如果要细致的讲解每一个细节,恐怕 5 篇这样长度的文章还显不够,本文也只是将实现的骨架勾勒了出来。不过,源码是一个软件产品最好的背书,我就不啰嗦了。
个人觉得实现 Promise 的难度在于它本身的逻辑比较绕,因为标准设计得就比较精巧,每一个地方的衔接都经过深思熟虑,往往一个方法就会产生一个新的 Promise,有一种层层迭代的感觉,类似于「蚕」的结构,所以把层次厘清确实需要花一番功夫,要有耐心。