从 Promises/A+ 看异步流控制

2020 • 2月 15 • 33 分钟阅读

单线程的挑战

众所周知,JavaScript 是一种 单线程 编程语言。单线程注定了在当前执行上下文栈中的代码还没有执行完成时,后续所有 task/micro-task queue 中的 task/micro-task 代码都不会执行。

为什么要单线程,多线程不好吗?

JavaScript 设计之初,它的设计定位是一种主要用于操作 DOM 的图灵完备语言。为了避免并发操作 DOM 带来的并发操作问题,并结合其语言定位,就注定了它单线程的命运。

Ajax 出现之前,所有的 JS 代码都是以同步的方式逐步执行。Ajax 的出现为网页带来了在不刷新页面的前提下就能更新页面的能力。Web 世界在搭上了 Ajax 的快车之后飞速发展。但是,当网络条件不稳定时问题出现了,同步的 Ajax 会始终占用主线程的执行上下文,导致在请求没有响应的期间,所有后续的同步代码都无法执行,那么整个网页就陷入了 “瘫痪”,所有的用户交互都被堵在了 task queue 中。

用户交互属于 task 而非 micro-task。

这个时候聪明的人们发明了 异步请求。在每次请求发出后,设置一个回调函数等待响应返回再处理响应,并且在等待的过程中 不再占用当前执行上下文

然而事情并没有那么简单。随着时间推移,网页需求越来越复杂,网页应用也越来越复杂,一不小心我们就掉进了 callback hell,究其原因是我们没有一个优雅的前端异步流控制 asynchronous flow control 解决方案。

如下:

const verifyUser = function(username, password, callback) {
  effect.verifyUser(username, password, (error, userInfo) => {
    if (error) {
      callback(error)
    } else {
      effect.getRoles(username, (error, roles) => {
        if (error) {
          callback(error)
        } else {
          effect.logAccess(username, error => {
            if (error) {
              callback(error)
            } else {
              callback(null, userInfo, roles)
            }
          })
        }
      })
    }
  })
}

常规的 callback API 不再能够满足包含大量异步操作的网页应用,大量的 异步操作 就意味着必须有一个合理的 异步流控制 方案来掌控各种异步流操作。

那么后来我们是如何避免回调地狱来清晰地实现异步流控制?

答案是 Promises/A+ 规范。

本文将严格依据 Promises/A+ 规范约束最终实现一个自定义版本且通过 规范测试套件 的异步流控制解决方案。在 @lbwa/promise-then 你可以找到完整的 Promises/A+ 实现。

什么是 Promises/A+

Promises/A+ 一个略微中二的名字,但它给社区给开发者带来了异步操作流的重磅炸弹—— Promise。 规范定义了一个名为 Promise 的对象来承载当下异步操作流的 最终结果。它用同步的方式表示了当下异步操作流的状态以及与状态相关联的返回值。

  1. 在操作成功的情况下,异步操作的返回值 value 是多少;
  2. 在操作失败的情况下,又是因为什么原因 reason 失败的。
// 仅借助 ts 的接口来表示 Promise 的 **抽象** 结构,不代表实现细节
interface Promise {
  state: PromiseState
  value: any // 不论是成功还是失败,都通过该字段存储对应的值或失败原因
}

Promise 对象交互的 首要方式 都是通过一个名为 then 的方法来实现,如修改 Promise 的状态,返回异步操作返回值或异步操作失败原因。

那么在定义了这样的规范核心的前提下,所有的规范内容都是围绕实现以上两点核心功能点。

如何表示异步流的当下状态

根据上文中 Promises/A+ 规范对 Promise 对象的定义。从本质上来讲,Promise 是一种抽象的状态模型,一种 有限状态机。规范在第一章首要位置首先定义了 3 个值来表示 Promise 对象的状态,即表示了所有异步操作流可能出现的三种状态:

  1. pending 状态,如字面意思一样,表示当下异步操作流正在执行中,正在 等待 异步流操作的结果。
  2. fulfill 状态,表示当下异步操作流已经操作 成功,并在 Promise 对象中包含了当下操作的执行结果。

    本文遵循 Promise/A+ 的表示方式,fulfill 等价于 ES6 Promise 中的 resolve 状态。

    interface FulfilledPromise {
     state: States.fulfilled
     value: any
    }
  3. reject 状态,表示当下异步操作流操作 失败,并在 Promise 对象中包含了当前操作失败的原因 reason

    interface RejectedPromise {
     state: States.rejected
     value: Error
    }

