Async Function 背后的秘密

XYShaoKang
由于能力有限,难免会有疏漏不妥之处,还请不吝赐教!也欢迎大家积极讨论

前几天看到一道题async 输出顺序的一道前端面试题疑问

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2 start')
  return new Promise((resolve, reject) => {
    resolve()
    console.log('async2 promise')
  })
}

async1()

new Promise(function (resolve) {
  console.log('promise1')
  resolve()
})
  .then(function () {
    console.log('promise2')
  })
  .then(function () {
    console.log('promise3')
  })

自己算了下,得到跟题主一样的疑惑,为什么async1 end会跑到promise3的后面,怎么算都应该在promise2后面

我的理解实际输出
async1 startasync1 start
async2 startasync2 start
async2 promiseasync2 promise
promise1promise1
promise2promise2
async1 endpromise3
promise3async1 end

既然理解跟实际结果的有出入,那肯定是哪里理解不到位,先调试看看到底是哪一段代码出了问题

调试代码

经过调试,发现问题的关键是以下代码

async function async2() {
  console.log('async2 start')
  return new Promise((resolve, reject) => {
    resolve()
    console.log('async2 promise')
  })
}

为了演示方便,做了一些修改:

new Promise(function (resolve) {
  console.log('tick: 1')
  resolve()
})
  .then(() => console.log('tick:2'))
  .then(() => console.log('tick:3'))
  .then(() => console.log('tick:4'))
  .then(() => console.log('tick:5'))

async function foo() {
  return Promise.resolve()
}
foo().then(() => {
  console.log('after:foo')
})

输出顺序如下:

tick:1
tick:2
tick:3
tick:4
after:foo
tick:5

经过反复调试发现,如果 foo 不加 async 关键字,或者不返回 Promise,结果都符合预期,after:foo出现在tick:2后面.而如果这两个同时出现的时候,按照我的理解after:foo应该出现在tick:3后面,但是实际结果却比预期额外多一个tick,出现在tick:4后面.我做了张调试的对比图,可以比较直观的感受到差别:

compare.png

这里说明我的理解不到位,那就需要去研究清楚这段代码到底发生了什么.

正好之前看过一些词法语法以及产生式之类的知识,于是想尝试从 ES 规范中找找,看能不能找到答案,就当作练习如何阅读规范了。

结果证明我还是太年轻了,刚开始就看的我头皮发麻,根本看不懂,本来英语对我来说就已经是天书了,加上规范中各种独创的语法,真的是要了亲命了,不过好在有各路大神和前辈的文章(后面会列出相关的这些文章),讲解怎么去阅读规范,通过慢慢学习,总算是把涉及到的相关方面都理清楚了.

从 ECMAScript 规范角度去解释代码的运行

接下来,尝试从语言规范的角度去解释一下以下代码,希望能跟大家一起从另外一个角度去理解这段代码在实际运行中到底做了什么.

重新放一下代码,我的理解 async 关键字会产生一个 Promise,加上返回的 Promise 最多两个微任务,而实际运行中却是多了个微任务,要搞清楚多出的一个是从哪里来的.

async function foo() {
  return Promise.resolve()
}

先用一张图理一下整体的流程

限于我这还没入门的英语水平,就不一一翻译了,有需要的朋友可以点击链接直接看原文,如果跟我一样英语比较差的,可以用百度翻译谷歌翻译之类的工具。红框中是涉及到相关章节,后续只解释其中的关键步骤.

async-function.png

步骤解析

EvaluateAsyncFunctionBody

我们首先找到15.8.4 Runtime Semantics: EvaluateAsyncFunctionBody,这里定义了AsyncFunction是如何执行的

Runtime Semantics: EvaluateAsyncFunctionBody

关键步骤:

  • 1. 执行抽象操作NewPromiseCapability,该操作会返回一个PromiseCapability Record { [[Promise]]: promise, [[Resolve]]: resolve, [[Reject]]: reject },将其赋值给promiseCapability
  • 2. 抽象操作FunctionDeclarationInstantiation执行函数声明初始化,像参数变量的声明,各种情况的说明,跟本文没有很大关系
  • 3. 如果实例化没有错误,则执行AsyncFunctionStart(promiseCapability, FunctionBody)
  • ...

AsyncFunctionStart

接下来我们进到 27.7.5.1 AsyncFunctionStart ( promiseCapability, asyncFunctionBody ) 看看AsyncFunctionStart的定义

AsyncFunctionStart

