浏览器知识点整理(十三)不同的回调执行时机:宏任务和微任务

2,318 阅读10分钟

回调函数(Callback Function

什么是回调函数呢?

将一个函数作为参数传递给另外一个函数,那么作为参数的这个函数就是回调函数。而回调函数有同步回调和异步回调两种方式。

同步回调

回调函数在主函数返回之前执行的回调过程称为同步回调

同步回调例子:

function callback() {
  console.log('callback');
}
function test(callback) {
  console.log('start');
  callback();
  console.log('end');
}
test(callback);

// start
// callback
// end

异步回调

回调函数在主函数外部执行的过程称为异步回调

异步回调例子:

function callback() {
  console.log('callback');
}
function test(callback) {
  console.log('start');
  setTimeout(callback, 1000);
  console.log('end');
}
test(callback);

// start
// end
// callback

站在页面循环的角度看回调

上一篇文章 中,我们知道,浏览器页面是通过 事件循环机制 来驱动的,每个渲染进程都有一个 消息队列,页面主线程按照顺序来执行消息队列中的事件,如执行 JavaScript 事件、解析 DOM 事件、计算布局事件、用户输入事件等等,如果页面有新的事件产生,那新的事件将会追加到消息队列的尾部。所以可以说 浏览器通过消息队列和主线程循环机制保证了页面有条不紊地运行

当循环系统在执行一个任务的时候,都会为这个任务维护一个调用栈。比如执行一个 <script> 标签包裹的代码就是一个任务,系统会为这个任务维护一个调用栈,当执行到具体函数的时候会把其执行上下文压入栈中,执行完就会弹出该执行上下文。

还记得 Event Loop 的过程吗?

image.png

它是先执行当前调用栈中的同步代码(一个宏任务),调用栈为空后去检查是否有异步任务(微任务)需要执行,如果有则执行完当前 异步代码(当前宏任务中 微任务队列 里的所有微任务),再之后就是从 消息队列 中取出下一个宏任务去执行(重新维护一个调用栈),这样周而复始就是 Event Loop 的过程了。

每一个任务在执行过程中都有自己的调用栈,那么在这个角度下同步回调和异步回调的区别如下:

  • 同步回调即是在当前主函数的上下文中执行回调函数,就是按照代码的顺序去执行。
  • 异步回调过程是指在主函数之外执行的回调函数,那么就有两种方式:
    • 第一种就是把异步函数做成一个任务(宏任务),添加到 消息队列 (延迟执行队列或普通的消息队列)的尾部,之后从消息队列取出维护一个新的调用栈去执行。比如 setTimeoutXMLHttpRequest 的回调函数。
    • 第二种就是把 异步函数添加到微任务队列 中,这样就是在当前任务(当前调用栈)的末尾处(在主函数执行结束之后、当前宏任务结束之前)执行了。比如 promiseMutationObserver 的回调函数。

这个其实也就是 宏任务和微任务的区别 了,不同的执行时机造成的一个差异。

宏任务

为了协调任务有条不紊地在主线程上执行,页面进程引入了 消息队列事件循环机制,渲染进程内部也会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。这些消息队列中的任务就称为 宏任务

一些常见的宏任务有:

  • 渲染事件(如解析 DOM、计算布局、绘制等);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JS 脚本执行事件;
  • 网络请求完成、文件读写完成事件。

消息队列中的任务是通过事件循环系统来执行的,通过 WHATWG 规范中定义事件循环机制,大致流程如下:

  • 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask
  • 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
  • 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask
  • 最后统计执行完成的时长等信息。

这也就是宏任务的执行流程。

宏任务可以满足大部分的日常需求,但是宏任务的时间粒度比较大,是不能精确控制执行的时间间隔的。

页面的渲染事件、各种 IO 的完成事件、执行 JS 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

也就是说高优先级的任务没有办法通过消息队列去控制执行任务的时机,于是在这种情况下,微任务诞生了。

微任务

微任务是什么?

微任务可以在实时性和效率之间做一个有效的权衡。浏览器基于微任务的技术有 MutationObserverPromise 以及以 Promise 为基础开发出来的很多其他的技术。

那么微任务是什么呢?它 是一个需要异步执行的回调函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

要搞清楚微任务系统是怎么运转的,得站在 V8 引擎的层面来分析:当 JS 执行一段脚本(一个宏任务)的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个 微任务队列。也就是说 每个宏任务都关联了一个微任务队列

下面通过分析两个重要的时间点(微任务产生的时机和执行微任务队列的时机)来理解微任务。

微任务产生的时机

在现代浏览器里面,产生微任务有两种方式:

  • 第一种方式是 使用 MutationObserver 监听某个 DOM 节点,然后再通过 JS 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • 第二种方式是 使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

通过使用 MutationObserver 监听 DOM 节点变化产生的微任务或者使用 Promise 产生的微任务都会被 JS 引擎按照顺序保存到 微任务队列 中。

执行微任务队列的时机

通常情况下,在 当前宏任务中的 JS 代码快执行完成时,即 在 V8 引擎准备退出全局执行上下文并清空调用栈时,V8 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 规范 把执行微任务的时间点称为检查点

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务继续添加到当前微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

微任务结论

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
  • 微任务的执行时长会影响到当前宏任务的时长
    • JS 是单线程执行代码的,在一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。
    • 所以在写代码的时候一定要注意控制微任务的执行时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行

宏任务和微任务不同的执行时机

下面通过几个经典的回调函数来了解 宏任务和微任务不同的执行时机

setTimeout

setTimeout异步执行函数,因为不确定执行时机,所以会是一个宏任务,当主线程运作到此函数时,setTimeout 里面的回调函数会进入 消息队列(宏任务队列) 中等待,然后运行 setTimeout 后面的语句,当执行完当前事件(执行栈清空)后, setTimeout 中的回调才会在下次(或某一个)事件循环中进入执行栈被执行。

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

输出结果为:setTimeout start => setTimeout end => setTimeout execute

Promise

Promise 本身是同 步的立即执行函数,当在执行体中执行 resolve() 或者 reject() 时,会把resolve()或者 reject() 丢到 当前宏任务中的微任务队列 中,通过 .then 进行延时绑定回调,当执行完当前事件(执行栈清空)后才会去执行 resolve() 或者 reject() 的方法。

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

输出结果为:script start => promise1 => promise1 end => script end => promise2 => setimeout

async/await

async 函数返回一个 promise 对象,当函数执行的时候,一旦遇到 await 就会先返回去执行 async 外的代码,等到调用栈清空之后,触发的异步操作(await的函数)完成,再执行 await 后面的语句,可以把 await 看成是让出线程的标志;await 函数后的语句相当于在 then 回调中执行。

async function async1() {
  console.log('async1 start')
  await async2();
  console.log('async1 end')
}
async function async2() {
  console.log('async2')
}
console.log('script start')
async1();
console.log('script end')

输出结果为:script start => async1 start => async2 => script end => async1 end

宏任务和微任务结合来看看

来看这段代码:

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

输出结果是:script start => async1 start => async2 => promise1 start => promise1 end => script end => async1 end => promise2 => promise3 => setTimeout

以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,所以会有以上的打印。

总结

本文开篇介绍了两种回调函数:同步回调和异步回调,异步回调主要有两个执行时机:一个是把回调函数做成一个宏任务添加到消息队列中去等待执行另一个是把回调函数添加到当前宏任务中的微任务队列里面去等待执行。最后通过 setTimeoutPromiseasync/await 的一些代码来加深对宏任务和任务不同执行时机的理解。