JavaScript内核与高级编程之:`JavaScript`的`Event Loop`:`microtask` 和 `macrotask` 的精确调度时序。

各位观众老爷,大家好!我是今天的讲师,咱们今天就来聊聊 JavaScript 里那让人既爱又恨的 Event Loop。别怕,咱们不搞那些晦涩难懂的概念,就用最接地气的方式,把这玩意儿给扒个精光!

开场白:Event Loop 是个啥?

想象一下,你是一个餐厅服务员,顾客(浏览器)不断给你提需求(JavaScript 代码),比如“点个菜(运行一个函数)”,“结账(处理一个事件)”。你不可能同时处理所有事情,对吧?所以你需要一个工作流程,一个“循环”来处理这些请求。这个“循环”就是 Event Loop

简单来说,Event Loop 就是 JavaScript 用来处理异步操作的一套机制。它保证了 JavaScript 代码可以非阻塞地运行,让你的网页不会卡死。

Event Loop 的核心组件

要理解 Event Loop,我们需要先认识几个关键的家伙:

  • 调用栈 (Call Stack): 这是 JavaScript 运行代码的地方。想象成一摞盘子,你只能从最上面取盘子(执行函数)。函数被调用时,会被压入栈中;函数执行完毕,就会从栈中弹出。JavaScript 是单线程的,这意味着同一时间只能执行栈顶的函数。

  • 任务队列 (Task Queue): 也叫回调队列 (Callback Queue)。这是存放待执行任务的地方。当一个异步操作完成时(比如 setTimeout 计时结束,或者 AJAX 请求返回数据),它的回调函数就会被放入任务队列。

  • Event Loop 本尊: 它就是一个无限循环,不断地检查调用栈是否为空。如果调用栈为空,它就会从任务队列中取出一个任务,放入调用栈中执行。

macrotaskmicrotask:任务队列的分类

任务队列并不是一个简单的“先进先出”的队列,而是分成了两类:macrotask (宏任务) 和 microtask (微任务)。

  • macrotask: 也叫做 task。包括:

    • setTimeout
    • setInterval
    • setImmediate (Node.js 特有)
    • I/O 操作 (比如文件读取,网络请求)
    • UI 渲染
  • microtask: 也叫做 jobs。包括:

    • Promise.then / .catch / .finally
    • queueMicrotask() (新增的 API)
    • MutationObserver

macrotaskmicrotask 的执行顺序

这是 Event Loop 最核心的部分,也是最容易让人困惑的地方。让我们用一个比喻来解释:

想象你的任务队列是一个自助餐厅。macrotask 是主菜,microtask 是甜点。

Event Loop 的工作流程是这样的:

  1. 执行一个 macrotask:macrotask 队列中取出一个任务,放入调用栈执行。
  2. 清空 microtask 队列: 执行完一个 macrotask 后,会立即检查 microtask 队列。如果队列中有任务,就全部执行,直到队列为空。
  3. 更新渲染: 浏览器可能会更新渲染界面。
  4. 重复以上步骤: 然后再次从 macrotask 队列中取下一个任务,重复上述过程。

用代码说话:实战演练

光说不练假把式,让我们来看几个例子,加深理解:

例子 1:setTimeoutPromise 的较量

console.log('script start');

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

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

console.log('script end');

你觉得这段代码会输出什么?

答案是:

script start
script end
promise1
promise2
setTimeout

为什么?

  1. console.log('script start') 被执行,输出 "script start"。
  2. setTimeout 被调用,它的回调函数被放入 macrotask 队列。
  3. Promise.resolve().then(...) 创建了一个 Promise,它的 then 回调函数被放入 microtask 队列。
  4. console.log('script end') 被执行,输出 "script end"。
  5. 此时,调用栈为空,Event Loop 开始工作。
  6. 首先,它会找到 macrotask 队列中的 setTimeout 回调函数,放入调用栈执行。
  7. 但是在此之前,它会先清空 microtask 队列。
  8. promise1 被输出。
  9. promise1then 执行完毕,将 promise2 放入 microtask 队列。
  10. promise2 被输出。
  11. microtask 队列为空。
  12. 现在,setTimeout 的回调函数被执行,输出 "setTimeout"。

例子 2:多个 Promise 的嵌套

console.log('start');

Promise.resolve().then(() => {
  console.log('promise1');
  return Promise.resolve();
}).then(() => {
  console.log('promise2');
});