关键步骤:

  • 1. 设置runningContextrunning execution context
  • 2. 设置asyncContextrunningContext的副本
  • 4. 设置asyncContext恢复后需要执行的步骤

    • a. 设置resultasyncFunctionBody的执行结果
    • ...
    • e. 如果result.[[Type]]return,则执行Call(promiseCapability.[[Resolve]], undefined, « result.[[Value]] »)
  • ...

这里关键的是第 4 步中的执行步骤,对于我们要理解的 foo 函数来说,会先执行Promise.resolve(),得到结果Promise {<fulfilled>: undefined},然后返回,所以result.[[Type]]return,会执行 4.e 这一步.

最终到 4.e 执行Call(promiseCapability.[[Resolve]], undefined, « result.[[Value]] »), Call是一个抽象操作,这句最后相当于转换成promiseCapability.[[Resolve]](« result.[[Value]] »).promiseCapability是一个PromiseCapability Record规范类型,在 27.2.1.1 PromiseCapability Records 中能看到PromiseCapability Record的定义

Promise Resolve Functions

顺着往下找,能找到27.2.1.3.2 Promise Resolve Functions的定义,接下来看看 resolve 都是怎么执行的.

Promise Resolve Functions

关键步骤,主要针对执行 resolve 时传入参数的不同,而执行不同的操作

  • resolve 方法接收参数resolution
  • 7. 使用SameValue(resolution, promise)比较resolutionpromise,如果为 true,则返回RejectPromise(promise, selfResolutionError),我的理解是为了避免自身循环引用,例:

    let f
    const p = new Promise(resolve => (f = resolve))
    f(p)
  • 8 - 12. 如果resolution不是对象,或者resolution是一个对象但resolution.then不是方法,则返回FulfillPromise(promise, resolution),例:

    // 8, resolution 不是对象
    new Promise(r => r(1))
    // 12, resolution.then 不是方法
    new Promise(r => r({ a: 1 }))
    new Promise(r => r({ then: { a: 1 } }))
  • 13. 设置thenJobCallbackHostMakeJobCallback(resolution.then.[[Value]])执行的结果JobCallback Record { [[Callback]]: callback, [[HostDefined]]: empty }
  • 14. 设置 job 为NewPromiseResolveThenableJob(promise, resolution, thenJobCallback)执行的结果Record { [[Job]]: job, [[Realm]]: thenRealm }

    • 上面这两步就是关键所在,这里的 job 会额外创建一个微任务,类似下面的伪代码:

      function job() {
        const resolution = { then() {} }
        const thenJobCallback = {
          [[Callback]]: resolution.then,
          [[HostDefined]]: empty,
        }
        return new Promise(function (resolve, reject) {
          thenJobCallback[[Callback]](resolve)
        })
      }
  • 15. 执行HostEnqueuePromiseJob(job.[[Job]], job.[[Realm]])

    • 这一步也会创建一个微任务,加上 job,如果传入的 resolution 还是一个 Promise 的话,那 resolution.then 还会创建一个微任务,这就解释了,为什么当在 Async Function 中返回 Promise 之后,after:foo会在tick:4之后出来

结论

至此我们可以知道中间的三个微任务都是哪里来的了:

  • HostEnqueuePromiseJob会创建一个微任务,这个微任务执行时,会去执行 NewPromiseResolveThenableJob返回的 job
  • NewPromiseResolveThenableJob返回的 job 执行时会创建一个微任务,当这个微任务执行时,去执行resolution.then
  • 加上如果resolution是一个 Promise,那执行 then 时,还会创建一个微任务

这其中NewPromiseResolveThenableJob返回的 job 就是之前我不知道的那点.这些都是 js 引擎在后面处理的,我们平常是没有感知的.如果不通过阅读规范,估计很难搞清楚这背后都发生了什么.

其实还有一种方法可以更接近实际运行的过程,就是去查看规范实现(既 js 引擎,比如 V8)的源码,不过相对来说可能阅读规范会比 C++ 的源码来的更容易一些.

为了方便记忆和理解,可以用 Promise 做如下转换

暂时执行结果是等价的,不过有可能之后会随着标准的修改,或者 JS 引擎实现的不同而有差异.
async function foo() {
  return Promise.resolve()
}
// =>
function foo() {
  const p = Promise.resolve()
  return new Promise(function (resolve, reject) {
    resolve(p)
  })
}
// =>
function foo() {
  const p = Promise.resolve()
  return new Promise(function (resolve, reject) {
    Promise.resolve().then(() => {
      p.then(resolve)
    })
  })
}

这里再放一张对比图,大家可以找找看跟前面一张有什么不同

