10

前言

一直想对异步处理做一个研究,在查阅资料时发现了这篇文章,非常深入的解释了事件循环中重的任务队列。原文中有代码执行工具,强烈建议自己执行一下查看结果,深入体会task执行顺序。
建议看这篇译文之前先看这篇全面讲解事件循环的文章:https://mp.weixin.qq.com/s/vI...
翻译参考了这篇文章的部分内容:https://juejin.im/entry/55dbd...

正文

原文地址:Tasks, microtasks, queues and schedules

当我告诉我的同事 Matt Gaunt 我想写一篇关于microtask、queueing和浏览器的Event Loop的文章。他说:“我实话跟你说吧,我是不会看的。” 好吧,无论如何我已经写完了,那么我们坐下来一起看看,好吧?

如果你更喜欢视频,Philip Roberts 在 JSConf 上就事件循环有一个很棒的演讲——没有讲 microtasks,不过很好的介绍了其它概念。好,继续!

思考下面 JavaScript 代码:

    console.log('script start');
    
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
    
    Promise.resolve().then(function() {
      console.log('promise1');
    }).then(function() {
      console.log('promise2');
    });
    
    console.log('script end');

控制台上的输出顺序是怎样的呢?

Try it

正确的答案是:

    script start

    script end
    
    promise1
    
    promise2
    
    setTimeout

但是由于浏览器实现支持不同导致结果也不一致。

Microsoft Edge, Firefox 40, iOS Safari 及桌面 Safari 8.0.8 在 promise1 和 promise2 之前打印 setTimeout -- 这似乎是浏览器厂商相互竞争导致的实现不同。但是很奇怪的是,Firefox 39 和 Safari 8.0.7 竟然结果都是对的(一致的)。

Why this happens

要想弄明白这些,你需要知道Event Loop是如何处理 tasks 和 microtasks的。如果你是第一次接触它,需要花些功夫才能弄明白。深呼吸。。。

每个线程都有自己的事件循环,所以每个 web worker 都有自己的事件循环,因此web worker才可以独立执行。而来自同域的所有窗口共享一个事件循环,所以它们可以同步地通信。事件循环持续运行,直到清空 tasks 列队的任务。事件循环包括多种任务源,事件循环执行时会访问这些任务源,这样就确定了各个任务源的执行顺序(IndexedDB 等规范定义了自己的任务源和执行顺序),但浏览器可以在每次循环中选择从哪个任务源去执行一个任务。这允许浏览器优先考虑性能敏感的任务,例如用户输入。Ok ok, 留下来陪我坐会儿……

Tasks 被放到任务源中,浏览器内部执行转移到JavaScript/DOM领域,并且确保这些 tasks按序执行。在tasks执行期间,浏览器可能更新渲染。来自鼠标点击的事件回调需要安排一个task,解析HTML和setTimeout同样需要。

setTimeout延迟给定的时间,然后为它的回调安排一个新的task。这就是为什么 setTimeout在 script end 之后打印:script end 在第一个task 内,setTimeout 在另一个 task 内。好了,我们快讲完了,剩下一点我需要你们坚持下……

Microtasks队列通常用于存放一些任务,这些任务应该在正在执行的脚本之后立即执行,比如对一批动作做出反应,或者操作异步执行避免创建整个新任务造成的性能浪费。 只要没有其他JavaScript代码在执行中,并且在每个task队列的任务结束时,microtask队列就会被处理。在处理 microtasks 队列期间,新添加到 microtasks 队列的任务也会被执行。 microtasks 包括 MutationObserver callbacks。例如上面的例子中的 promise的callback。

一个settled状态的promise(直接调用resolve或者reject)或者已经变成settled状态(异步请求被settled)的promise,会立刻将它的callback(then)放到microtask队列里面。这就能保证promise的回调是异步的,即便promise已经变为settled状态。因此一个已settled的promise调用.then(yey,nay)时将立即把一个microtask任务加入microtasks任务队列。这就是为什么 promise1 和 promise2 在 script end 之后打印,因为正在运行的代码必须在处理 microtasks 之前完成。promise1 和 promise2 在 setTimeout 之前打印,因为 microtasks 总是在下一个 task 之前执行。

好,一步一步的运行:

    console.log('script start');
    
    setTimeout(function() {
      console.log('setTimeout');
    }, 0);
    
    Promise.resolve().then(function() {
      console.log('promise1');
    }).then(function() {
      console.log('promise2');
    });

图片描述

图片描述

图片描述

图片描述

图片描述

图片描述

图片描述

没错,就是上面这个,我做了一个 step-by-step 动画图解。你周六是怎么过的?和朋友们一起出去玩?我没有出去。嗯,如果搞不明白我的令人惊叹的UI设计界面,点击上面的箭头试试。