所有的异步操作流仅存在以上三种操作状态,那么笔者借助 TypeScript 中的静态枚举可实现以下结构包含所有的静态状态变量值:

const enum States {
  pending = 'pending',
  fulfilled = 'fulfilled',
  rejected = 'rejected'
}

在笔者个人对 Promise/A+ 的实现中,之所以使用静态枚举的原因是在 TypeScript 中所有的静态枚举值都可在 compile time 时期被直接编译为 静态变量字面量,而不是 JS 运行时中的一个朴素的 JS 对象,这里的静态枚举起到的作用类似于一些编译型语言(如 C++)中的 编译时常量

实现的技术核心是什么

不论是回调地狱还是 Promises/A+ 规范,首先抛开实现技术细节上来看,所有的异步操作流程都具有:

  1. 注册回调

    因为是异步流控制,异步流内部相对于外部模块来说始终是在进行异步操作,那么在执行异步操作开始,进行中,结束时的任意阶段都首先通过异步操作模块对外暴露接口 注册一个或多个回调函数。

    回调地狱只能在异步流开始之前进行回调注册,而 Promises/A+ 定义了同一个 Promise 实例可以调用多次 then 函数,即实现了多阶段多个回调注册。

  2. 触发回调

    在异步流不再为 pending 状态时,那么给对应状态注册的所有回调函数,会 依据注册顺序 分别得到执行。

通过对关键点的梳理,所有的回调函数都是直接依赖于异步流的状态的,那么它们都是当前异步流状态变化的 观察者,而当前异步流的状态变化始终是所有回调函数的 主题。结合二者的关系分析,不难通过 observer 模式实现异步流控制的 技术核心

这里笔者的实现是将 then 回调传入的回调函数 onFulFilledonRejected 合二为一看待,它们形成一个 回调函数 整体 ThenableCallbacks。该集合整体 直接依赖 于当前的 Promise 实例,当前实例作为所有回调集合主题 subject。侧重于集合整体观察 Promise 状态变换,而非具体状态值。在存在 Promise 状态值变换,即 subject 变化时,将广播至每一个回调集合。故说是通过 observer 模式(而不是 publish/subscribe 模式)。

interface ThenableCallbacks {
  onFulfilled?: OnfulfilledCallback
  onRejected?: OnRejectedCallback
}
ThenableCallbacks --观察--> Promise.state 的变化

当然存在另外一种抽象思路是,将 onFulfilledonRejected 都认为是独立的个体,它们分别通过 fulfilledQueuerejectedQueue间接依赖 当前的 Promise 实例。并且 fulfilledQueuerejectedQueue 通过 message brokerevent bus 来订阅各自的期望主题 topic。这个时候整个模式侧重的不再是 Promise 实例的状态变化,而是实例的某一个具体的状态值。所有的回调函数的触发都是通过 message brokerevent bus 来发布对应的 topic 来实现函数调用。而这种抽象设计模式正是 publish/subscribe 模式。

type FulFilledQueue = OnfulfilledCallback[]
type RejectedQueue = OnRejectedCallback[]
onFulfilled --注册--> fulfilledQueue --订阅--> fulfilled state
onRejected --注册--> rejectedQueue --订阅--> rejected state

这里二者设计模式实现的异步流控制方案复杂程度并没有太大差异,本文也将采用第一种 observer 方式来严格实现技术细节。

异步控制流的初始化

在初始化异步流阶段,我们应该如何实现 Promise 初始化?通过对规范中 The promise resolution procedure 阅读可见,规范中将异步流初始化定义为一种基于单个 promise 和一个单值 x 处理的异步流抽象操作,记为 [[Resolve]](promise, x)

  1. 这里 promise 就是在初始化 Promise 构造函数所返回的 Promise 实例,记为 $0。它始终是对外暴露的。
  2. 单值 x 是作为当前异步流的结果载体,它本身是表示一个异步流的结果返回值,不应被模块外部直接修改。当 x 表示一个非 Promise 实例的值时,它的存在会直接被当前 $0 实例直接引用为 Promise 的实例 value 字段的值。

    细心的读者可能发现,x 值是可以为另一个 Promise 实例的,那么在这种情况下,当前 $0 实例会尝试直接同步 x 变量所引用的 Promise 实例的状态 state,并将该实例的 value 字段的异步流结果值,直接赋值给 $0 实例的 value 字段。

promise procedure

