【人人都能读标准】18. 手写一个通过test262标准符合性测试的Promise

756 阅读13分钟

本文为《人人都能读标准》—— ECMAScript篇的第18篇。我在这个仓库中系统地介绍了标准的阅读规则以及使用方式,并深入剖析了标准对JavaScript核心原理的描述。


Promise是JavaScript中的“明星对象”。如果你在github搜一搜,你会发现有大量的程序员在造Promise的轮子,与之相比,你就几乎看不到有人会说“我要手写一个Math对象”。

我自己也造一个Promise的轮子,不过这个“轮子”跟其他人的“轮子”不太一样,这个轮子可以通过test262标准符合性测试99.3%的测试用例。在同类产品中,then/Promise只有63.8%的通过率,es6-promise只有46%的通过率。你可以在spec-promise中看到我轮子的源码,以及具体的测试方法。

test-result

我是如何做到这一点的?是靠个人聪明才智么?那肯定不是。论能力,目前的我肯定远远不及这些项目背后的大佬。我使用的是看起来“非常笨”的方法:跟着标准定义的Promise按部就班地实现。

本节的内容会分为两个部分,前面的部分我会为你展示基于标准的定义实现Promise的大致过程。基于易读性的考虑,我不能把spec-promise中的代码直接贴进来,我会使用更为简易的代码框架为你展示实现的过程。当然,简易不是简陋,最后这个Promise还是可以小跑一下的,完整的代码可见这里。而剩余的部分,我会延续16.生成器中异步编程的内容,从标准的角度为你展示,Async函数是如何实现异步编程的。

跟着标准写Promise

Promise骨架

1.阅读向导我提到过,标准在定义每一个内置构造器的时候,都会从构造器函数构造器属性prototype对象属性实例属性4个角度出发。基于这4个角度,我们可以从标准Promise章节的目录看到Promise的“架构”:

promise-toc

于是,我们就有了Promise类的JavaScript骨架:

class Promise {
    // 27.2.3 The Promise Constructor: 构造函数
    constructor(executor){}

    // 27.2.4 Properties of the Promise Constructor:构造器属性
    static all(iterable){}
    static allSettled(iterable){}
    static any(iterable){}
    static race(iterable){}
    static reject(r){}
    static resolve(x){}
    static get [Symbol.species](){}

    // 27.2.5 Properties of the Promise Prototype Object:prototype对象属性
    then(onFulfilled, onRejected){}
    catch(onRejected){}
    finally(onFinally){}
    [Symbol.toStringTag](){}

    // 27.2.6 Properties of Promise Instances:实例属性(内部插槽)
    __PromiseState;
    __PromiseResult;
    __PromiseFulfillReactions;
    __PromiseRejectReactions;
    __PromiseIsHandled;
}