浏览器实现差异

一些浏览器的打印结果:

    script start
    script end
    setTimeout
    promise1
    promise2

在 setTimeout 之后运行 promise 的回调,就好像将 promise 的回调当作一个新的 task 而不是 microtask。

这多少情有可原,因为 promise 来自 ECMAScript 规范而不是 HTML 规范。ECAMScript 有一个概念 job,和 microtask 相似,但是两者的关系在邮件列表讨论中没有明确。不过,一般共识是 promise 应该是 microtask 队列的一部分,并且有充足的理由。

将 promise当作task(macrotask)会带来一些性能问题,因为回调没有必要因为task相关的事(比如渲染)而延迟执行。与其它 task 来源交互时它也产生不确定性,也会打断与其它 API 的交互,不过后面再细说。

我提交了一条 Edge 反馈,它错误地将 promises 当作 task。WebKit nightly 做对了,所以我认为 Safari 最终会修复,而 Firefox 43 似乎已经修复。

有趣的是 Safari 和 Firefox 发生了退化,而之前的版本是对的。我在想这是否只是巧合。

How to tell if something uses tasks or microtasks

动手试一试是一种办法,查看相对于promise和setTimeout如何打印,尽管这取决于实现是否正确。

一种方法是查看规范:
将一个 task 加入队列: step 14 of setTimeout
将 microtask 加入队列:step 5 of queuing a mutation record

如上所述,ECMAScript 将 microtask 称为 job:
调用 EnqueueJob 将一个 microtask 加入队列:step 8.a of PerformPromiseThen

现在,让我们看一个更复杂的例子。一个有心的学徒 :“但是他们还没有准备好”。别管他,你已经准备好了,让我们开始……

Level 1 bossfight

在发出这篇文章之前,我犯过一个错误。下面是一段html代码:

    <div class="outer">
      <div class="inner"></div>
    </div>
    

给出下面的JS代码,如果click div.inner将会打印出什么呢?

    // Let's get hold of those elements
    var outer = document.querySelector('.outer');
    var inner = document.querySelector('.inner');
    
    // Let's listen for attribute changes on the
    // outer element
    new MutationObserver(function() {
      console.log('mutate');
    }).observe(outer, {
      attributes: true
    });
    
    // Here's a click listener…
    function onClick() {
      console.log('click');
    
      setTimeout(function() {
        console.log('timeout');
      }, 0);
    
      Promise.resolve().then(function() {
        console.log('promise');
      });
    
      outer.setAttribute('data-random', Math.random());
    }
    
    // …which we'll attach to both elements
    inner.addEventListener('click', onClick);
    outer.addEventListener('click', onClick);

继续,在查看答案之前先试一试。 线索:logs可能会发生多次。

Test it

点击inner区域触发click事件:

图片描述

click div.inner :

    click
    promise
    mutate
    click
    promise
    mutate
    timeout
    timeout

click div.outer :


    click
    promise
    mutate
    timeout

和你猜想的有不同吗?如果是,你得到的结果可能也是正确的。不幸的是,浏览器实现并不统一,下面是各个浏览器下测试结果:

图片描述

Who's right?

触发 click 事件是一个 task,Mutation observer 和 promise 的回调 加入microtask列队,setTimeout 回调加入task列队。因此运行过程如下:

点击内部区域触发内部区域点击事件 -> 冒泡到外部区域 -> 触发外部区域点击事件
这里要注意一点: setTimeout 执行时机在冒泡之后,因为也是在microtask之后,准确的说是在最后的时机执行了。

图片描述

堆栈为空之后将会执行microtasks里面的任务。

图片描述

由于冒泡, click函数再一次执行。

图片描述

最后将执行setTimeout。

图片描述

所以 Chrome 是对的。对我来说新发现是,microtasks 在回调之后运行(只要没有其它的 Javascript 在运行),我原以为它只能在一个task 的末尾执行。这个规则来自 HTML 规范,调用一个回调:

If the stack of script settings objects is now empty, perform a microtask checkpoint

HTML: Cleaning up after a callback step 3

一个 microtask checkpoint 逐个检查 microtask队列,除非我们已经在处理一个 microtask 队列。类似地,ECMAScript 规范这么说 jobs:

Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…

ECMAScript: Jobs and Job Queues
尽管在 HTML 中"can be"变成了"must be"。

What did browsers get wrong?

对于 mutation callbacks,Firefox 和 Safari 都正确地在内部区域和外部区域单击事件之间执行完毕,清空了microtask 队列,但是 promises 列队的处理看起来和chrome不一样。这多少情有可原,因为 jobs 和 microtasks 的关系不清楚,但是我仍然期望在事件回调之间处理。Firefox ticket. Safari ticket.

