聊聊JavaScript异步中的macrotask和microtask

前言

首先来看一个JavaScript的代码片段:

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);

如果你能知道正确的答案,那么后续的内容可以略过了;如果不能建议看看下面有关js异步的内容,百利无一害,😁😁。

任务队列

js的一大特点是单线程,即同一个时间只能做一件事,这样设计主要与其作为浏览器脚本语言有关,js主要用途是用户交互以及操作dom,这决定其是单线程设计,否则会带来复杂的同步问题。比如一个线程删除一个节点,而另一个线程要操作该节点,浏览器不知以哪个线程为准。

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

  • 同步任务在主线程上执行,形成一个执行栈

  • js会将主线程执行栈中的异步任务置于任务队列排队

  • 一旦主线程执行栈同步任务执行完毕处于空闲状态时,就会将任务队列中任务入栈开始执行

还是先来看一个js片段:

console.log('script start')
setTimeout(function() {
    console.log('timeout')
}, 0)
console.log('script end')

这段代码在进入主线程执行时,当执行到setTimeout时会将其放置到异步任务队列中,即使设置时间为0也不会马上执行,必须等到主线程执行栈空闲时(执行完console.log('script end')语句后)才会读取异步队列的任务执行。

macrotask与microtask

二者任务都会被放置于任务队列中等待某个时机被主线程入栈执行,其实任务队列分为宏任务队列和微任务队列,其中放置的分别为宏任务和微任务。

  • macrotask(宏任务) 在浏览器端,其可以理解为该任务执行完后,在下一个macrotask执行开始前,浏览器可以进行页面渲染。触发macrotask任务的操作包括:

    • script(整体代码)

    • setTimeoutsetIntervalsetImmediate

    • I/OUI交互事件

    • postMessageMessageChannel

  • microtask(微任务)可以理解为在macrotask任务执行后,页面渲染前立即执行的任务。触发microtask任务的操作包括:

    • Promise.then

    • MutationObserver

    • process.nextTick(Node环境)

下面通过例子来看看二者的不同:

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

上面一段代码输出结果为:

script start > script end > promise1 > promise2 > timeout

具体的可视化操作演示可以参考Tasks, microtasks, queues and schedules

上面代码运行到最后一句console后,生成的任务队列:

macrotasks:【setTimeout回调】

microtasks:【Promise.then回调1, Promise.then回调2】

两种不同的任务队列,为啥microtask的任务会先执行呢,这就要说说macrotask与microtask的运行机制[3]如下:

  • 执行一个macrotask(包括整体script代码),若js执行栈空闲则从任务队列中取

  • 执行过程中遇到microtask,则将其添加到micro task queue中;同样遇到macrotask则添加到macro task queue中

  • macrotask执行完毕后,立即按序执行micro task queue中的所有microtask;如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行

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

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

Event loop

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

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

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

  • NodeJS中的Event loop是基于libuv实现

下面来说说浏览器环境下的Event loop,首先借用一幅图:

根据HTML Standard - event loop processing model对Event loop规范描述来简单说明事件循环模型:

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

  2. 设置Event loop的当前任务为上面一步选择的任务

  3. 进栈运行所选的任务

  4. 运行完毕设置Event loop的当前任务为null

  5. 将第一步选择的任务从任务队列中删除

  6. 执行microtask:perform a microtask checkpoint,具体执行步骤参考这里

  7. 更新并进行UI渲染

  8. 返回第一步执行

microtask的应用

根据Event loop机制,macrotask的一个任务执行完后就进行UI渲染,然后进行另一个macrotask任务执行,macrotask任务的应用就不做过多介绍。下面来说说microtask任务的应用场景,我们以vue的异步更新DOM来做说明,先看官网的说明:

Vue异步执行DOM更新,只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。

也就是说,Vue绑定的数据发生变化时,页面视图不会立即重新更新,需要等到当前任务执行完毕时进行更新。例如下面代码:

<template>
  <div>
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
  </div>
</template>
export default {
    data () {
        return {
            test: 'begin'
        };
    },
    methods () {
        handleClick () {
            this.test = 'end';
            console.log(this.$refs.test.innerText);//打印“begin”
        }
    }
}

上面代码在执行this.test = 'end'后,页面视图绑定数据test发生变化,若按照同步执行代码,视图应该能马上获取到对应dom的内容,但是并没有获取到。这是因为Vue采用异步视图更新的。具体来说就是Vue在侦听到数据变化时,异步更新视图最终是通过nextTick来完成的,而该方法默认采用microtask任务来实现异步任务,具体的可以参考从Vue.js源码看nextTick机制;这样在 microtask 中就完成数据更新,task 结束就可以得到最新的 UI 了。上面代码如下:

handleClick () {
 this.test = 'end';
 this.$nextTick(() => {
  console.log(this.$refs.test.innerText);//打印"end"
 });
}

按照HTML Standard描述,macrotask、microtask和UI 渲染的执行顺序:

一个macrotask任务 --> 所有microtask任务 --> UI 渲染

既然nextTick是按照microtask来实现异步的,那么microtask任务应该是在UI渲染前执行的,为什么表现的是microtask在UI 渲染之后执行的呢?可能有人对上面提出过质疑。猜测原因如下,具体原因可以参考这篇文章

JS更新dom是同步完成的,但是UI渲染是异步的。

microtask跨浏览器实现

从Vue的nextTick方法的实现以及immediate的实现可以看出,怎么实现Event loop中的microtask实现呢?那就是借助js原生支持的Promise、MutationObserver(浏览器)、process.nextTick(nodejs环境)来实现,均不支持时使用setTimeout(fn, 0)来兜底降级实现。下面就来简单说说microtask的实现思路:

  • 浏览器是否原生实现Promise,有则使用Promise类似如下实现,否则走下一步。

    if (typeof Promise !== 'undefined' && isNative(Promise)) {
      const p = Promise.resolve()
      microTimerFunc = () => {
        p.then(handle)
      }
    
  • 浏览器环境是否原生支持MutationObserver,支持可以这么实现,否则走下一步。

    function microFun(handle) {
     var observer = new MutationObserver(handle);
     var element = document.createTextNode('');
     observer.observe(element, {
       characterData: true
     });
     return function () {
       element.data = blabla;
     };
    }
    
  • 浏览器是否支持onreadystatechange事件,支持则创建一个空的script标签,一旦插入到document中,其onreadystatechange事件将会异步地触发,比setTimeout(fn,0)快,否则走下一步

    function microFun(handle) {
      return function () {
        var scriptEl = document.createElement('script');
        scriptEl.onreadystatechange = function () {
          handle();
    
          scriptEl.onreadystatechange = null;
          scriptEl.parentNode.removeChild(scriptEl);
          scriptEl = null;
        };
        document.documentElement.appendChild(scriptEl);
        return handle;
      };
    };
    
  • 使用setTimeout(fn, 0)来兜底实现

下面看一下core-js模块中Promise中对microtask的模拟实现,具体可以参考源码:

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 () {
      process.nextTick(flush);
    };
  // browsers with MutationObserver
  } else if (Observer) {
    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) {
    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;
  };
};

问题答案

对于文章开头的js代码,其最终输出内容为:

1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6

可以从以下几个步骤来简单分析,具体执行步骤如下图所示:

参考文献

posted @ 2019-09-17 09:52  wonyun  阅读(5704)  评论(2编辑  收藏  举报