在基于以上两点的前提下,我们在初始化 Promise 阶段的核心目标是,实现一个有限状态机,并接收一个 executor接收 状态机对外的暴露的状态变换器。该变换器的核心目标是,在被调用的情况下,修改状态机内部的状态值。另外,基于 Promises/A+ 规范中的定义,所有已经固定的状态,无法被再次修改

Promise 实例的状态被状态变换器触发改变的条件下,同时通过状态变换器来接收执行结果或执行错误。并将执行结果或执行错误赋值给 Promisevalue 字段,以供 Promise 的交互接口 then 函数来获取对应的异步流操作状态和操作结果。

class Promise {
  // 初始化实例的状态为 pending 状态
  private state: States = States.pending
  private value: any = undefined

  // 模块内部的状态变换器核心
  private _settle(state: Exclude<States, States.pending>, result: any) {}

  constructor(executor: (onFulfilled, onRejected) => void) {
    executor(
      function fulfill(result?: any) {}, // 对外状态变换器
      function reject(reason?: Error) {} // 对外状态变换器
    )
  }
}

在上文中,通过简单代码展示了 Promise 初始化的核心抽象流程。fulfill 函数对应了前文所述的向模块外部暴露,用于修改 Promise 状态机的 fulfilled 状态的状态变换器;而 reject 函数同样是向外暴露,且用于修改 Promiserejected 状态的状态变换器。

另外,我们抛开技术实现细节,从函数的抽象功能来看。不论是 fulfill 函数还是 reject 函数它们起到的作用不外乎:

  1. 变换当前 Promise 实例状态机的状态至一个指定值。
  2. 被调用时,将接收外部传递的异步流操作的结果返回值,并将其在当前 Promise 实例中保存。
  3. 在实现了 Promise 状态机状态变换后,应该发送 异步通知 给所有的之前已经注册的观察当前状态的回调函数。

基于以上三点和 Don't repeat yourself 原则,那么我们可以从功能抽象的角度来实现一个 _settle 内部函数,用于实现 真正的状态变换 功能。

interface ThenableCallbacks {
  onFulfilled?: (result?: any) => any
  onRejected?: (reason?: )
}

class Promise {
  private _observers: ThenableCallbacks[] = []
  // ...
  private _settle(state: Exclude<States, States.pending>, result: any) {
    if (state !== States.pending) return
    this.state = state
    this.value = result
    this._notify(state === States.fulfilled ? 'onFulfilled' : 'onRejected')
  }

  private _notify(type, message?: any) {}
  // ...
}

那么在通知状态的时,为什么要实现 异步通知 而不是直接通过 同步通知 观察状态变化的回调函数?

这是因为在 Promise/A+ 中已经明确定义,所有 onFulfilledonRejected 回调函数,不能在当前执行上下文栈中执行,而是必须等到 一个全新的仅有平台代码的执行上下文栈 中执行。

平台代码是指仅有 JS 引擎,环境和 Promise 实现代码而不含其他业务代码的场景。

如何实现回调函数调用

根据 ECMA 262 规范 8.3 Execution Contexts 章节模型,所有平台 JS 代码执行都是依赖于 执行上下文 execution context,其容器为 执行上下文栈 execution context stack,而 执行上下文栈 是由 task queuemicro-task queue 来驱动。

在代码执行完成时,即当前 执行上下文 移交 running execution context 的标志时,会退出当前 执行上下文栈。在当前 running task/micro-task 执行完成之际,也就是当前 执行上下文栈 中清空后,会由下一个 task queue 中的 taskmicro-task queue 中的 micro-task 来创建一个 执行上下文栈 栈底的执行上下文,并继续执行新的 task/micro-task 中的代码。

在 JS 中执行上下文的类型分为:函数执行上下文,全局执行上下文,eval 执行上下文(如无必须,不推荐使用,故不讨论)。所有的函数执行都会创建一个新的执行上下文用于追踪代码执行,反之,其他代码执行都会在全局执行上下文中执行。

那么依据上文,回调函数的执行必须等到当前执行上下文栈清空,并在一个空白的执行上下文栈来执行 onFulfilledonRejected 回调,即除开平台代码外,onFulfilledonRejected 回调函数必须是当前执行上下文栈中最靠近栈底的第一个执行上下文。

根据 JavaScript事件驱动模型

  1. 若需要将回调函数加入到 task queuequeue task,那么我们需要基于一个 task 引导 来执行待执行的回调函数,如 setTimeoutsetImmediate(限 IENode.js 环境);
  1. 若需要将回调函数加入到 micro-task queuequeue microtask,那么我们需要基于一个 micro-task 引导 将待执行回调函数加入到 micro-task queue 中,如 MutationObserver(限浏览器环境)、process.nextTick(限 Node.js 环境)。