对于 Edge,我们已经看到它错误的将 promises 当作 task,它也没有在单击回调之间清空 microtask 队列,而是在所有单击回调执行完之后清空,于是总共只有一个 mutate 在两个 click 之后打印。 Bug ticket.

Level 1 boss's angry older brother

仍然使用上面的例子,假如我们运行下面代码会怎么样:


    inner.click();

跟之前一样,它会触发 click 事件,不过是通过代码而不是实际的交互动作。

Try it

下面是各个浏览器的运行情况:

图片描述

我发誓我一直在Chrome 中得到不同的结果,我已经更新了这个表许许多次了。我觉得我是错误地测试了Canary。假如你在 Chrome 中得到了不同的结果,请在评论中告诉我是哪个版本。

Why is it different?

这里介绍了它是怎样发生的:

将Run srcipt加入Tasks队列,将inner.click加入执行堆栈:

图片描述

执行click函数:

图片描述

按顺序执行,分别将setTimeout加入Tasks队列,将Promise MultationObserver加入microtasks队列:

图片描述

click函数执行完毕之后,我们没有去处理microtasks队列的任务,因为此时堆栈不为空:

图片描述

我们不能将 MultationObserver加入microtasks队列,因为有一个等待处理的 MultationObserver:

图片描述

现在堆栈为空了,我们可以处理microtasks队列的任务了:

图片描述

最终结果:

图片描述

通过对比事件触发,我们要注意两个地方:JS stack是否是空的决定了microtasks队列里任务的执行;microtasks队列里不能同时有多个MultationObserver。

正确的顺序是:click, click, promise, mutate, promise, timeout, timeout,似乎 Chrome 是对的。

在每个listerner callback被调用之后:

If the stack of script settings objects is now empty,perform a microtask checkpoint.

— HTML: 回调之后的清理第三步

之前,这意味着 microtasks 在事件回调之间运行,但是现在.click()让事件同步触发,因此调用.click()的脚本仍处于回调之间的堆栈中。上面的规则确保了 microtasks 不会中断正在执行的JS代码。这意味着 microtasks 队列不会在事件回调之间处理,而是在它们之后处理。

Does any of this matter?

重要,它会在偏角处咬你(疼)。我就遇到了这个问题,我在尝试为IndexedDB创建一个使用promises而不是奇怪的IDBRequest对象的简单包装库时遇到了此问题。它让 IDB 用起来很有趣

当 IDB 触发成功事件时,相关的 transaction 对象在事件之后转为非激活状态(第四步)。如果我创建的 promise 在这个事件发生时被resolved,回调应当在第四步之前执行,这时这个对象仍然是激活状态。但是在 Chrome 之外的浏览器中不是这样,导致这个库有些无用。

实际上你可以在 Firefox 中解决这个问题,因为 promise polyfills 如 es6-promise 使用 mutation observers 执行回调,它正确地使用了 microtasks。而它在 Safari 下似乎存在竞态条件,不过这可能是因为他们糟糕的 IDB 实现。不幸的是 IE/Edge 不一致,因为 mutation 事件不在回调之后处理。

希望不久我们能看到一些互通性。

You made it!

总结:

  • tasks 按序执行,浏览器会在 tasks 之间执行渲染。
  • microtasks 按序执行,在下面情况时执行:

    • 在每个回调之后,只要没有其它代码正在运行。
    • 在每个 task 的末尾。

希望你现在明白了事件循环,或者至少得到一个借口出去走一走,躺一躺。

呃,还有人在吗?Hello?Hello?

感谢 Anne van Kesteren, Domenic Denicola, Brian Kardell 和 Matt Gaunt 校对和修正。是的,Matt 最后还是看了此文,我不必把他整成发条橙了。

后记

总结

1.microtask队列就会被处理的时机

(1)只要没有其他JavaScript代码在执行中,
(2)并且在每个task队列的任务结束时, microtask队列就会被处理。也就是说可以在执行一个task之后连续执行多个microtask。

2. promise相关

(1)promise一旦创建就会马上执行
(2)当状态变为settled的时候,callback才会被加入microtask 队列

所以要注意promise创建和callback被执行的时机。

面试题

async function async1(){
   console.log('async1 start');
    await async2();
    console.log('async1 end')
}
async function async2(){
    console.log('async2')
}
console.log('script start');
setTimeout(function(){
    console.log('setTimeout')
},0);
async1();
new Promise(function(resolve){
    console.log('promise1');
    resolve();
}).then(function(){
    console.log('promise2')
});
console.log('script end')

答案:

script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout

答案讲解参考:https://juejin.im/post/6844903617959313415


specialcoder
2.2k 声望170 粉丝

前端 设计 摄影 文学