意见箱
恒创运营部门将仔细参阅您的意见和建议,必要时将通过预留邮箱与您保持联络。感谢您的支持!
意见/建议
提交建议

对Promises/A+规范的研究------引用

来源:恒创科技 编辑:恒创科技编辑部
2022-09-27 20:27:04

作为 Modern JavaScript 基础设施的一部分,Promises 对前端开发者而言异常重要。它是 async/await 语法的基础,是 JavaScript 中处理异步的标准形式。并且,未来的 Web API,只要是异步的,都会以 ​​Promises​​ 的形式出现。

1、实现 Promises/A+ 规范1.1、前期工作

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


对Promises/A+规范的研究------引用

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

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

对 Promises/A+  规范的研究 ------引用_语法糖

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

1.2、了解术语

对 Promises/A+  规范的研究 ------引用_语法糖_02

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

promise 是一个包含 then 方法的对象或函数,该方法符合规范指定的行为。

thenable 是一个包含 then 方法和对象或者函数。

value 就是任意合法 JS 值。

exception 就是 throw 语句抛出的值。

reason 是一个指示 promise 为什么被 rejected 的值。

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

1.3 Promise 状态

对 Promises/A+  规范的研究 ------引用_缓存_03

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

在 pending 状态,promise 可以切换到 fulfilled 或 rejected。

在 fulfilled 状态,不能迁移到其它状态,必须有个不可变的 value。

在 rejected 状态,不能迁移到其它状态,必须有个不可变的 reason。

对 Promises/A+  规范的研究 ------引用_缓存_04

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

有 3 个常量 pending, fulfilled, rejected,

一个 Promise ​​构造函数​​,有 state 和 result 两个属性。

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

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

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

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

1.4、Then 方法

对 Promises/A+  规范的研究 ------引用_语法糖_05

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

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

对 Promises/A+  规范的研究 ------引用_缓存_06

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

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

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

对 Promises/A+  规范的研究 ------引用_语法糖_07

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


对 Promises/A+  规范的研究 ------引用_语法糖_08

then 方法必须返回 promise。

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

对 Promises/A+  规范的研究 ------引用_语法糖_09

在 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 的行为指定。

对 Promises/A+  规范的研究 ------引用_语法糖_10

这正是 handleCallback 要做的部分。

对 Promises/A+  规范的研究 ------引用_语法糖_11

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

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

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

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

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

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

1.4、The Promise Resolution Procedure

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

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

对 Promises/A+  规范的研究 ------引用_缓存_12

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

对 Promises/A+  规范的研究 ------引用_缓存_13

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

对 Promises/A+  规范的研究 ------引用_语法糖_14

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

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

对 Promises/A+  规范的研究 ------引用_缓存_15

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

第一个判断 result 是不是 promise 本身,是就抛 TypeError 错误。

第二个判断 result 是不是 promise 类型,是就调用 then(resolve, reject) 取它的 value 或 reason。

第三个判断 result 是不是 thenable 对象,是就先取出 then,再用 new Promise 去进入 The Promise Resolution Procedure 过程。

若都不是,则直接 resolve result。

1.5、整合剩余部分

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

1)我们有了 transition 对单个 promise 进行状态迁移。

2)我们有了 handleCallback ,在当前 promise 和下一个 promise 之间进行状态传递。

3)我们有了 resolvePromise,对特殊的 result 进行特殊处理。

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

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

对 Promises/A+  规范的研究 ------引用_语法糖_16

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

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

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

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

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

对 Promises/A+  规范的研究 ------引用_语法糖_17

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

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

对 Promises/A+  规范的研究 ------引用_语法糖_18

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

1.6、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 的几个扩充方法。

对 Promises/A+  规范的研究 ------引用_缓存_19

catch 方法和 resolve/reject 静态方法的实现如上所示。Promise.all 和 Promise.race 的实现

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

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

2.2.1、promises 也属于 callback style 的一种

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

对 Promises/A+  规范的研究 ------引用_语法糖_20

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

callback style,通常是指 nodejs 那种 Error-First Callbacks,或者其它 raw callback。

对 Promises/A+  规范的研究 ------引用_语法糖_21

2.2.2、generator function 也是一种 callback style

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

​co(function*(){​​​​var=yieldPromise.resolve(true);​​​​return;​​​​}).then(function(value){​​​​.log(value);​​​​},function(err){​​​​.error(err.stack);​​​​})​​2.3、async/await 是异步终极解决方案?

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

2.3.1 generator function 比 async function 更普适

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

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

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

async function 只支持异步行为。

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


对 Promises/A+  规范的研究 ------引用_缓存_22

如上所示,当 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 处理。

对 Promises/A+  规范的研究 ------引用_缓存_23

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

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

对 Promises/A+  规范的研究 ------引用_语法糖_24

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

对 Promises/A+  规范的研究 ------引用_语法糖_25

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

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

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

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

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

对 Promises/A+  规范的研究 ------引用_缓存_26

如上所示,我们建立了一个 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。

对 Promises/A+  规范的研究 ------引用_缓存_27

感兴趣的同学,可以访问上面的链接,了解 callbag。

受到 callbag 的启发,rxjs v7.0 版本正在用相似的模式,进行内部重构。他们称这个模式为 Functional Observables。

我们可以看到,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,异步生成器。

对 Promises/A+  规范的研究 ------引用_语法糖_28

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

对 Promises/A+  规范的研究 ------引用_语法糖_29

在 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。

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

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

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



上一篇: 租用美国服务器:潜在的风险与应对策略。 下一篇: MongoDB 5.0 扩展开源文档数据库操作