100 行代码实现 Promises/A+ 规范

Posted 凯小默

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了100 行代码实现 Promises/A+ 规范相关的知识,希望对你有一定的参考价值。

说明

本文转载自【工业聚】:https://mp.weixin.qq.com/s/qdJ0Xd8zTgtetFdlJL3P1g

源码:https://github.com/Lucifier129/promise-aplus-impl

原文

作为 Modern javascript 基础设施的一部分,Promises 对前端开发者而言异常重要。

它是 async/await 语法的基础,是 JavaScript 中处理异步的标准形式。并且,未来的 Web API,只要是异步的,都会以 Promises 的形式出现。

如果不理解 Promises 相关的知识和运行机制,将来可能无法完成 Web 开发的日常工作。

因此,Promises 成为了前端面试中的必问问题之一。在网络上,也可以搜索到很多 Promises 的技术文章。甚至一些前端付费教程,以教你从零实现 Promises/A+ 规范作为卖点。

Promises/A+ 规范,有现成的单元测试套件,很容易搭建开发环境,以及验证代码是否符合规范要求。

对于已经理解 Promises 的开发者来说,实现 Promises/A+ 是一个有益的训练。

不过,如果一个初学者,想通过实现 Promises/A+ 去学习 Promises。或者认为实现了 Promises/A+规范后,对 Promises 的理解水平能得到质的提升。

最后可能会失望。

实际上,Promises/A+ 规范,内容简短,实现难度低。其中充斥着细节行为的描述,缺乏设计目的和背景的部分,完全没有介绍使用场景。并不是一个入门 Promises 的好材料。

即便成功实现 Promises/A+ 规范,也不一定比没实现过的开发者,更善于使用 Promises 特性。

本文将分成三部分。第一部分,演示用 100 行以内的代码,实现 Promises/A+规范。第二部分,澄清开发者对 Promises 的几个迷思。第三部分进行总结。

1、实现 Promises/A+ 规范

1.1、前期工作

An open standard for sound, interoperable JavaScript promises
https://promisesaplus.com

通过上面的地址,可以查看规范内容。

通过 npm install promises-aplus-tests ,可以下载测试套件。

通过 npm run test 运行测试套件。

1.2、了解术语

规范的第一部分,描述了几个术语的意思。

  1. promise 是一个包含 then 方法的对象或函数,该方法符合规范指定的行为。
  2. thenable 是一个包含 then 方法和对象或者函数。
  3. value 就是任意合法 JS 值。
  4. exception 就是 throw 语句抛出的值。
  5. reason 是一个指示 promise 为什么被 rejected 的值。

这部分没有需要落实到代码的地方,继续看下去。

1.3、Promise 状态

promise 有 3 个状态,分别是 pending, fulfilled 和 rejected。

  1. 在 pending 状态,promise 可以切换到 fulfilled 或 rejected。
  2. 在 fulfilled 状态,不能迁移到其它状态,必须有个不可变的 value。
  3. 在 rejected 状态,不能迁移到其它状态,必须有个不可变的 reason。

落实到代码上,大概像上面那样:

有 3 个常量 pending, fulfilled, rejected,一个 Promise 构造函数,有 state 和 result 两个属性。

  • 当 state 为 fulfilled 时,result 作为 value 看待。
  • 当 state 为 rejected 时,result 作为 reason 看待。

一个 transition 状态迁移函数,它只会在 state 为 pending 时,进行状态迁移。

如上,其实并没有多少自由发挥的空间。不管由谁来编写,仅仅是变量名,代码行数上的微小差异。

1.4、Then 方法

promise 必须有 then 方法,接受 onFulfilled 和 onRejected 参数。

那就像下面那样,新增一个 then 的原型方法。

onFulfilled 和 onRejected 如果是函数,必须最多执行一次。

onFulfilled 的参数是 value,onRejected 函数的参数是 reason。

then 方法可以被调用很多次,每次注册一组 onFulfilled 和 onRejected 的 callback。它们如果被调用,必须按照注册顺序调用。