compare2.png

关于面试时遇到这道题的"解法"

鉴于我也没有多少面试的经验,前不久刚搞砸了一场面试 😭,下面纯属我个人的 yy,没有多少实践基础,大家可以把这当作一个思路作为参考,如果有不对的地方欢迎补充和讨论

当我们遇到这种题,如果之前有研究过,那能给出正确的答案固然好.不过有可能会遇到一些题,日常使用中,基本上不会遇到,所以基本对这类边界情况可能不会有接触,比如像这道题,不过不知道也有不知道的解法,面试官出题的目的是为了考察面试者的知识,以掌握面试者的能力.

像这种情况可以直接把自己求解的过程描述给面试官,这样能通过这个过程把自己掌握的相关知识呈现给面试官,这也是面试官所想要的.还可以请教面试官正确的解法或者如果找到相关资料,从中展现自己的求知欲.也可以描述自己平常是如何去编写异步代码的,如果是顺序相关的异步会明确先后顺序的使用 then 串联,或者使用 await 关键词,保证顺序是确定的,而如果是顺序不相关的异步,遇到这种情况也没太大关系.这可以展现自己良好的编程能力.

另外一个怪异现象

在调试的过程中发现另外一个令人费解的情况,如果在Promise.resolve()之前加一个await,居然能让after:foo提前,排在tick:3后面,这又是一个令人费解的现象.

其实这是因为规范之前针对await做过一次优化,如果await后面跟着的值是一个 Promise 的话,这个优化会少创建两次微任务,更多详情可以查看下面的文章:

Node.js v10中还没有这个优化,所以我们可以实际验证一下:

Comparison of different Node.js

ES 规范阅读

  • 基础(这些基础属于非必须条件)

    • 文法,语法,词法之类的基础知识
    • BNF 产生式
    • 有一定的 JavaScript 基础

前两个基础,如果有了解的话是最好的,没有也影响不大,至于第三个基础,如果没有的话,难度会有点大 😂

推荐资料

在下面的资源中,把推荐阅读列表读完,基本上就能自行去阅读规范了.不过刚开始可能会有一些难度,比如遇到某个语句不知道什么意思,或者为什么这里会出现这种语句之类的疑问,这时候可以通过搜索引擎去搜索相关关键字找到答案.另外这些文章是可以反复阅读,也许每次阅读都会有不一样的收获.

官方的规范有两个地方可以看到,https://tc39.eshttps://www.ecma-internationa... 都可以,不过官方的规范都是放在一个页面上的,每次打开都需要加载所有内容,速度会非常慢.

这里推荐一个项目read262.用read262的话,可以分章节阅读,要查看某个章节,只需要加载那个章节的内容,当需要打开规范多个部分进行对照时会很方便.不过read262会根据 https://tc39.es/ecma262 的更新自动重键,所以只有最新的规范内容,如果需要看其他版本的规范,还是需要到ECMA-262去看对应的版本.read262可以直接使用在线版 https://read262.jedfox.com

JS 引擎

推荐一个库engine262,之前我说看引擎的源码会更接近实现,只是碍于阅读难度来说,阅读规范会更容易一些.其实有一个库是用 JavaScript 实现的引擎,这样源码的阅读难度显然小了很多.不过我推荐还是先去看规范,然后在实际去engine262源码中查看对应的实现,最后还可以将代码下载到本地运行,实际去调试源码的运行的过程,以印证对规范的理解.

engine262会根据最新的规范去实现,而我们看的有时候不一定是最新的规范,engine262也没有依据规范的版本去做标记.这里有一个小技巧,可以先找到实现规范对应的源码,然后看那个文件的提交记录,找到跟规范修改的时间相吻合的提交,然后去看那个提交中的实现就跟规范中的描述一致了.

写在最后

这篇文章通过一个例子,展示如何通过阅读规范去找到隐藏在 JavaScript 代码背后的秘密.当然如果仅仅只是得出一个结论,其实并没有多大意义,像例子中的情况,属于非常边界的情况,现实中能遇到的概率应该不大.

我希望能通过这篇文章让大家更多的了解规范,并且通过上面列出的资料去学习和掌握如何阅读规范的技巧,这样当我们遇到某些问题时可以去找到最权威的资料解答疑惑.不过规范中的大多数知识可能对于我们日常开发都太大帮助,我们只需要掌握阅读的技巧,在有需要的时候去翻翻它即可.

阅读 546
3.2k 声望
66 粉丝
0 条评论
你知道吗?

3.2k 声望
66 粉丝
文章目录
宣传栏