本文也将依据以上理论,基于 setTimeout 来实现名为 marcoTaskRunnertask 创建函数。

function marcoTaskRunner(this: any, fn: Function, ...args: any[]) {
  return setTimeout(() => fn.apply(this, args), 0)
}

那么完善在新的执行上下文中执行 onFulfilledonRejected 回调函数的基础后,不难写出向所有回调函数观察者广播的 _notify 函数,并基于此得到整个状态修改后的通知观察者流程:

settle promise

class Promise {
  private _observers: ThenableCallbacks[] = []

  // ...
  private _settle(state: Exclude<States, States.pending>, result: any) {
    if (state !== States.pending) return
    this.state = state
    this.value = result
    this._notify(state === States.fulfilled ? 'onFulfilled' : 'onRejected')
  }

  private _notify(type, message?: any) {
    this._observers.forEach(callbacks => {
      const handler = callbacks[type]
      if (isFunction(handler)) {
        // spec 2.2.5
        // https://promisesaplus.com/#point-35
        handler.call(null, message)
      }
    })
  }
  // ...
}

当在前文中 executor 的外部状态转换器 fulfillreject 函数触发 Promise 内部状态变换器后,将触发状态修改,结果赋值,调用回调函数一系列流程。这即是实现有序的异步流控制流程 Promise 方案的 技术核心 之一。

剩余一些在初始化 Promise 阶段的特定流程可在 the promise resolution procedure 找到。本文将省略已经在规范中明确定义的普通初始化流程。

then 方法实现

Promise/A+ 中,定义了所有异步流控制的回调函数都是通过 then 函数来注册。then 函数始终返回一个 新的 Promise 实例。

2.2.7 then must return a promise.

深入思考一下,为什么 then 要返回一个新的 Promise 实例。这样做的原因是,then 函数返回的 Promise 实例可继续被下一个 then 函数访问,这样循环往复,最终可实现 Promise 链式(级联) 调用。

asyncAction()
  .then(function callbackA() {}, function catchA() {})
  .then(function callbackB() {}, function catchB() {})
  .then(function callbackC() {}, function catchC() {})
  .then(function callbackD() {}, function catchD() {})

为什么要支持 级联调用

级联调用 私以为最大的 优势 在于开发者可以将不同的异步流将一个繁琐的异步流操作 分解 decouple 为更小的异步流操作,而更小的操作意味着更多的操作组合可能性。这样的优势可大大增强代码灵活性,可读性,维护性。所有的异步流操作不再受限于传入的 API 格式,如果有必要任意组合多个异步流操作。

在不同时期处理回调函数

class Promise<T> {
  // ...
  then(onFulfilled?: OnFulfilled<T>, onRejected?: OnRejected): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      if (isStrictEql(this.state, States.pending)) {
        // TODO
        return
      }

      if (isStrictEql(this.state, States.fulfilled)) {
        // TODO
        return
      }

      if (isStrictEql(this.state, States.rejected)) {
        // TODO
        return
      }
    })
  }
}

Promise 显式原型上的 then 方法将根据 Promise 状态机可能的三种状态进行三种不同类型的回调函数操作。

结合 Promises/A+2.2 The then method 中的定义:

  1. 不论是 onFulfilled 还是 onRejected 回调函数,必须等待 this 所指向的 Promise 实例的状态固定为 fulfilledrejected 之后才能被调用。

    换句话说,当 this 所指的 Promise 实例为 pending 状态时,应该将 onFulfilledonRejected 设置为当前 Promise 状态的观察者,通过 observer 模式实现状态修改广播。

    当然这里也更加细致地分别订阅 fulfilled 状态和 onRejected 状态,进而基于 publish/subscribe 模式实现发布状态的 topic,进而触发调用已经注册的回调函数队列。

  2. this 所引用的实例状态为 fulfilled 时,应该调用所有之前注册的 onFulfilled 函数,并将 this 引用的实例的 value 字段作为 onFulfilled 的参数传入,以用来表示 Promise 实例所代表的异步流的操作结果。
  3. this 所引用的实例为 rejected 状态时,应该调用 onRejected 函数,并将 this 引用的实例的 value 值,作为 onRejected 的参数传入,以用来表示 Promise 实例被 rejected 的原因。