那就像上面那样,为 Promise 新增一个 callbacks 数组记录。

then 方法必须返回 promise。

那 then 实现丰富化成下面这样:

在 then 方法里,return new Promise(f),满足 then 必须 return promise 的要求。

  • 当 state 处于 pending 状态,就储存进 callbacks 列表里。
  • 当 state 不是 pending 状态,就扔给 handleCallback 去处理。

至于 handleCallback 是什么。其实不重要,我们只需要知道,它一定存在。我们总得做一些处理,不是写死在 then 函数里,就是在外部的辅助函数里。

至于为啥要套个 setTimeout 呢?

因为 then 方法里,还有一个重要约束是:

onFulfilled or onRejected must not be called until the execution context stack contains only platform code.

我们不是在 JS 引擎层面实现 Promises,而是使用 JS 去实现 JS Promises。在JS里无法主动控制自身 execution context stack。可以通过 setTimeout/nextTick 等 API 间接实现,此处选用了 setTimeout。

then 方法返回的 promise,也有自己的 state 和 result。它们将由 onFulfilled 和 onRejected 的行为指定。

这正是 handleCallback 要做的部分。

handleCallback 函数,根据 state 状态,判断是走 fulfilled 路径,还是 rejected 路径。

先判断 onFulfilled/onRejected 是否是函数,如果是,以它们的返回值,作为下一个 promise 的 result。

如果不是,直接以当前 promise 的 result 作为下一个 promise 的 result。

如果 onFulfilled/onRejected 执行过程中抛错,那这个错误,作为下一个 promise 的 rejected reason 来用。

then 方法核心用途是,构造下一个 promise 的 result。

我们的代码,几乎没有多余的处理逻辑,忠实的完成规范指定的行为。

1.5、The Promise Resolution Procedure

从上面的截图里,我们还看到了 The Promise Resolution Procedure 的说法。

它描述的是,一些特殊的 value 被 resolve 时,要做特殊处理。这个特殊处理,规范也明确描述了。

第一步,如果 result 是当前 promise 本身,就抛出 TypeError 错误。

第二步,如果 result 是另一个 promise,那么沿用它的 state 和 result 状态。

第三步,如果 result 是一个 thenable 对象。先取 then 函数,再 call then 函数,重新进入 The Promise Resolution Procedure 过程。

最后,如果不是上述情况,这个 result 成为当前 promise 的 result。

用代码描绘起来,如上所示,按照规范描述的顺序,编写 3 个 if。

  1. 第一个判断 result 是不是 promise 本身,是就抛 TypeError 错误。
  2. 第二个判断 result 是不是 promise 类型,是就调用 then(resolve, reject) 取它的 value 或 reason。
  3. 第三个判断 result 是不是 thenable 对象,是就先取出 then,再用 new Promise 去进入 The Promise Resolution Procedure 过程。

若都不是,则直接 resolve result。

1.6、整合剩余部分

至此,所有重要部分,都已经被处理。

  1. 我们有了 transition 对单个 promise 进行状态迁移。
  2. 我们有了 handleCallback ,在当前 promise 和下一个 promise 之间进行状态传递。
  3. 我们有了 resolvePromise,对特殊的 result 进行特殊处理。

接下来,我们只需要整合一下,把各部分衔接起来即可。

其中,Promise 构造函数,扩充如下:

构造 onFulfilled 去切换到 fulfilled 状态,构造 onRejected 去切换到 rejected 状态。

构造 resolve 和 reject 函数,在 resolve 函数里,通过 resolvePromise 对 value 进行验证。

配合 ignore 这个 flag,保证 resolve/reject 只有一次调用作用。

最后将 resolve/reject 作为参数,传入 f 函数。

若 f 函数执行报错,该错误就作为 reject 的 reason 来用。

transition 函数扩充如上,当状态变更时,异步清空所有 callbacks。

之前我们已经实现了 handleCallback,实现 handleCallbacks 只需要一个循环。

运行测试套件后,全部 passing。

