JS `setTimeout(…, 0)` 与 `queueMicrotask()` 的任务队列差异

各位观众老爷,大家好!欢迎来到今天的JS任务队列脱口秀。今天咱们聊聊setTimeout(..., 0)queueMicrotask(),这两个家伙,看起来都是“立即执行”,但实际上肚子里弯弯绕绕可多了。准备好,咱们开始上车!

第一幕:setTimeout(…, 0)——延迟退休的老干部

首先,我们来认识一下setTimeout(..., 0)。这家伙,表面上说“0毫秒后执行”,但实际上,它可不是立即执行。它会把你的任务扔到宏任务队列(Macrotask Queue)里,等着浏览器“处理完手头的事儿”再说。

宏任务队列里都有些什么妖魔鬼怪呢?

宏任务类型 说明
setTimeout 设定一个定时器,到期后执行回调函数。
setInterval 循环执行回调函数,直到被clearInterval清除。
setImmediate (Node.js 特有) 立即执行回调函数,但会在事件循环的下一个迭代中执行。
I/O 操作 比如读取文件、发送网络请求等。
UI 渲染 浏览器需要渲染页面的时候,也会把渲染任务放到宏任务队列里。
用户交互 比如用户点击、滚动等事件。
script(初始JS代码) 脚本的执行本身就是一个宏任务。
console.log("任务开始");

setTimeout(function() {
  console.log("setTimeout 0:老干部上班了");
}, 0);

console.log("任务结束");

// 输出顺序:
// 任务开始
// 任务结束
// setTimeout 0:老干部上班了

看,setTimeout(..., 0)里的任务,虽然设置了0延迟,但还是排在了console.log("任务结束")后面执行。这是因为,JavaScript引擎会先执行完当前的同步代码,然后才会去宏任务队列里找活干。

第二幕:queueMicrotask()——特事特办的VIP

接下来,隆重介绍我们的VIP贵宾:queueMicrotask()。这家伙可不简单,它会把你的任务扔到微任务队列(Microtask Queue)里。

微任务队列里的都是些什么大佬呢?

微任务类型 说明
Promise.then 当Promise的状态变为fulfilled或rejected时,会执行对应的thencatch回调函数。
async/await async函数会返回一个Promise,await会暂停async函数的执行,直到Promise resolved/rejected。await之后的代码可以看作是Promise.then的回调函数。
MutationObserver 监听DOM树的变化,并在DOM变化后执行回调函数。
queueMicrotask 将一个函数排入微任务队列,以便在当前任务结束之后、渲染之前立即执行。
process.nextTick (Node.js 特有) 将一个函数排入下一个事件循环迭代的开始阶段执行。 严格来说,它不是标准的微任务,但行为类似。
console.log("任务开始");

queueMicrotask(function() {
  console.log("queueMicrotask:VIP贵宾驾到");
});

console.log("任务结束");

// 输出顺序:
// 任务开始
// 任务结束
// queueMicrotask:VIP贵宾驾到

setTimeout(..., 0)一样,queueMicrotask里的任务也不是立即执行。但是,它的优先级比宏任务高!也就是说,JavaScript引擎会先执行完当前的同步代码,然后立即执行微任务队列里的所有任务,才会去宏任务队列里找活干。

第三幕:宏任务 vs 微任务——一场速度与激情的较量

现在,我们把setTimeout(..., 0)queueMicrotask()放在一起,看看会发生什么:

console.log("任务开始");

setTimeout(function() {
  console.log("setTimeout 0:老干部慢悠悠");
}, 0);

queueMicrotask(function() {
  console.log("queueMicrotask:VIP风风火火");
});

console.log("任务结束");

// 输出顺序:
// 任务开始
// 任务结束
// queueMicrotask:VIP风风火火
// setTimeout 0:老干部慢悠悠

看到了吧?即使setTimeout(..., 0)设置了0延迟,queueMicrotask()里的任务还是会先执行。这就是微任务的优先级高于宏任务的体现。

第四幕:Event Loop——幕后大Boss

这一切的幕后推手,就是Event Loop(事件循环)。Event Loop就像一个调度员,负责协调宏任务队列和微任务队列的工作。它的工作流程大致如下:

  1. 执行栈执行全局Script代码,这是第一个宏任务。
  2. 宏任务执行过程中,可以产生新的宏任务和微任务。
  3. 当宏任务执行完毕,检查微任务队列,依次执行队列中的所有微任务。
  4. 当微任务队列为空时,浏览器可能会更新渲染。
  5. 选取下一个宏任务执行,回到步骤2,循环往复。

用一张图来表示:

+---------------------+
|  Global Script      |  (宏任务)
+---------------------+
      |
      V
+---------------------+
|  执行栈清空         |
+---------------------+
      |
      V
+---------------------+
|  检查微任务队列     |
+---------------------+
      |
      V
+---------------------+
|  执行所有微任务       |
+---------------------+
      |
      V
+---------------------+
|  微任务队列为空?   |
+---------------------+
      |   Yes         No|
      V               |
+---------------------+   ^
|  更新渲染(可能)   |   |
+---------------------+   |
      |               |
      V               |
+---------------------+   |
|  选取下一个宏任务     |---+
+---------------------+

第五幕:Promise——微任务的扛把子

Promise是微任务的典型代表。Promise.then的回调函数会被添加到微任务队列中。