Promise.resolve().then(() => {
  console.log('promise3');
});

console.log('end');

输出结果:

start
end
promise1
promise3
promise2

分析:

  1. startend 先被输出,这个没啥疑问。
  2. promise1promise3then 回调都被放入 microtask 队列。
  3. 注意,promise1then 回调返回了一个新的 Promise
  4. microtask 队列清空,promise1promise3 依次执行。
  5. promise1then 回调返回的 Promisethen 回调(也就是输出 promise2 的那个回调)被放入 microtask 队列。
  6. 再次清空 microtask 队列,promise2 被输出。

例子 3:queueMicrotask() 的使用

queueMicrotask() 是一个新增的 API,允许你手动将一个函数放入 microtask 队列。

console.log('start');

queueMicrotask(() => {
  console.log('microtask1');
});

Promise.resolve().then(() => {
  console.log('promise1');
});

console.log('end');

输出结果:

start
end
microtask1
promise1

分析:

queueMicrotaskPromise.then 都会将回调函数放入 microtask 队列,它们的执行顺序取决于它们被放入队列的顺序。

表格总结:macrotask vs microtask

特性 macrotask microtask
类型 宏任务 微任务
例子 setTimeout, setInterval, I/O, UI 渲染 Promise.then, queueMicrotask, MutationObserver
执行时机 每个 macrotask 执行完毕后,清空 microtask 队列 在当前 macrotask 执行完毕后立即执行
优先级
对 UI 的影响 可能导致页面卡顿 通常不会导致页面卡顿

注意事项:microtask 的饥饿问题

如果 microtask 队列中有大量的任务,并且不断地产生新的 microtask,那么 Event Loop 可能会一直执行 microtask,导致 macrotask 无法得到执行,造成页面卡顿,甚至崩溃。这就是所谓的 microtask 的饥饿问题。

如何避免 microtask 饥饿?

  • 避免在 microtask 中执行耗时操作: microtask 应该尽可能快地执行完毕。
  • 限制 microtask 的数量: 不要一次性创建大量的 microtask
  • 考虑使用 macrotask 代替 microtask: 如果任务不是必须在当前 macrotask 结束后立即执行,可以考虑使用 setTimeout 将其放入 macrotask 队列。

async/await 的幕后功臣

async/await 是 JavaScript 中处理异步操作的语法糖。它让异步代码看起来像同步代码一样,提高了代码的可读性。

async/await 的底层实现也是基于 PromiseEvent Loop 的。

async function myFunc() {
  console.log('before await');
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('after await');
}

myFunc();

这段代码的执行流程是这样的:

  1. myFunc() 被调用,输出 "before await"。
  2. await new Promise(...) 会暂停 myFunc() 的执行,并将 Promisethen 回调(也就是输出 "after await" 的那部分代码)放入 microtask 队列。
  3. setTimeout 会将计时结束后的回调放入 macrotask 队列。
  4. setTimeout 的回调执行时,Promise resolve, then 回调被放入 microtask 队列.
  5. Event Loop 再次循环,microtask 队列被清空,输出 "after await"。

总结:Event Loop 的精髓

Event Loop 是 JavaScript 的核心机制之一,理解它对于编写高性能、响应迅速的 Web 应用至关重要。

  • Event Loop 是一个无限循环,用于处理异步操作。
  • macrotaskmicrotask 是任务队列的两种类型,它们的执行顺序有严格的规定。
  • microtask 的优先级高于 macrotask,但过度使用 microtask 可能会导致饥饿问题。
  • async/await 是基于 PromiseEvent Loop 的语法糖,简化了异步代码的编写。

结尾:练习题

为了巩固大家的理解,给大家留一道练习题:

console.log('1');

setTimeout(function() {
    console.log('2');
    new Promise(function(resolve) {
        console.log('3');
        resolve();
    }).then(function() {
        console.log('4')
    })
});

new Promise(function(resolve) {
    console.log('5');
    resolve();
}).then(function() {
    console.log('6')
});

console.log('7');

setTimeout(function() {
    console.log('8');
    new Promise(function(resolve) {
        console.log('9');
        resolve();
    }).then(function() {
        console.log('10')
    })
});

请分析这段代码的输出结果,并在评论区留下你的答案!

今天的讲座就到这里,希望大家有所收获!下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注