值得注意的是,这里的实例属性都是Promise实例对象中的内部插槽,理论上来说是无法被代码观察到的。但由于我们的Promise使用JavaScript实现,所以只能使用带有__前缀的属性名表示内部插槽。(不使用私有属性#是因为在后续的实现中,外部的“抽象操作”也会用到这些实例属性)。

这些内部插槽的含义我用下表给你总结:

内部插槽描述
__PromiseState表示Promise的状态:pendingfulfilledrejected
__PromiseResult表示Promise的结果。
__PromiseFulfillReactionsfulfilled状态的handlers列表。当状态从pending转为fulfilled时,列表中的handler会依次触发。
__PromiseRejectReactionsrejected状态的handlers列表。当状态从pending转为rejected时,列表中的handler会依次触发。
__PromiseIsHandled记录promise是否注册了handler。

构造函数constructor

constructor方法会在new表达式上被触发。

从标准27.2.3 Promise构造函数的定义可以看出,该算法主要做以下几个事情:

  1. 判断方法是否被合法调用:必须由new表达式或super方法触发,且传入的第一个参数executor必须是一个函数;
  2. 创建promise实例对象,并初始化其内部插槽;
  3. 创建resolve,reject两个内置函数;
  4. 分别以resolve,reject作为executor的参数,执行executor;如果executor的执行有误,直接reject promise。
  5. 返回promise对象。

翻译成代码是这样的:

class Promise {
    constructor(executor) {
        // 1. 判断方法是否被合法调用:必须由new表达式、super方法触发,且传入的第一个参数executor必须是一个函数。
        if (!new.target) throw new TypeError("Promise constructor cannot be invoked without 'new'")
        if (typeof executor !== 'function') throw new TypeError("Promise resolver  is not a function")

        // 2. 创建promise实例对象,并初始化其内部插槽
        const promise = Object.create(Promise.prototype)
        promise.__PromiseState = "pending"
        promise.__PromiseFulfillReactions = []
        promise.__PromiseRejectReactions = []
        promise.__PromiseIsHandled = false

        // 3. 创建resolve,reject两个内置函数
        let { resolve, reject } = createResolvingFunction(promise)

        // 4. 分别以resolve,reject作为executor的参数,执行executor;如果executor的执行有误,直接reject promise。
        try {
            executor.call(undefined, resolve, reject)
        } catch (e) {
            reject.call(undefined, e)
        }

        // 5. 返回promise对象。
        return promise
    }
    // ...
}

在第3步中,createResolvingFunction是一个抽象操作,这个抽象操作会创建resolve()reject()两个内置函数,调用它们会将promise的状态从pending分别转为fulfilledrejected,并触发对应状态handler列表上的函数,如下面的代码所示:

function createResolvingFunction(promise){
    let alreadyResolve = false // 使得resolve、reject只允许触发一次

    return {
        resolve: function (value) { // resolve内置函数
            if (alreadyResolve) return undefined;
            alreadyResolve = true

            // 修改内部插槽
            let reactions = promise.__PromiseFulfillReactions
            promise.__PromiseState = "fulfilled"
            promise.__PromiseFulfillReactions = undefined
            promise.__PromiseRejectReactions = undefined
            promise.__PromiseResult = value

            // 触发handler
            for (let reaction of reactions) {
                queueMicrotask(reaction.bind(undefined, value))
            }

        },
        reject: function (reason) { // reject内置函数
            if (alreadyResolve) return undefined;
            alreadyResolve = true

            // 修改内部插槽
            let reactions = promise.__PromiseRejectReactions
            promise.__PromiseState = "rejected"
            promise.__PromiseFulfillReactions = undefined
            promise.__PromiseRejectReactions = undefined
            promise.__PromiseResult = reason

            // 触发handler
            for (let reaction of reactions) {
                queueMicrotask(reaction.bind(undefined, reason))
            }
        }
    }
}

当然,以上并不是createResolvingFunction的所有逻辑,比如,当resolve()的参数是一个promise时,会有其他的逻辑。你可以在标准中看到这个抽象操作的完整逻辑,也可以spec-promise的实现

prototype对象的方法

不管是prototype对象的方法还是静态方法,在执行的过程中,往往会创建新的promise,于是在使用自然语言解释这个过程时,常常很容易导致读者混淆,所以这里需要先做一些用词上的规定。后续的篇幅都会使用以下的词汇区分promise对象:

  • old_promise:表示被触发方法的promise对象;
  • new_promise:表示方法执行过程中创建的新的promise对象。

Promise的prototype对象最重要的方法就是then方法,很多其他的prototype对象方法、静态方法、甚至await语句都是基于Promise.prototype.then实现的。

then方法的核心逻辑其实也很简单,它接受两个函数(onFulfilledonRejected)作为参数,主要做以下两件事情:

  1. 创建一个新的promise(new_Promise),并分别对onFulfilled以及onRejected再做一层封装,得到两个新的函数:
    • onFulfilledJobCallback:用以触发onFulfilled的逻辑,然后根据执行过程中有无错误选择resolve/reject new_Promise
    • onRejectedJobCallback:用以触发onRejected的逻辑,然后根据执行过程中有无错误选择resolve/reject new_Promise;;
  2. 根据old_promise的状态做不同的操作:
    • 如果是pending
      • onFulfilledJobCallback添加到old_promise的fulfilled handlers列表(__PromiseFulfillReactions)中
      • onRejectedJobCallback添加到old_promise的rejected handlers列表(__PromiseRejectReactions)中。
    • 如果是fulfilled,以old_promise.__PromiseResult为参数,把onFulfilledJobCallback添加到宿主微任务队列。
    • 如果是rejected,以old_promise.__PromiseResult为参数,把onRejectedJobCallback添加到宿主微任务队列。

把这个逻辑翻译成代码大概是这样的:

class Promise {
    // ...
    then(onFulfilled, onRejected) {
        return new Promise((resolve, reject) => {
          
            // 封装onFufilled
            let onFulfilledJobCallback = function (value) {
                try {
                    let handerResult = onFulfilled.call(undefined, value);
                    resolve(handerResult)
                } catch (e) {
                    reject(e)
                }
            }
            // 封装onRejected
            let onRejectedJobCallback = function (reason) {
                try {
                    let handerResult = onRejected.call(undefined, reason);
                    resolve(handerResult)
                } catch (e) {
                    reject(e)
                }
            }

            // 根据old_promise的状态做不同的操作:
            if (this.__PromiseState === "pending") {
                this.__PromiseFulfillReactions.push(onFulfilledJobCallback)
                this.__PromiseRejectReactions.push(onRejectedJobCallback)
            } else if (this.__PromiseState === "fulfilled") {
                queueMicrotask(onFulfilledJobCallback.bind(undefined, this.__PromiseResult))
            } else if (this.__PromiseState === "rejected") {
                queueMicrotask(onRejectedJobCallback.bind(undefined, this.__PromiseResult))
            }
        })
    }
    // ...
}

还记得我们在3.宿主环境中说的吗,标准使用HostEnqueuePromiseJob表示注册promise微任务的过程。而我在这里使用queueMicrotask替代这个抽象操作。以防你不知道,这是一个不管在浏览器宿主还是在node环境中都真实可用的方法。

当然,这里其实省略了一些边缘逻辑。比如,对参数onFulfilled/onRejected不是函数类型的情况做处理、对执行onFulfilled/onRejected时Realm的选择等等。完整的逻辑可见标准,也可见spec-promise


有了then方法,catch方法的逻辑就非常简单了:

class Promise {
    // ...
    catch(onRejected){
        return this.then(undefined, onRejected)
    }
}

finally方法也是完全基于then方法实现的,但是finally会对传入的函数onFinally先做一层封装:

class Promise {
  // ... 
  finally(onFinally){
        // ... 省略对edge cases的处理
        let thenFinally = function(value){
            let result = onFinally.call(undefined)  // ①
            return Promise.resolve(result).then(() => {return value}) // ②
        }
        let catchFinally = function(reason){
            let result = onFinally.call(undefined) // ①
            return Promise.resolve(result).then(() => {throw reason}) // ②
        }
        return this.then(thenFinally, catchFinally) // ③
    }
}

从这段逻辑,你可以看出finally方法的一些特点:

  1. 参数onFinally函数在被调用时,不会传入任何参数(注释①):

    Promise.resolve("first").finally((arg) => {console.log(arg)}) // undefined
    
  2. 对finally方法进行链式调用时,finally不会修改链上传递的值(注释②):

    Promise.resolve("first").finally(() => {return "second"}).then((arg) => console.log(arg)) // "first"
    
  3. 在finally方法的执行过程中,实际上调用了两次then方法:第一次是在返回语句中(注释③),第二次是在执行thenFinally/catchFinally的过程中(注释②)。于是,完整执行一次finally,至少需要两次执行微任务队列,所以你会看到下面代码的这种奇异行为:

    Promise.resolve().finally(() => {}).then(() => {console.log("first")})
    Promise.resolve().then(() => {}).then(() => {console.log("second")})
    // sencond
    // first
    

静态方法

Promise.resolve/Promsise.reject是两个最常用的静态方法,它们的逻辑也很简单:

  • Promise.resolve(x)
    1. 判断x是不是promise,如果是的话,直接返回。
    2. 如果x不是promise,创建一个新的promise,并以x为参数resolve该promise。
  • Promise.reject(r):
    1. 创建一个新的promise,并以r为参数reject该promise。
class Promise {
    // ...
    static resolve(x){
        if (x instanceof Promise) return x
        return new Promise(resolve => resolve(x))
    }
    static reject(r){
        return new Promise((_, reject) => reject(r))
    }
}

而对于Promise.all / Promise.any / Promise.allSettled / Promise.race这些静态方法,它们的大体逻辑是一样的,只是终止条件不同。它们都接受一个可迭代对象(iterable)为参数,并做以下事情:

  1. 创建new_promise
  2. 遍历iterable,对于遍历过程中的每一个值i,分别执行Promise.resolve(i),最终得到一个promises列表。
  3. promises列表中的每个元素p,分别使用then方法注册handler:p.then(onFulfilled, onRejected)。而这四种静态方法的差异,就在于onFulfilledonRejected的不同:
    • Promise.all:

      • onFulfilled:如果此时其他所有的p状态都为fulfilled,resolve new_promise
      • onRejected:直接reject new_promise
    • Promise.any:

      • onFulfilled:直接resolve new_promise
      • onRejected:如果此时其他所有的p状态都为rejected,reject new_promise
    • Promise.allSettled:

      • onFulfilled:如果此时其他所有的p状态都不是pending,resolve new_promise
      • onRejected:如果此时其他所有的p状态都不是pending,resolve new_promise
    • Promise.race:

      • onFulfilled:直接resolve new_promise
      • onRejected:直接reject new_promise

如果你小学数学掌握得还不错,你会发现把这4个静态方法放在一起,呈现出一种几何上的美感,它们就好像对称地坐落在坐标系上4个不同的象限一样。

以下,我列出了Promise.all、Promise.allSettled两个静态方法的代码示例:

class Promise {
    // ...
    static all(iterable) {
        return new Promise((resolve, reject) => {
            let promises = [...iterable].map(i => Promise.resolve(i)) 
            let values = [...iterable].map(_ => undefined) 
            let remainingElementsCount = 0 // 计算未fulfilled的p

            promises.forEach((p, index) => {
                remainingElementsCount++
                p.then( 
                    (x) => { // onFulfilled
                        remainingElementsCount--
                        values[index] = x
                        if (remainingElementsCount === 0) { 
                          // 此时其他所有的p状态都为fulfilled
                            resolve(values)
                        }
                    },
                    (reason) => reject(reason)) // onRejected
            })
        })
    }
  
    static allSettled(iterable) {
        return new Promise((resolve) => {
            let promises = [...iterable].map(i => Promise.resolve(i))
            let values = [...iterable].map(_ => undefined)
            let remainingElementsCount = 0 // 计算未settled的p

            promises.forEach((p, index) => {
                remainingElementsCount++
                p.then(  
                    (x) => { // onFulfilled
                        remainingElementsCount--
                        values[index] = {
                            status: "fulfilled",
                            value: x
                        }
                        if (remainingElementsCount === 0) { 
                          // 此时其他所有的p状态都不是pending
                            resolve(values)
                        }
                    },
                    (reason) => { // onRejected
                        remainingElementsCount--
                        values[index] = {
                            status: "rejected",
                            reason
                        }
                        if (remainingElementsCount === 0) { 
                          // 此时其他所有的p状态都不是pending
                            resolve(values)
                        }
                    })
            })
        })
    }
}

至此,一个简易的Promise就完成了。

Async/await的异步实现

16.生成器中,我已经为你展示了generator是如何实现异步编程的,其关键就是对执行上下文的保存与恢复。实际上,Async函数对异步的实现方式,与generator本质上是一样的,以下是它们之间的算法对比:

Async函数generator
创建抽象闭包、初始化执行上下文AsyncFunctionStartGeneratorStart
暂停执行AwaitGeneratorYield
恢复执行由微任务队列执行时恢复GeneratorResume

你也可以通过以下代码看到Async函数相关抽象操作的调用顺序:

async function a(){
    // 1. 触发【AsyncFunctionStart】
    console.log(1)
    await 0; // 2. 触发【Await】
    console.log(2); // 3. 微任务队列执行时恢复执行
    await 0; // 4. 触发【Await】
    console.log(3); // 5. 微任务队列执行时恢复执行
}

a()

从执行Async函数体的EvaluateAsyncFunctionBody运行时语义我们可以看出,Async函数会先创建一个promise,然后启动AsyncFunctionStart的执行,执行完毕之后,会返回这个promise。

AsyncFunctionStart的逻辑如下:

async-function-start

这里的关键逻辑是:

  1. AsyncFunctionStart:
    1. (1)使变量runningContext为当前执行上下文(由于此时正在执行Async函数,所以runningContext是由Async函数创建的,图中使用红色标注)。
    2. (2)使变量asyncContextrunningContext的一份拷贝。(图中使用黄色标注)
    3. (4)启动AsyncBlockStart:
      1. (3)创建一个抽象闭包(青色的closure),这个抽象闭包封装了一段逻辑:执行Async函数体内的语句列表(绿色的asyncBody),并根据执行的结果,选择resolve或者reject由Async函数创建的promise。
      2. (4)设置asyncContextcode evaluation state 组件,使得每次asyncContext压入调用栈栈顶并恢复执行时,抽象闭包从原来暂停的地方继续执行。
      3. (5、6)把asyncContext压入调用栈栈顶,并启动抽象闭包的执行

Generator函数的执行会创建generator但不会立即执行这个过程中创建抽象闭包;而Async函数的执行不仅会创建promise,还会直接启动抽象闭包的执行。

在执行抽象闭包的过程中,当遇到await语句的时候,会触发抽象操作Await

await

这里的关键逻辑如下:

  1. (2)对await表达式后面的值(青色的value)使用Promise.resolve(value),得到一个新的promise(绿色的promise)。
  2. (3)创建一个抽象闭包(棕色的fulfilledClosure),这个抽象闭包会在后续作为promisethen方法的第一个参数使用,它会把asyncContext重新压入调用栈栈顶,并以传入的参数(红色的v)恢复其执行。
  3. (5)再创建一个抽象闭包(蓝色的rejectedClosure),这个抽象闭包会在后续作为promisethen方法的第二个参数使用,它会把asyncContext重新压入调用栈栈顶,并以传入的参数(粉红色的reason)恢复其执行。
  4. (7)通过promsise.then()把两个抽象闭包注册到promise上
  5. (8)移除asyncContext,恢复原来asyncContext下面的执行上下文的执行。

整个await的逻辑,相当于以下的代码:

const promise = Promise.resolve(value)
const fulfilledClosure = (v) => {/*以参数v恢复执行*/}
const rejectedClousure = (reason) => {/*以参数reason恢复执行*/}
promise.then(fulfilledClosure, rejectedClousure)
// 移除asyncContext

这就解释了为什么当await表达式所创建的promise被resolve的时候,Async函数内的逻辑会重新启动执行。由此,Async函数就实现了异步编程。

在Async函数中首次遇到await的时候,此时调用栈中,asyncContext下方的执行上下文是由Async函数创建的执行上下文runningContext。因此,当asyncContext弹出后,会执行EvaluateAsyncFunctionBody运行时语义中未完成的逻辑,即返回一个promise,如以下代码所示:

async function a(){
    console.log(1)
    await 0;
    console.log(2);
    await 0;
    console.log(3);
}

console.log("Promise: " + a())
// 1
// Promise: [object Promise]
// 2
// 3

返回promise之后,runningContext就被销毁了,之后每一次恢复执行,使用的都是asyncContext


8.执行环境,我们早已对Async/await执行过程中调用栈的变化进行了可视化,现在你可以再看一次这张图,相信你会有许多新的感悟:

async function a(){
    console.log(1)
    await 0;
    console.log(2);
    await 0;
    console.log(3);
}
a()

这段代码的执行过程中,调用栈的变化如下图所示:(出于易读性的考虑,此图忽略了上面提到的runningContext

execution-context