Promises/A+ 规范实现完毕。

1.7、ES2015 Promises

Promises/A+ 规范跟 ES2015 Promises 不完全等价。在 A+ 规范里,并没有描述 catch 方法,以及 Promsie.resolve, Promise.reject, Promise.all, Promise.race 等静态方法。

甚至,new Promise 这种用法都不是 A+ 规范的内容,只是恰好我们现在用 ES2015 Promises 风格去实现。

ES2015 Promises 兼容 Promises/A+ 规范,并做了自己的扩充。

有了 then 方法,我们可以很容易实现 ES2015 Promises 的几个扩充方法。

catch 方法和 resolve/reject 静态方法的实现如上所示。Promise.allPromise.race 的实现,留给感兴趣的读者,作为练习。

2、澄清迷思

2.1、能手写 promises 实现,一定精通 promises?

在计算机行业,盛行着一种朴素还原论的迷思。即认为越接近底层,技术含量越高。每个程序员都有读懂底层源代码的追求。

这在一定程度上是正确的。不过,我们也应该看到,一旦底层和表层之间,形成了领域鸿沟。精通底层,并不能代表在表层的水平。

比如游戏的开发者,不一定是游戏中的佼佼者。这在 FPS 射击游戏或者格斗游戏里尤为明显,这些游戏里的绝大部分顶尖玩家,完全不会写代码。

如果将精通 promises 定义为,善于在各种异步场景中使用 promises 解决问题。

那么,能手写 promises 实现,对精通 promises 帮助不大。

Promise/A+ 规范,重点围绕的是 How to implement。

而不是 How to use。

通过实现规范,或者所谓的吃透底层源码,以期打通任督二脉的武学幻想,是不切实际的。

想要精通 promises,还得在日常开发的各个异步场景中,多加思考和训练。

有同学一定好奇,如何解释许多精通某技术的开发者,确实能读懂和实现规范?

其实,这是一个典型的【把相关当因果】的案例。

他们不是因为读懂和实现了规范而精通。而是精通该技术使他们产生读规范和实现规范的兴趣。

很少看到有电工试图通过学习电磁学,去精通电工实操,却常常看到开发者框架都还没玩熟,就想啃框架源码,代码还写得一塌糊涂,就想看语言规范。

想跳过扎实的学习和训练过程,直接走最后一步。有点像企图通过只吃最后一那口饭,来吃饱。

2.2、promises 是比 callback 更先进的异步方案?

网络上,许多文章介绍 JavaScript 里的异步方案的演进时,是用下面这种顺序:

callback -> promise -> generator -> async/await

其中,callback 被认为是最差的方案,而 async/await 则是所谓的异步终极解决方案。

从流行趋势来看,这种说法不无道理。我们确实从 callback style 一步步走到了今天大量 async/await 的阶段。

不过,有些一些有趣的内容,我们可以了解一下。

2.2.1、promises 也属于 callback style 的一种

在 Promises/A+ 规范的第一段,我们能看到一个明确的表述:

promise 是通过 then 方法去注册 callbacks,其中 onFulfilled callback 处理 value,而 onRejected callback 处理 reason。

我们一直诟病的 callback style,通常是指 nodejs 那种 Error-First Callbacks,或者其它 raw callback。

callback 自身是一个很宽泛的概念。

仿照 state management 的说法,我们可以将 promises 理解为是一种 callback management。

而 rxjs 则是另一种 callback management,相比 promises 只关心单个异步操作的 value 和 reason,rxjs 可以胜任多个异步操作的 value, error 和 complete 信号。

如果说 promises 可以处理 0 ~ 1 个异步结果,rxjs 则可以处理 0 ~ Infinite 无限多个异步结果。

一个很合理的设想是,我们能否用 rxjs 实现 Promises/A+ 规范?

看起来,无非是缩小 rxjs 的处理范围罢了。

答案是,可以的。

如上,我们可以将 rxjs 的 observable 封装成 promise style 的 api,在内部用 merge 组合 value$reason$result$,利用 rxjs 的 subscribe 机制,处理 promises 的 onFulfilled 和 onRejeted 两个 callbacks。