那么在基于以上对规范的抽象总结后,不难得到:

class Promise<T> {
  // ...
  private _register(onFulfilled: OnFulfilled<T>, onRejected: OnRejected) {
    // 方案一:observer 模式,以回调集合为基准,作为 Promise 实例变化的观察者
    this._observers.push({
      onFulfilled,
      onRejected
    })

    // 方案二:publish/subscribe 模式,以单个回调为基准,订阅特定的 topic
    // this._onFulfilledSubs.push(onFulfilled)
    // this._onRejectedSubs.push(onRejected)
  }

  // ...
  then(onFulfilled?: OnFulfilled<T>, onRejected?: OnRejected): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      if (isStrictEql(this.state, States.pending)) {
        this._register(
          result => onFulfilled(result),
          reason => onRejected(reason)
        )
        return
      }

      if (isStrictEql(this.state, States.fulfilled)) {
        return marcoTaskRunner(() => onFulfilled(this.value))
      }

      if (isStrictEql(this.state, States.rejected)) {
        return marcoTaskRunner(() => onRejected(this.value))
      }
    })
  }
}

那么我们是不是就最终实现了 Promise 显式原型上的 then 方法了呢?

并没有,因为在规范中,还定义了 then 方法的两个参数都是 可选参数,而以上我们对 then 方法的实现都是基于 onFulFilledonRejected 都是强制参数,且都为函数的情况。

class Promise<T> {
  // ...
  then(onFulfilled?: OnFulfilled<T>, onRejected?: OnRejected): Promise<T> {
    const createFulfilledHandler = (
      resolve: OnFulfilled<T>,
      reject: OnRejected
    ) => (result?: any) => {
      try {
        if (isFunction(onFulFilled)) {
          return resolve(onFulFilled(result))
        }
        return resolve(result)
      } catch (evaluationError) {
        reject(evaluationError)
      }
    }

    const createRejectedHandler = (
      resolve: OnFulfilled<T>,
      reject: OnRejected
    ) => (reason?: Error) => {
      try {
        if (isFunction(onRejected)) {
          return resolve(onRejected(reason))
        }
        return reject(reason)
      } catch (evaluationError) {
        reject(evaluationError)
      }
    }

    return new Promise<T>((resolve, reject) => {
      const handleFulfilled = createFulfilledHandler(resolve, reject)
      const handleRejected = createRejectedHandler(resolve, reject)

      if (isStrictEql(this.state, States.pending)) {
        this._register(
          result => handleFulfilled(result),
          reason => handleRejected(reason)
        )
        return
      }

      if (isStrictEql(this.state, States.fulfilled)) {
        return marcoTaskRunner(() => handleFulfilled(this.value))
      }

      if (isStrictEql(this.state, States.rejected)) {
        return marcoTaskRunner(() => handleRejected(this.value))
      }
    })
  }
}

基于规范 then 函数的功能定义,将 fulfilledrejected 状态分别对应的功能逻辑抽象出两个高阶函数 createFulfilledHandlercreateRejectedHandler。由两个高阶函数分别生成对应状态的处理函数。

至此,以上即是 then 函数中的核心功能点:

  1. 依托前文已经实现的 Promise 实例化流程,then 函数不论在何种情况下都始终返回一个新的 Promise 实例。
  2. then 函数内部会对 this 所引用的 Promise 实例的状态进行针对性的回调函数处理:

    1. thispending 状态的 Promise 实例,那么在当前 event loop 中不会调用任何回调函数,并且,将所有回调函数与 Promise 形成绑定关系,结合前文中的状态变换器,在发生任意的状态固定时,将触发对应的回调函数调用。
    2. thisfulfilled/rejected 状态的 Promise 实例,即表明该 Promise 实例状态已经固定,那么指定状态的回调函数将在下一轮 event loop 中被调用。

结论

以上笔者避免繁琐地把 Promises/A+ 规范的一条条定义逐步进行阐述,而是着重在于阐述分析规范中的 核心思维模式技术细节关键点。笔者私以为技术实现方案千差万别,实现的技术细节并不是最有价值的东西,最有价值其实是技术背后的思想。对于异步流控制解决方案来说,对于 Promises/A+ 来说,最核心的思维模式是:

  1. 通过一个有限状态机表示一个异步流控制流程。
  2. 通过状态机内部状态变化,触发状态修改,进而触发回调队列的顺序调用。本质上是一种基于 observerpublish/subscribe 模式,回调函数与异步流状态之间的相互依赖的关系。
  3. 规定 then 函数如 Promise 构造函数一样始终返回一个新的 Promise 实例,使得 链式(级联) 调用成为了可能。

