正文
一、背景
缘起自一篇文章:8 张图帮你一步步看清 async/await 和 promise 的执行顺序,文中所抛出的话题,本质上就是考察是否完全掌握了 JavaScript 的事件循环机制(Event Loop)罢了。
插个话,不同宿主环境(比如浏览器、Node),JS 的事件循环会稍有不同,本文则是基于浏览器环境下。至于其中差异并非本文讨论的内容,因此不展开讲述。
同样一段代码在不同浏览器、或同一浏览器的不同版本,执行顺序存在差异。
本人亲测结果,在 Chrome 92 和 Safari 14.1.2 执行顺序仍有差异(2021.08)。
这种差异会带来什么影响呢?
实际应用场景几乎没有影响。所以不用担心,如果有人在项目中写出这样的代码,你可以去打死他了。请不要过分依赖异步操作的顺序。
一般来说,若再遇到 JavaScript 运行方面的差异,应以最新 Chrome 浏览器的行为为准。(跟 Chrome 浏览器的 V8 引擎更新策略有关)
二、找原因
本着寻根问底的初心,去找答案。其实去阅读 ECMAScript 标准是最直接、最权威的(例如,关于 Await 的标准在这里)。但由于功力不够,没办法完全看懂。
于是搜了好久,终于找到了一个相关的问题:async/await 在 Chrome 环境和 Node 环境的执行结果不一致,求解?以及贺老的回答。
该问题中的示例(略微修改)如下:
async function foo() { console.log('a') await bar() console.log('b') } async function bar() { console.log('c') } foo() new Promise(resolve => { console.log('d') resolve() }).then(() => { console.log('e') })
相信很多同学一下就写出了“正确”的打印顺序:a、c、d、b、e
。
我们执行代码并打印出来看下:
Chrome 92
Safari 14.1.2
对比发现,不同浏览器下运行结果竟然不一样,Why?
- 最新版 Chrome 浏览器打印结果为:
a、c、d、b、e
。 - 最新版 Safari 浏览器打印结果为:
a、c、d、e、b
。 - 在 Node 14.16.0 环境下,运行结果同 Chrome 浏览器。
造成以上差异的根本原因是,ECMAScript 就 Await 标准有所调整,最新规定是 await
将直接使用 Promise.resolve()
相同的语义。正是因为此次调整,导致了不同 JS 引擎或者同一 JS 引擎的不同版本,在解析同一脚本会出现结果的差异。
上面示例中 await bar()
的计算结果(指 bar()
返回值)就是一个 Promise
对象。根据 Promise.resolve()
的语法,若参数是一个 Promise
实例对象,将会不做任何修改、原封不动地返回该实例。
const p1 = new Promise(resolve => resolve(1)) const p2 = Promise.resolve(p1) console.log(p1 === p2) // true // ⚠️ 注意,关于 Promise.resolve() 在 Chrome 与 Safari 表现是一致的。
其实无需过分担心这种差异,对平时写项目有什么影响,如果在真正项目写出类似的逻辑,确实该反思一下。但是......面试官可能会问哦,前面文章提到的那道题好像就是头条的面试题。
三、原因剖析
这种差异,是 JavaScript 引擎在实现时没有严格遵循 ECMAScript 标准导致的。
往下之前,明确两点:
Promise
对象的构造方法内属于同步任务,而Promise.prototype.then()
才属于异步任务(微任务,它的执行顺序后于同步任务)Promise.resolve()
方法,若参数为Promise
对象,将会直接返回该对象,而不是返回一个全新的Promise
对象。- 只有当
Promise
对象的状态发生变化,才会被放入微任务队列。
上面的示例中 a
、c
、d
的顺序都没有争议,因此我们简化一下示例:
// 其中 p1、p2 都是状态为 fulfilled 的 Promise 对象 async function foo() { await p1 console.log('b') } foo() p2.then(() => { console.log('e') })
关键点在于 await p1
的语义是什么?一般而言,我们可以把:
async function foo() { await p1 console.log('b') }
理解为:
function foo() { return RESOLVE(p1).then(() => { console.log('b') }) }
按目前的标准定义 RESOLVE(p1)
等同于 Promise.resolve(p1)
,因此 RESOLVE(p1)
结果就是 p1
。根据代码逻辑可知 p1
比 p2
更早地放入微任务队列。本着先进先出的原则,会先执行微任务 p1
,后执行微任务 p2
,因此先后打印出 b
、e
。
但是旧版的 JS 引擎在实现 RESOLVE(p1)
的问题上,与当前标准有微妙而重要的区别。区别在于,即使 p1
是一个 Promise
对象,RESOLVE(p1)
仍会返回一个全新的 Promise
对象(假设为 p3
)。
换句话说,就是执行 p1.then()
时,又产生了一个微任务 p3
,并放入微任务队列。还是本着先进先出的原则,接着执行微任务 p2
并打印 e
。等 p2
执行完毕,接着执行微任务 p3
,然后打印出 b
。因此先后顺序是 e
、b
。
function foo() { return RESOLVE(p1).then(() => { console.log('b') }) } // 相当于 function foo() { return new Promise(resolve => resolve(p1)) // 相当于微任务 p1 .then(() => { // 相当于微任务 p3 console.log('b') }) }
虽然我认为自己懂 Async 内部执行器的执行过程,但是我自认为对本案例解释得不够好。就是那种“懂但不知道怎么表达出来”的感觉。如果看懵了的话,建议直接看贺老的回答。
四、结论
综上,不同浏览器下执行顺序不一样,应该就是 JS 引擎(其中 Chrome、Node 是 V8 引擎,而 Safari 是 JavaScriptCore 引擎。)底层实现 await
语法的方式略有不同。若严格遵循 ECMAScript 标准的话, 执行结果与最新的 Chrome 浏览器应该是一致的。
前面提到若有差异,一般以最新版本的 Chrome 为准,原因是:Chrome 浏览器每次升级都会同时更新到 V8 的最新版。而 Node 更新小版本时,V8 也只更新小版本,只有 Node 更新大版本时才会更新 V8 大版本。所以,绝大部分时候 Node 的 V8 会比同时期的 Chrome 的 V8 要落后。