可以查看代码仓库,了解 rxjs-based 的 Promises/A+ 实现。

如果将 promises 视为一种 callback management,那么之前我们认为的 callback -> promise 的演进,就变成了用一种 callback style 取代另一种 callback style 的过程。

2.2.2、generator function 也是一种 callback style

基于 generator + promise 的异步解决方案,可以实现用同步的写法,编写异步代码的效果。比如用 tj 的 co 库:

co(function* () 
  var result = yield Promise.resolve(true);
  return result;
).then(function (value) 
  console.log(value);
, function (err) 
  console.error(err.stack);
);

这种妙用,正是源于 generator function 的 callback 特征。

generator function 可视为多个 callback 函数的整合,它返回一个 generator 对象,该对象包含 next, throw 和 return 三个方法,其中 generator.next(value) 把 value 发送给 generator function。

这个过程是不断的消费由 yield 关键字切割出来的多个 callback 函数的过程。

乍看可能难以理解,我们可以看一个简单案例。

如上图所示,我们通过 yield 输出了 3 个值给外部,并从外部获取了 3 个值,然后 return 我们收集到的所有外部输入。

如果我们去掉 * 号和 yield 语法糖,用朴素的函数来表示,一种简化形式如下:

如上,我们构造了一个嵌套了 4 层的 callback,然后在 next 函数的 4 次调用中,分别解开每一层,最后清空了 callback 的层次。实现了与前面的 generator function 相似的行为。

我们的处理是过度简化的。只做了 next 方法,没有提供 throw 和 return 方法,也没有演示如何处理循环语句里的 yield,但基本思路是一致的。

我们可以把 generator function 视为将嵌套的 callback 用 yield 关键字扁平化的语法糖。

这种处理不是 JavaScript 里特有的,早在 20 多年前的上世纪 90 年代,Haskell 里就用 do-notation 这种语法糖,将嵌套的 bind operator 扁平化。

通过 babel 编译,generator function 被 desugared 成下面这种样子:

它没有生成多个嵌套 callback,而是合成一个函数,通过 switch case 将它分割成多块,每次执行其中一个 case。相当于将多个 callback inline 到一起。

如果说 generator function 是一个带着语法糖的 callback management,那么之前我们认为的 promise -> generator 的异步方案演进,可以理解成是一个带语法糖的 callback style 取代手动调用的 then 方法的 callback style。

2.2.3、async function 也是一种 callback style

至于 generator -> async/await 的演进,可以视为是对 promise + generator 这个方案的语义化和标准化。毕竟 * 号和 yield 关键字,并不能准确反映这是一个异步操作。

在 babel 的编译处理中,async/await 的编译结果,正是在 generator 的基础上,包装一层 asyncToGenerator 函数。

至此,我们可以将 JavaScript 里的异步方案演进的表述,重新梳理成如下形式:

Raw Callback Style -> Promise Callback Style -> Generator Callback Style -> Async/Await Callback

它们分别是:

  1. Raw Callback Style: 朴素函数作为 callback,接受 error, data 等参数。
  2. Promise Callback Style:通过 then 对象,去处理 onFulfilled 和 onRejected 两个 callback 函数。
  3. Generator Callback Style:通过 * 号和 yield 关键字,将多个嵌套 callback 扁平化的语法糖。
  4. Async/Await Callback Style:通过 async 和 await 关键字,将 Promise + Generator Callback Style 语义化和标准化的产物。

新的表述跟旧的表述,总体上是一致的。只是描述口径从 4 个不同事物的演进,变成了同种事物的不同形态的演进。

在这个主流演进路径之外,还存在 Rxjs Callback Style 等其它形态。

对 callback 的本质和背后的概念感兴趣的同学,可以搜索 Continuation 这个术语。

2.3、async/await 是异步终极解决方案?

我不太确定当人们说 async/await 是异步终极解决方案时,所描述的终极在什么维度上衡量。