横向对比

在深入 Promises/A+ 规范之后,如雨后春笋般出现了各种具有不同适用场景的规范实现,应用最广泛的即是众所周知的 ECMA 262 规范中的 JS 内置对象 —— Promise。那么 Promise 内置对象以及同样具有异步流控制能力的 async function 又是如何为开发者提供高效简洁的异步流控制?

ECMA 262 实现

根据最新的 ECMA 262 中对 Promise 章节的阐述,Promises/A+ 规范中 fulfilled 状态在 ECMA 262 中被定义为 resolved 状态。Promise 内置对象不仅仅实现了 Promises/A+ 标准,还在其基础上进行了拓展,Promise 显式原型对象上不仅仅包含 then 函数,还包含了:

功能函数 使用场景
catch 捕获 Promise 链中之前未被处理的 rejected 状态。等价于 promise.then(null, function catch() {})
finally 只要 Promise 链之前的 Promise 状态得到固定(即不为 pending 状态),都始终触发传入 finally 的回调函数。
all 适用于多个异步流并发的场景,当存在一个包含多个 Promise 实例的列表时,只有当所有的 Promise 实例状态都为 resolved 状态时,all 函数返回的 Promise 实例的状态才会为 resolved
race 适用于多个异步流并发的场景,当存在一个包含多个 Promise 实例的列表时,第一个固定状态的 Promise 状态和值,会被赋值给当前 Promise.all 生成的 Promise 实例。
allSettled 适用于多个异步流并发的场景,当存在一个包含多个 Promise 实例的列表时,只有当所有 Promise 实例都固定状态时才会返回一个结果列表,该列表展示了对应索引 Promise 的状态和结果值(或 rejected 原因)。

本质上 JS 中的 Promise 内置对象是 Promises/A+ 的实现的拓展版本,在完成了规范定义的要素和功能之后,又在其核心功能上拓展出了以上几个便捷的 API 用于处理异步流控制。

实践:多重 Promise 的嵌套

以下实践基于 web api 规范中 Promise 实现,在该规范中 Promisethen 的回调函数被定义为 micro-task。请首先充分理解 web api 规范中的 event loop processing model 章节以及 ECMA 262 规范中的 execution contextexecution context stack 再进行以下阅读。

// start
new Promise(/* executor0 */ resolve => resolve()) // promise0
  // thenable0
  .then(/* onfulfilled0 */ () => console.log(0))
  // thenable1
  .then(
    /* onfulfilled1 */ () => {
      console.log(1)

      /* nestedPromise0 */
      new Promise(/* executor1 */ resolve => resolve())
        .then(/* onfulfilled2 */ () => console.log(2))
        .then(/* onfulfilled3 */ () => console.log(3))

      console.log(4)

      /* nestedPromise1 */
      new Promise(/* executor2 */ resolve => resolve())
        .then(/* onfulfilled5 */ () => console.log(5))
        .then(/* onfulfilled6 */ () => console.log(6))
    }
  )
  // thenable7
  .then(/* onfulfilled7 */ () => console.log(7))
  // thenable8
  .then(/* onfulfilled8 */ () => console.log(8))[
  /* fn9 */ (() => {
    console.log(9)
    return true
  })()
]
// end

2.2.2.1 If onFulfilled is a function, it must be called after promise is fulfilled, with promise’s value as its first argument.

2.2.7 then must return a promise.

2.2.7.1 If either onFulfilled or onRejected returns a value x, run the Promise Resolution Procedure [[Resolve]](promise2, x).

基于以上 Promise A+ 标准,若 thenthis 值所指向的 promise 实例在 pending 状态时,那么当前的 then 返回的 promise 实例也为 pending 状态,且 thenthenableCallbacks 不会被加入到 micro-task queue 中,即后续不会参与执行。

若前一个 then 的状态为 pending,那么级联调用链中后续所有 then 返回的 promise 实例的状态均为 pending

有且仅有 this 值的 promise 实例的状态不为 pending 时,thenableCallbacks 才会被加入到 micro-task queue 中待执行。

根据 2.2.7.1 有且仅有 thenableCallback 执行完成后,当前 then 方法返回的 Promise 实例状态才会被固定。

