深入理解 JavaScript 事件循环:超越 setTimeout
调用栈与任务队列
JavaScript 是单线程的,这意味着它只有一个调用栈来执行代码。当函数被调用时,它会被推入调用栈;当函数执行完毕后,它会被弹出。然而,像网络请求或定时器这样的耗时操作如果都在主线程上执行,将会阻塞整个页面。为了解决这个问题,JavaScript 使用了一个任务队列。当一个异步操作(如 setTimeout)完成时,它的回调函数会被放入任务队列。事件循环会持续检查调用栈是否为空,如果为空,它就会从任务队列中取出第一个任务,并将其推入调用栈执行。83wl2.tongdaolzw.com
console.log("Start");
setTimeout(function() {
console.log("Timeout callback");
}, 2000);
console.log("End");
// 输出顺序:
// 1. "Start"
// 2. "End"
// 3. (大约 2 秒后) "Timeout callback"
微任务与宏任务kqyfr9.tongdaolzw.com
任务队列实际上被分为两种:宏任务和微任务。宏任务包括 setTimeout、setInterval、I/O 操作和 UI 渲染等。微任务则包括 Promise 的回调(.then(), .catch(), .finally())以及 queueMicrotask()。事件循环的执行规则至关重要:在执行完一个宏任务后,会立即清空所有微任务队列中的任务,然后再执行下一个宏任务。这就解释了为什么 Promise 的回调总是比 setTimeout 的回调先执行,即使 setTimeout 的延迟时间为 0。8ketm.tongdaolzw.com
console.log("Script Start");
setTimeout(function() {
console.log("setTimeout (Macro-task)");
}, 0);
Promise.resolve().then(function() {
console.log("Promise.then (Micro-task)");
});
console.log("Script End");
// 输出顺序:
// 1. "Script Start"
// 2. "Script End"
// 3. "Promise.then (Micro-task)"
// 4. "setTimeout (Macro-task)"
async/await 的底层魔法2t926.tongdaolzw.com
async/await 是 JavaScript 中处理异步的语法糖,它让异步代码看起来像同步代码。但它的底层仍然是基于 Promise 的。一个 async 函数会隐式地返回一个 Promise。当函数执行到 await 关键字时,它会暂停该函数的执行,等待 await 后面的 Promise 完成。重要的是,await 后面的代码实际上相当于被放到了 .then() 的回调中,因此它是一个微任务。i4xae.tongdaolzw.com
async function asyncFunction() {
console.log("Inside async function (before await)");
await Promise.resolve();
console.log("Inside async function (after await)");
}
console.log("Script Start");
asyncFunction();
console.log("Script End");
// 输出顺序:
// 1. "Script Start"
// 2. "Inside async function (before await)"
// 3. "Script End"
// 4. "Inside async function (after await)"
渲染任务的角色o9ei6.tongdaolzw.com
在浏览器环境中,事件循环还有一个重要的参与者:渲染任务。浏览器通常希望在每次宏任务执行完毕后,并在执行下一个宏任务之前,重新渲染页面。这意味着,如果你有一个执行时间很长的宏任务(例如一个巨大的 for 循环),它会阻塞 UI 的更新,导致页面卡顿。将复杂的计算分解成多个小的宏任务(例如使用 setTimeout(fn, 0))可以让浏览器有机会在间隙中渲染 UI,从而保持页面的响应性。gdn29.tongdaolzw.com
// 一个会阻塞 UI 的同步任务
function blockingTask() {
const start = Date.now();
while (Date.now() - start < 3000) {
// 空循环,持续 3 秒
}
console.log("Blocking task finished");
}
// 在控制台运行 blockingTask(),你会发现页面在这 3 秒内是完全无响应的。
// 而微任务和宏任务机制的存在,就是为了避免这种情况的发生。
整合所有部分:一个复杂示例eppq7.tongdaolzw.com
现在,让我们把所有概念整合起来,分析一个更复杂的例子。你需要记住以下执行顺序:1. 执行同步代码。2. 执行所有微任务。3. 执行一个宏任务。4. 重复步骤 2 和 3。通过这个模型,你可以精确地预测任何异步 JavaScript 代码的执行顺序。d6gys.tongdaolzw.com
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => {
console.log("3");
Promise.resolve().then(() => console.log("4"));
});
Promise.resolve().then(() => console.log("5"));
setTimeout(() => console.log("6"), 0);
console.log("7");
// 正确的输出顺序是:
// 1. "1" (同步)
// 2. "7" (同步)
// 3. "3" (第一个微任务)
// 4. "5" (第二个微任务)
// 5. "4" (在微任务 "3" 中产生的微任务)
// 6. "2" (第一个宏任务)
// 7. "6" (第二个宏任务)