如果是指表达能力的维度,有几个有趣的事实,我们可以了解一下。

2.3.1、generator function 比 async function 更普适

语义化和标准化,不意味着能力的增强,它也有可能导致能力的减弱。

async/await 是能力减弱的案例。

generator function 即能支持同步行为,也能支持异步行为。

async function 只支持异步行为。

对于 tj 的 co 库来说,promise 只是它最主要的异步数据源,co 还能从其它异步数据源中获取结果。比如 thunk 函数。

如上所示,当 yield 一个 thunk 函数时,co 会传递 done 这个 nodejs 风格的 callback 函数。

如果愿意,我们还可以支持 yield rxjs 的 observable 等对象。

这是因为 co 是一个 library 里,它可以尽可能利用 generator function 的一切特性,实现想要的拓展功能。

而 async/await 是一个新的语法,它必须建立在标准化的基础上,它必须拥有一致的语义。它需要做很多取舍,通过放弃对非标准化对象的支持,换取清晰的语义。

因此,async/await 只能从 promise 对象中获取异步数据结果,相比 co 是一种能力上的降低。

2.3.2、裸写 promise 比 async/await 更灵活

尽管 90% 以上的异步场景下,async/await 都能胜任;然而,还是有一些场景,裸写 promise 更加灵活。

最典型的案例就是并行的 promise 处理。

如上,await 关键字总是串行,当我们想要依次获取 a, b 时,写起来是很简单。如果我们想同时获取 a 和 b 并等待其结果,await 关键字却难以处理。

我们得自行通过 Promise.all 将多个 promise 包装成单个。

曾经有个 await* 的提案,作为 Promise.all 的语法糖。不过,并没有得到落地。

仔细一想,很容易理解为什么上图的做法,难以落地。Promise.all 只是 promise 的其中一种组合方式,还有 Promise.race,Promise.allSettled 等组合。

如果为每个组合方式都分配一个符号作为语法糖,代码将更难以阅读。

因此,当需要组合多个 promise 时,裸写 promise 是一个必要措施。

此外,async/await 是语法,不是值,因此它不能被存储和传递。而 promise 对象,可以存储在内存里,可以作为参数在函数中传递。

这种灵活性,在一些特殊场景下,可以带来便利。比如,我们可以通过缓存 promise 来缓存异步结果。

如上所示,我们建立了一个 map,存储 url -> promise 的映射。每次 get url 时,都查一下缓存。

通过 async/await 语法的话,promise 对象被隐藏起来了。我们无法获取。最多等结果返回后,缓存 url -> result 的映射。

然而,这种做法的缓存覆盖面有空隙。当 get 请求触发,但结果还没抵达的过程中,又触发了多个相同的请求,这些请求无法命中缓存。

如果我们缓存的是 promise 对象,那么利用 promise 对象可以多次调用 then 方法的特性,我们能做到让所有 get url 获取到同一份异步请求结果。

2.3.3、裸写 callback 比 promise 更灵活

promise 的 then 只支持 onFulfilled 和 onRejected 两种 callback 路径,属于对所有可能的 callback 路径的简化。

比如前面提到的 rxjs,observer 和 subscriber 有 next, error, complete 三个 callback 路径,能比 promise 处理更多 cases。

如果愿意手动管理 callback,在理论上我们能做到比 promise 更强大和灵活。

比如 cyclejs 作者提出的 callbag 模式,仅用多个 callback 函数的组合,就实现了 rxjs-like 的 observables and iterables。

A standard for JS callbacks that enables lightweight observables and iterables
https://github.com/callbag/callbag

感兴趣的同学,可以访问上面的链接,了解 rxjs v7.0 的设计思路(需翻墙)。

如上,我们可以看到,async/await 反而是表达能力最弱的一个,callback 则是最强的一个。

JavaScript 的异步方案演进史:

Raw Callback Style -> Promise Callback Style -> Generator Callback Style -> Async/Await Callback

并非表达能力不断增强的过程,而是对开发者的友好程度不断增加的过程。