那么对于示例代码来说,仅当 promise0 的状态固定时,才会触发 thenable0 的状态固定,进而才会触发 onfulfilled0 的调用。在 onfulfilled0 状态为 pending 状态时,后续所有 then 方法(thenable1thenable7thenable8)返回的 promise 实例 始终保持 pending 状态。

  1. 初始化基于 web api 的事件循环的初始 tick

    /**
    * LIFO stack: 0(newer) <--索引--> n(older)
    * 索引 0 为 running execution context
    */
    const executionContextStack: ExecutionContext[] = []
    
    /* FIFO queue: 0(older) <--索引--> n(newer) */
    const taskQueue: Task[] = [scriptTask]
    const microTaskQueue: MicroTask[] = []
  2. 根据 Promise A+ step.2.3,首先立即实例化 Promise 实例,并在当前执行上下文中创建一个新的函数执行上下文,即执行 executor0 回调函数。而在 executor0 函数中调用了 resolve 使得返回的 Promise 实例状态为 resolved

    /**
    * LIFO stack: 0(newer) <--索引--> n(older)
    * 索引 0 为 running execution context
    */
    const executionContextStack: ExecutionContext[] = [
     executor0,
     promiseInstantiation,
     scriptExecutionContext
    ]
    
    /* FIFO queue: 0(older) <--索引--> n(newer) */
    const taskQueue: Task[] = []
  3. 在完成 Promise 实例化后,分别注册 ThenableCallbacks 回调,即示例代码中的 onfulfilled0onfulfilled1onfulfilled7onfulfilled8

    注意,以上回调函数仅仅是注册,类似于 发布/订阅 模式中的 订阅 指定的 topic 操作。此时的这些回调函数 并没有 加入到 micro-task queue 中,更谈不上执行。

  4. 并继续执行至 fn9 处,并在控制台打印 9

    /**
    * LIFO stack: 0(newer) <--索引--> n(older)
    * 索引 0 为 running execution context
    */
    const executionContextStack: ExecutionContext[] = [
     (() => {
       console.log(9)
       return true
     })(),
     scriptExecutionContext
    ]
    9

    在打印 9 后,返回 true 值(此时为 Boolean 类型),并调用 Boolean 的原型方法 toString 转换为 String 类型来获取 thenable8 的返回值的 true 属性。又根据 Promise A+then 函数的规范定义,then 方法应该始终返回一个 Promise 实例,那么,以上获取 true 属性的本质是获取一个 promise 实例的 true 属性,即为 undefined。但是这里因为没有被 console.log 包裹,故不会在控制台有任何打印。

  5. 因为 promise1 早在第一步中状态因 executor0 的执行而固定为 resolved,那么此时 onfulfilled0 将被加入到当前事件循环的 micro-task queue 中称为该队列的第一个待执行函数。

    /* FIFO queue: 0(older) <--索引--> n(newer) */
    const microTaskQueue: MicroTask[] = [onFulfilled0]

    又根据 Promise A+ 2.2.4 对于 thenableCallbacks 执行时机的定义:

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

    所有的 onfulfilledonRejected 回调函数都必须在一个仅包含平台代码的全新的 执行上下文栈 中执行。什么叫仅包含平台代码的全新执行上下文栈?简短来说就是不包含任何用户代码的执行上下文栈,即在执行上下文栈中最底层的 执行上下文 不能为 Script 执行上下文。

    那么在 Script 执行上下文退出执行上文栈后,即示例代码 // end 之后,此时的执行上下文栈为空,那么开始执行 micro-task queue 中的 micro-tasks,即开始调用队列中唯一的待执行函数 —— onfulfilled0 函数,并打印 0

    /**
    * LIFO stack: 0(newer) <--索引--> n(older)
    * 索引 0 为 running execution context
    */
    const executionContextStack: ExecutionContext[] = [onfulFilled0]
    
    /* FIFO queue: 0(older) <--索引--> n(newer) */
    const taskQueue: Task[] = []
    const microTaskQueue: MicroTask[] = []
    9
    0
  6. thenable0 执行完成后,根据 Promise A+ 2.2.7.12.3.4 返回一个 resolved 状态的 promise 实例。根据 Promise A+ 2.2.2.1,当 thenable0 的状态变为 resolved 时,将使得 onfulfilled1 加入到当前 micro-task queue 中并成为唯一地待执行函数。

    /* FIFO queue: 0(older) <--索引--> n(newer) */
    const microTaskQueue: MicroTask[] = [onfulfilled1]

    在开始执行 onfulfilled1 时,首先打印 1。之后实例化 nestedPromise0 实例,并因为 executor1 的执行而改变状态为 resolved。后续逻辑如同 promise0 的实例化逻辑和 thenableX 等函数的回调逻辑一致,故在打印 4 之前,会首先将 onfulfilled2 加入 micro-task queue 中。同理在 micro-task queue 中加入 onfulfilled5

    /* FIFO queue: 0(older) <--索引--> n(newer) */
    const microTaskQueue: MicroTask[] = [onfulfilled2, onfulfilled5]

    此时 onfulfilled1 执行完成后,有以下控制台输出:

    9
    0
    1
    4

    此时,遵循事件循环规范,开始处理当前事件循环 tick 中的 micro-task queue。其中 micro-task queue 中有且仅有 onfulfilled2onfulfilled5。清空 micro-task queue 后,存在以下控制台输出:

    9
    0
    1
    4
    2
    5
  7. 根据 2.2.7.1,在完成 onfulfilled1 的执行时,会触发 thenable1 的状态固定为 resolved,那么会触发 onfulfilled7 加入到 micro-task queue 中,同理,onfulfilled2onfulfilled5 完成执行时,同样会触发 onfulfilled3onfulfilled6 加入到 micro-task queue 中,此时的 micro-task queue 的结构如下:

    /* FIFO queue: older <---> newer */
    const microTaskQueue: MicroTask[] = [
     onfulfilled7,
     onfulfilled3,
     onfulfilled6
    ]

    根据以上分析得到以下控制台输出:

    9
    0
    1
    4
    2
    5
    7
    3
    6
  8. 在执行 onfulfilled7 完成后,thenable7 的状态固定为 resolved,并导致 onfulfilled8 回调函数加入 micro-task queue

    const microTaskQueue: MicroTask[] = [onfulfilled8]

    得到以下控制台输出:

    9
    0
    1
    4
    2
    5
    7
    3
    6
    8

