macroTask 和 microTask 理解

1,274 阅读12分钟

前言

什么是 macroTask ? 什么是 microTask ?

本人在阅读 macroTask 和 microTask 相关文献的之前,其实也只是有印象的了解过,但具体不清楚其含义的;

直到在阅读 React 调度机制 Scheduler 源码的时候,了解到 React 使用了 MessageChannel 进行调度执行任务的触发方法的时候才注意到的;

React 为什么不直接进行 “方法的调用”,而是需要通过 MessageChannel 的 postMessage 的方法来触发调用呢?

由此引发了我对这一块的思考,从而了解到了 macroTask 和 microTask 的机制,对于这个机制的认识;

这是有利于应对我们经常在面试中遇到的,需要回答面试官给出的一段 JS 代码执行后,输出的 log 结果的顺序;

还有就是有利于 React 针对 requestIdleCallback 兼容性实现调度机制的源码阅读的理解;

相信对于前端知识来说,这一块是必须要认识的内容。

案例

首先这里先来看几个 JavaScript 事件循环机制在处理任务的时候,实际执行情况是如何的:

console.log(1);

setTimeout(function(){
  console.log(2);
}, 0);

console.log(3);

输出结果为: 1、3、2

上面这个案例很容易可以得到答案,因为 setTimeout 会进入异步任务队列(尽管设置的延迟时间为 0), JavaScript 主栈队列会按照从上到下的执行 1、3 输出,当主栈队列清空后,就会从 异步任务队列中出栈进入主栈队列执行,因为对于 JavaScript 的内部机制来说,是单线程的。

接下来看第二个案例:

console.log(1);

setTimeout(function(){
  console.log(2);
}, 0);

var res = Promise.resolve(6).then(function(value){
  console.log(3);
  return value
}).then(function(value){
  console.log(4);
  return value
});

res.then(function(value){
  console.log(value);
});

console.log(5);

输出结果为:1、5、3、4、6、2

这里的第二个案例,应该在前端的面试中,会经常遇到的这种类型的题目,其实这个题目是很简单的,当然是在已经能够正确认识异步任务队列中的 macrotask 和 microtask 之后,这类型的题目是能够非常简单的回答出来的,因为如果未了解异步队列的执行机制的话,常常会被 PromisesetTimeoutthen 搞得很混乱。

上面第二个案例中,Promise 异步任务会优先于 setTimeout的延时为 0 的任务先执行。

原因是任务队列分为 macroTask 和 microTask, 而 Promise 中的 then 方法的函数会被推入到 microTask 队列中,而 setTimeout 函数会被推入到 macroTask 任务队列中;

在每一次事件循环中,macroTask 只会提取一个执行,而 microTask 会一直提取,直到 microTask 队列为空为止。

也就是说如果某个 microTask 任务被推入到执行中,那么当主线程任务执行完成后,会循环调用该队列任务中的下一个任务来执行,直到该任务队列到最后一个任务为止。而事件循环每次只会入栈一个 macroTask ,主线程执行完成该任务后又会检查 microTask 队列并完成里面的所有任务后再执行 macroTask的任务。

任务队列

一般来说,前端人员都知道 JavaScript 的一个特点是单线程,即同一个时间只能做一件事,这样设计主要与其作为浏览器脚本语言有关,JavaScript 主要用途是用户交互以及操作dom,这决定其是单线程设计,否则会带来复杂的同步问题。

比如: 如果 JavaScript 是多线程,那就回产生一个线程删除一个节点,而另一个线程要操作该节点,这个时候游览器需要判断那个线程的顺序之类的会变得十分复杂,游览器也不知道到底先执行谁。

JavaScript 单线程意味着任务需要排队,如果前一个任务耗时长,那么就会阻塞后续任务的执行。为此 JavaScript 出现了同步和异步任务,二者都需要在主线程执行栈中执行;其中异步任务需要进入任务队列(Task queue)进行排队,其具体运行机制如下:

  1. 同步任务在主线程上执行,形成一个执行栈
  2. 异步任务,JavaScript 会将任务入栈形式,放入到异步队列中进行排队
  3. 主线程上的任务执行完后,通过 Event loop ,JavaScript 会从异步任务队列中出栈,将任务进栈到主线程队列中开始实行

JavaScript 事件机制如图:

image

macroTask 与 microTask 具体指的是什么?

macroTask 与 microTask 指的就是宏任务微任务,在异步队列中,把异步任务分成了宏任务微任务两种;

macroTask 与 microTask 任务都是异步任务,执行的时候都会被入栈到异步任务队列中,并且等待某个时机被主线程入栈执行;

macroTask

macroTask(宏任务) 在浏览器端,其可以理解为该任务执行完后,在下一个 macroTask 执行开始前,浏览器可以进行页面渲染。

触发 macroTask 任务的操作包括:

  • script(整体代码)

  • setTimeout、setInterval、setImmediate(浏览器完成其他操作(例如事件和显示更新)后立即运行回调函数-链接)

  • I/O、UI交互事件

  • postMessage、MessageChannel

microTask

microTask(微任务)可以理解为在 macroTask 任务执行后,页面渲染前立即执行的任务。

触发 microTask 任务的操作包括:

  • Promise.then

  • MutationObserver(提供了监视对DOM树所做的更改的功能-链接)

  • process.nextTick(Node环境)

这时候我们继续看上面的第二个例子:

console.log(1);

setTimeout(function(){
  console.log(2);
}, 0);

var res = Promise.resolve(6).then(function(value){
  console.log(3);
  return value
}).then(function(value){
  console.log(4);
  return value
});

res.then(function(value){
  console.log(value);
});

console.log(5);

该程序的结果也已经知道:1、5、3、4、6、2

具体是如何执行输出这个结果的呢?

通过对 macroTask 与 microTask 的了解,所知道这两个队列分别为哪些任务:

macroTasks: [setTimeout回调]

microTasks: [setTimeout回调1, setTimeout回调2, setTimeout回调3]

对于宏任务和微任务,为什么结果输出的时候,是给人感觉就是微任务优先级更高呢?这也是由于宏任务和微任务在 JavaScript 的运行机制所导致的了:

  • 执行一个 macroTask(包括整体 Script 代码),若 Js 执行栈空闲则从任务队列中取出异步任务

  • 执行一个 macroTask(包括整体 Script 代码)过程中遇到 microTask,则将其添加到 Micro task queue中;同样遇到 macroTask 则添加到 Macro task queue 中

  • 这时 macroTask 执行完毕后,立即按序执行 Micro task queue 中的所有 Microtask(因为 microTask 是在 macroTask 执行完成之后,页面渲染之前调用的);如果在执行 microTask的过程中,又产生了 microTask,那么会加入到队列的末尾,也会在这个周期被调用执行;

  • 所有 microTask 执行完毕后,浏览器开始渲染,GUI 线程接管渲染

  • 渲染完毕,从 Macro task queue 中取下一个 macroTask 开始执行

Event loop

在主线程执行栈空闲的情况下,从任务队列中读取任务入执行栈执行,这个过程是循环不断进行的,所以又称 Event loop(事件循环)。

Event loop 是一个 js 实现异步的规范,在不同环境下有不同的实现机制,例如浏览器和 NodeJS 实现机制不同:

  1. 浏览器的 Event loop 是按照 html 标准定义来实现,具体的实现留给各浏览器厂商

image

下面来说说浏览器环境下的Event loop:

  1. 按先进先出原则选择最新进入 Event loop 任务队列的一个 macroTask,若没有则直接进入第6步的 microTask

  2. 选择当前 Event loop 的任务准备进入执行栈进行执行

  3. Event loop 当前任务进栈运行

  4. 当执行栈运行完毕所有任务后,循环机制 Event loop 会再次查询异步队列是否有任务

  5. 执行完一次 microTask,Event loop 会优先查询是否含有 microTask 任务,如果有

  6. 出栈 microTask 再次进执行栈

  7. 更新并进行UI渲染

  8. 返回第一步执行

  1. NodeJS 中的 Event loop 是基于 libuv 实现

image

根据上图,Node.js的运行机制如下。

(1)V8 引擎解析 JavaScript 脚本。

(2)解析后的代码,调用 Node API。

(3)libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。

(4)V8 引擎再将结果返回给用户。

除了 setTimeoutsetInterval 这两个方法,Node.js 还提供了另外两个与"任务队列"有关的方法:process.nextTicksetImmediate。它们可以帮助我们加深对"任务队列"的理解。