console.log("任务开始");

Promise.resolve().then(function() {
  console.log("Promise.then:微任务大哥");
});

console.log("任务结束");

// 输出顺序:
// 任务开始
// 任务结束
// Promise.then:微任务大哥

再来一个更复杂的例子:

console.log("任务开始");

setTimeout(function() {
  console.log("setTimeout 0:宏任务小弟");
}, 0);

Promise.resolve().then(function() {
  console.log("Promise.then 1:微任务大哥1");
});

Promise.resolve().then(function() {
  console.log("Promise.then 2:微任务大哥2");
});

queueMicrotask(function() {
  console.log("queueMicrotask:微任务小弟");
});

console.log("任务结束");

// 输出顺序:
// 任务开始
// 任务结束
// Promise.then 1:微任务大哥1
// Promise.then 2:微任务大哥2
// queueMicrotask:微任务小弟
// setTimeout 0:宏任务小弟

注意,所有的微任务都会在同一个事件循环迭代中执行完毕,才会去执行下一个宏任务。而且,微任务的执行顺序是按照它们被添加到队列的顺序来的。

第六幕:async/await——Promise的语法糖

async/awaitPromise的语法糖,它可以让异步代码看起来更像同步代码。await后面的代码,实际上会被转换成Promise.then的回调函数,也就是一个微任务。

async function myAsyncFunction() {
  console.log("async函数开始");
  await new Promise(resolve => setTimeout(resolve, 0)); // 注意这里是setTimeout,所以是宏任务
  console.log("async函数结束");
}

console.log("任务开始");
myAsyncFunction();
console.log("任务结束");

// 输出顺序:
// 任务开始
// async函数开始
// 任务结束
// async函数结束

但是注意,这里await new Promise(resolve => setTimeout(resolve, 0)), 虽然await 后面跟的是一个Promise,但是这个Promise内部使用了setTimeout, 所以实际上这里是制造了一个宏任务。因此,“async函数结束”会在下一次事件循环中执行。

如果我们将setTimeout改成queueMicrotask或者直接resolve,那么结果就会不一样了:

async function myAsyncFunction() {
  console.log("async函数开始");
  await new Promise(resolve => queueMicrotask(resolve)); // queueMicrotask,微任务
  console.log("async函数结束");
}

console.log("任务开始");
myAsyncFunction();
console.log("任务结束");

// 输出顺序:
// 任务开始
// async函数开始
// 任务结束
// async函数结束

或者:

async function myAsyncFunction() {
  console.log("async函数开始");
  await Promise.resolve(); // 直接resolve,微任务
  console.log("async函数结束");
}

console.log("任务开始");
myAsyncFunction();
console.log("任务结束");

// 输出顺序:
// 任务开始
// async函数开始
// 任务结束
// async函数结束

在这种情况下,“async函数结束”会在当前事件循环中,在同步代码执行完毕后,立即执行。

第七幕:实际应用场景——选择困难症患者的福音

那么,setTimeout(..., 0)queueMicrotask()在实际开发中有什么用呢?什么时候该用哪个呢?

  • setTimeout(..., 0)

    • 解耦任务: 如果你想把一个耗时的任务分解成多个小任务,避免阻塞UI线程,可以使用setTimeout(..., 0)
    • 延迟执行: 如果你想在浏览器完成渲染之后再执行某些操作,可以使用setTimeout(..., 0)。比如,你可能需要在DOM更新之后,再获取元素的尺寸。
    • 模拟用户交互: 有些情况下,你可能需要模拟用户的交互行为。比如,你可能需要延迟一段时间后,再触发一个点击事件。
  • queueMicrotask()

    • 确保任务在渲染前执行: 如果你需要在DOM更新之后,立即执行某些操作,而且这些操作不能被UI渲染打断,可以使用queueMicrotask()。比如,你可能需要在更新DOM之后,立即更新组件的状态。
    • 避免状态不一致: 如果你有多个操作会修改同一个状态,为了避免状态不一致,可以使用queueMicrotask()把这些操作放在同一个微任务队列里。这样可以确保这些操作在一个事件循环迭代中全部执行完毕。
    • 库的内部实现: 很多库会使用queueMicrotask来确保某些操作的原子性,比如在状态更新后立即触发回调函数。

总的来说,如果你对执行时机要求不高,或者希望把任务分解成多个小块,可以使用setTimeout(..., 0)。如果你对执行时机要求很高,或者需要在渲染前执行某些操作,可以使用queueMicrotask()

第八幕:总结——记住这些要点

  • setTimeout(..., 0)把任务扔到宏任务队列,queueMicrotask()把任务扔到微任务队列。
  • 微任务的优先级高于宏任务。
  • Event Loop负责协调宏任务队列和微任务队列的工作。
  • Promise.thenasync/await都是微任务的典型代表。
  • 根据实际需求选择合适的API。

谢幕:彩蛋——一道思考题

最后,给大家留一道思考题:

console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

queueMicrotask(() => console.log(4));

setTimeout(() => console.log(5));

Promise.resolve().then(() => console.log(6));

console.log(7);

请问,这段代码的输出顺序是什么? 欢迎在评论区留下你的答案!

今天的JS任务队列脱口秀就到这里了,感谢大家的观看! 我们下期再见!

发表回复

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