通过microtasks和macrotasks看JavaScript异步任务执行顺序 | 拓跋的前端客栈

setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在”任务队列”的尾部添加一个事件,因此要等到同步任务和”任务队列”现有的事件都处理完,才会得到执行。

上面的这段文字摘自阮一峰的博客,即使你的延迟设定的是0,setTimeout仍然会将你的函数给放到队列最尾端,等你的当前任务执行完毕以后,才会去执行该函数。

2. 深入 — setTimeout()和Promise同场竞技时是什么样呢?


众所周知,Promise是ES6发布的一种非常流行的异步实现方案,当Promise和setTimeout同时出现在一段代码中,他们的执行顺序是什么样子的呢?请看下面这段代码:

setTimeout(function(){
    console.log(1)
},0);
new Promise(function(resolve){
    console.log(2)
    for( var i=100000 ; i>0 ; i-- ){
        i==1 && resolve()
    }
    console.log(3)
}).then(function(){
    console.log(4)
});
console.log(5);

如果按照正常逻辑分析,应该是这样的:

  1. 当运行到setTimeout时,会把setTimeout的回调函数console.log(1)放到任务队列里去,然后继续向下执行。
  2. 接下来会遇到一个Promise。首先执行打印console.log(2),然后执行for循环,即时for循环要累加到10万,也是在执行栈里面,等待for循环执行完毕以后,将Promise的状态从fulfilled切换到resolve,随后把要执行的回调函数,也就是then里面的console.log(4)推到任务队列里面去。接下来马上执行马上console.log(3)。
  3. 然后出Promise,还剩一个同步的console.log(5),直接打印。这样第一轮下来,已经依次打印了2,3,5。
  4. 然后再读取任务队列,任务队列里还剩console.log(1)和console.log(4),因为任务队列是队列嘛,肯定遵循的先进先出的策略,因此更早入列的setTimeout()的回调函数先执行,打印1,最后剩下Promise的回调,打印4。

因此一通分析下来,得到的打印结果是2,3,5,1,4。那我们实际试一下呢?

2
3
5
4
1

啊嘞嘞?跟我们一开始想象的貌似有点不一样啊!是什么原因导致了原本应该在setTimeout回调后面的Promise的回调反而跑到前面去执行了呢?

为了搞清这个问题,我专门去翻阅了一下资料,首先找到了Promises/A+标准里面提到:

Here “platform code” means engine, environment, and promise implementation code. In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick. Since the promise implementation is considered platform code, it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.

这里提到了micro-task和macro-task这两个概念,并分别列举了两种情况:setTimeout和setImmediate属于macro-task,MutationObserver和process.nextTick属于micro-task。但并没有进一步的详述,于是我以此为线索进一步搜索资料,找到stackoverflow上的一个问答,终于让我的疑惑得到解决。

macrotasks和microtasks的划分:

macrotasks:

  • setTimeout
  • setInterval
  • setImmediate
  • requestAnimationFrame
  • I/O
  • UI rendering

microtasks:

  • process.nextTick
  • Promises
  • Object.observe
  • MutationObserver

那我们上面提到的任务队列到底是什么呢?跟macrotasks和microtasks有什么联系呢?

  • An event loop has one or more task queues.(task queue is macrotask queue)
  • Each event loop has a microtask queue.
  • task queue = macrotask queue != microtask queue
  • a task may be pushed into macrotask queue,or microtask queue
  • when a task is pushed into a queue(micro/macro),we mean preparing work is finished,so the task can be executed now.

翻译一下就是:

  • 一个事件循环有一个或者多个任务队列;
  • 每个事件循环都有一个microtask队列
  • macrotask队列就是我们常说的任务队列,microtask队列不是任务队列
  • 一个任务可以被放入到macrotask队列,也可以放入microtask队列
  • 当一个任务被放入microtask或者macrotask队列后,准备工作就已经结束,这时候可以开始执行任务了。

可见,setTimeout和Promises不是同一类的任务,处理方式应该会有区别,具体的处理方式有什么不同呢?我从这篇文章里找到了下面这段话:

Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task. The microtask queue is processed after callbacks as long as no other JavaScript is mid-execution, and at the end of each task. Any additional microtasks queued during microtasks are added to the end of the queue and also processed. Microtasks include mutation observer callbacks, and as in the above example, promise callbacks.

通俗的解释一下, microtasks的作用是用来调度应在当前执行的脚本执行结束后立即执行的任务。 例如响应事件、或者异步操作,以避免付出额外的一个task的费用。

microtask会在两种情况下执行:

  1. 任务队列(macrotask = task queue)回调后执行,前提条件是当前没有其他执行中的代码。
  2. 每个task末尾执行。

另外在处理microtask期间,如果有新添加的microtasks,也会被添加到队列的末尾并执行。

也就是说执行顺序是:

开始 -> 取task queue第一个task执行 -> 取microtask全部任务依次执行 -> 取task queue下一个任务执行 -> 再次取出microtask全部任务执行 -> … 这样循环往复

Once a promise settles, or if it has already settled, it queues a microtask for its reactionary callbacks. This ensures promise callbacks are async even if the promise has already settled. So calling .then(yey, nay) against a settled promise immediately queues a microtask. This is why promise1 and promise2 are logged after script end, as the currently running script must finish before microtasks are handled. promise1 and promise2 are logged before setTimeout, as microtasks always happen before the next task.

Promise一旦状态置为完成态,便为其回调(.then内的函数)安排一个microtask。

接下来我们看回我们上面的代码

setTimeout(function(){
    console.log(1)
},0);
new Promise(function(resolve){
    console.log(2)
    for( var i=100000 ; i>0 ; i-- ){
        i==1 && resolve()
    }
    console.log(3)
}).then(function(){
    console.log(4)
});
console.log(5);

按照上面的规则重新分析一遍:

  1. 当运行到setTimeout时,会把setTimeout的回调函数console.log(1)放到任务队列里去,然后继续向下执行。
  2. 接下来会遇到一个Promise。首先执行打印console.log(2),然后执行for循环,即时for循环要累加到10万,也是在执行栈里面,等待for循环执行完毕以后,将Promise的状态从fulfilled切换到resolve, 随后把要执行的回调函数,也就是then里面的console.log(4)推到microtask里面去。接下来马上执行马上console.log(3)。
  3. 然后出Promise,还剩一个同步的console.log(5),直接打印。这样第一轮下来,已经依次打印了2,3,5。
  4. 现在第一轮任务队列已经执行完毕,没有正在执行的代码。符合上面讲的microtask执行条件,因此会将microtask中的任务优先执行,因此执行console.log(4)
  5. 最后还剩macrotask里的setTimeout放入的函数console.log(1)最后执行。

如此分析输出顺序是:

2
3
5
4
1

看吧,这次分析对了呢ヾ(◍°∇°◍)ノ゙

3. 总结和参考资料


microtask和macrotask看起来容易混淆,实际上还是很好区分的。macrotask就是我们常说的任务队列(task queue)。

JavaScript执行顺序可以简要总结如下:

开始 -> 取task queue第一个task执行 -> 取microtask全部任务依次执行 -> 取task queue下一个任务执行 -> 再次取出microtask全部任务执行 -> …

原文链接:tuobaye.com

上一篇:【CSS基础】Flex弹性布局
下一篇:webpack组织模块的原理

相关推荐

官方社区

扫码加入 JavaScript 社区