process.nextTick 方法可以在当前"执行栈"的尾部----下一次 Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate 方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行,这与 setTimeout(fn, 0) 很像。

microTask 的应用

Vue 的应用

microTask 异步任务的应用,在 Vue 中就有使用,其中在官网就有明确的说明和解释(链接),以下是 Vue 官方解释和案例:

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。然后,在下一个的事件循环“tick”的时候,Vue 刷新队列并执行实际工作,通过 microTask 安排这些任务,执行这些缓存(这时候就会更新 Dom 树)。Vue 在内部对异步队列会尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

例如,当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。例如:

Vue.component('example', {
  template: '<span>{{ message }}</span>',
  data: function () {
    return {
      message: '未更新'
    }
  },
  methods: {
    updateMessage: function () {
      this.message = '已更新'
      console.log(this.$el.textContent) // => '未更新'
      this.$nextTick(function () {
        console.log(this.$el.textContent) // => '已更新'
      })
    }
  }
})

这个官方例子中,this.$nextTick 就会触发一个 microTask 任务,在进行数据赋值发生变化之后,离开调用 this.$nextTick,这时候就会把回调任务一同放到Vue 刷新队列并执行实际工作 同次时间循环中的 microTask 队列后面,这用 Vue 实际更新完 Dom 数据并在游览器渲染之前,就会执行这个回调,进而就可以拿到 this.$el.textContent 已更新的值

因为 $nextTick() 返回一个 Promise 对象,所以你可以使用新的 ES2017 async/await 语法完成相同的事情:

methods: {
  updateMessage: async function () {
    this.message = '已更新'
    console.log(this.$el.textContent) // => '未更新'
    await this.$nextTick()
    console.log(this.$el.textContent) // => '已更新'
  }
}

core-js 的模块应用

参考源码

module.exports = function () {
  var head, last, notify;

  var flush = function () {
    var parent, fn;
    if (isNode && (parent = process.domain)) parent.exit();
    while (head) {
      fn = head.fn;
      head = head.next;
      try {
        fn();
      } catch (e) {
        if (head) notify();
        else last = undefined;
        throw e;
      }
    } last = undefined;
    if (parent) parent.enter();
  };

  // Node.js
  if (isNode) {
    notify = function () {
      // Node 方式
      process.nextTick(flush);
    };
  // browsers with MutationObserver
  } else if (Observer) {
    // MutationObserver 方式
    var toggle = true;
    var node = document.createTextNode('');
    new Observer(flush).observe(node, { characterData: true }); // eslint-disable-line no-new
    notify = function () {
      node.data = toggle = !toggle;
    };
  // environments with maybe non-completely correct, but existent Promise
  } else if (Promise && Promise.resolve) {
    // Promise.then 方式
    var promise = Promise.resolve();
    notify = function () {
      promise.then(flush);
    };
  // for other environments - macrotask based on:
  // - setImmediate
  // - MessageChannel
  // - window.postMessag
  // - onreadystatechange
  // - setTimeout
  } else {
    notify = function () {
      // strange IE + webpack dev server bug - use .call(global)
      macrotask.call(global, flush);
    };
  }

  return function (fn) {
    var task = { fn: fn, next: undefined };
    if (last) last.next = task;
    if (!head) {
      head = task;
      notify();
    } last = task;
  };
};

最后实战一下

console.log(1);

setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
}, 0);

new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
})

setTimeout(() => {
  console.log(6);
}, 0)

console.log(7);

这是一个面试题中常见的题目,通过阅读上面的内容,这样的题,相信各位应该很容易可以解答出来了,哈哈~

答案:1、4、7、5、2、3、6

解析:

  1. 首先,1、4、7 都是一开始在执行栈直接执行的任务
  2. 之后会先去执行 microTask,因为 1、4、7 执行的时候算一个 macroTask - script(整体代码),所以输出 5
  3. microTask 队列清空后,就会执行下一个 macroTask ,这时候输出了 2,并且 通过 Promise.resolve() 入栈一个 microTask
  4. 执行完 2 这个 macroTask 后,会在执行 3 这个 microTask,所以这时候输出了 3
  5. 最后清空完 microTask 队列,执行下一个 macroTask 6,这时就会输出 6 了

参考文献