Rxjs Callback Style 因为没有语法糖的支撑,且 operators 极多,出名的对新手不友好,因此一直难以成为主流方案。

而 Callbag Style 更为反人类,连源代码都不利于阅读。只适合由资深的开发者编写,隐藏在 library/framework 内部。

2.3.4、语法糖是有代价的

到目前为止,我们知道 async/await 语法可以视为多个 callback 函数组合的语法糖,可以简化我们编写的异步代码的复杂性。

不过,这不是没有代价的。

JS 的编译器需要处理大量的场景,要识别关键字,要准确的处理异步的 throw error 和同步的 throw error 的差异。要让 async/await 能跟普通函数协调的工作,能跟 generator function 协调的工作。

async/await + generator function 又将组合出一个新的 async generator function,异步生成器。

如上图所示,通过 async generators + for-await 语法,我们可以同时获得async/await 的异步处理能力,和 generator 输出多个值的能力。

有个库 axax 受到启发,基于 async generators 实现了 rxjs-like 的异步数据流方案。相比 rxjs 用 subscribe 方法去消费数据,axax 通过 for-await of 语法来消费。

Async Iterator Extensions for JavaScript e.g. map, reduce, filter, flatMap, etc.
https://github.com/jamiemccrindle/axax

感兴趣的同学,可以点击上述链接,了解 axax。

如今,在 JavaScript 里,有多种函数类型:

  1. plain function 普通函数
  2. arrow function 箭头函数
  3. generator function 生成器函数
  4. async function 异步函数
  5. async arrow function 异步箭头函数
  6. async generator function 异步生成器函数

不断新增的函数类型和语法,对编译器的迭代和优化提出了巨大挑战,也对 ECMAScript 语言新增特性带来了问题。所以我们能看到 async arrow function,却没有看到 generator arrow function,以及 async generator arrow function。

将来增加更多函数类型,跟之前的函数类型进行排列组合,数量将会越来越多,协调多种函数类型将变得越来越难。

这是不是编程语言发展绕不开的问题呢?

不是的。还有其它路线,async/await 和 iterator 等特性可以作为 library 而不是 syntax 语法出现。

Structured Asynchrony with Algebraic Effects
https://www.microsoft.com/en-us/research/wp-content/uploads/2017/05/asynceffects-msr-tr-2017-21.pdf

上述 Paper 描述了具备 Algebraic Effects 特性的 Koka 语言,可以通过 algebraic effect handlers 模拟包括 async/await 在内的诸多特性,以 library 而非 language syntax 的形式提供。不会显著增加编译器的负担。

React 即将发布的 Suspense 特性,即采用了上述能力,它内部通过 JS 里一些比较 hack 的方式去模拟 Algebraic Effects。可以实现在 render 函数里不需要 await 一个异步操作,也能通过某种途径,获取到异步结果,同时又不会影响 render 函数的幂等要求。

虽然 Algebraic Effects 或 Koka 还未得到广泛的实践验证,不过它让我们看到了,当前的方案存在的问题以及可能的解决方案。

3、总结

在这篇文章中,我们给出了 100 行以内的代码实现 Promise/A+ 规范的案例,我们澄清了几个关于 Promises 和 JS 里的异步方案的迷思。

我们重新梳理了在 JavaScript 里的异步方案演进史的表述。

我们也了解到,async/await 并非异步终极解决方案,也不是异步数据处理能力最强的模式。只能说,它是当前对新手开发者友好的主流方案。

关于 Promises 还有好多话题可以讨论,比如它与函数式的 Monad 的关系,比如 Promise/A+ 规范中值得商榷和可以改进的部分等。篇幅所限,今天就到这里。

以上是关于100 行代码实现 Promises/A+ 规范的主要内容,如果未能解决你的问题,请参考以下文章

Promises/A+规范

Promises/A+ 标准翻译

JS - Promise使用随笔

JS-JS模拟实现Promise-源码解析

[转]JS - Promise使用详解1(基本概念使用优点)

Promise原理详解