async function 又是什么

async function 在实际应用中表现为 async ... await 表达式,它的出现同样是为了给予开发者异步流控制的能力。

async function 的出现是为了什么?如前文阐述,Promise 支持级联调用,那么可能会出现一种情况——过长的级联调用链。而 async function 给开发者提供了一种 以同步的方式 来书写异步流程的可能。

  • 级联调用链
asyncAction()
  .then(function callbackA() {}, function catchA() {})
  .then(function callbackB() {}, function catchB() {})
  .then(function callbackC() {}, function catchC() {})
  .then(function callbackD() {}, function catchD() {})
  • 以同步的方式书写异步流程
async function newAsyncActionChain() {
  try {
    const resultA = await callbackA()
  } catch (error) {
    catchA(error)
  }

  try {
    const resultB = await callbackB()
  } catch (error) {
    catchB(error)
  }
  // ...
}

这样的同步方式更加符合人类的思维定势。但是 async function 同样具有缺陷,每一次通过 async await 表达式获取异步操作结果都需要使用 try...catch 语句来捕获所有异步流的 rejected 状态。

对比 async functionPromise 这两种异步流控制解决方案,它们各有优势,又各有弊端:

异步流控制 相对优势 相对劣势
async function 以同步思维写异步 需要使用额外代码 try...catch 处理异常
Promise 级联调用,可拆分更加细致的异步流控制 可能出现过长的级联调用链

另外,如果读者在使用 TypeScript 且设置较低版本的编译目标的后, TypeScript 会将代码中的 async function 编译为基于 Promise 函数和 Generator 函数的结合体。 而其他一些流行的 async function 方案,如 @babel/plugin-transform-async-to-generator 同样会将 async function 转换为 Generator 函数。

不论是从哪个实现上来看,async function 的流行 polyfill 都离不开 Generator 函数,因为 Generator 函数天生具有 暂停 函数执行的能力。这一点正好符合 await 语句的功能点。async function 本质上可以认为是一个不仅具有自动执行 next() 功能的 Generator 函数,而且还包含 Promise 的回调函数功能的结合体。

结论

通过以上对比发现,不论是 Promise 内置对象所提供的级联调用,还是 async function 的以同步方式书写异步流程,它们的出现都是为了更好的解决开发时各种异步流控制的问题。

结合全文来看,不论是通过什么样的方式实现异步流控制,其本质上,都是回调函数与异步流状态必须形成一种对应关系,在这种关系下,所有的回调函数都依赖于异步流状态的值的改变。在异步流状态改变的情况下,若存在对应的回调函数,就会被调用。