执行机制与事件循环
JavaScript 代码“按什么顺序执行”,由运行时的事件循环决定。这篇从单线程模型讲起,梳理宏任务和微任务的划分、一轮循环里渲染发生在哪、async/await 如何拆成微任务,再对比 Node.js 的事件循环阶段。读完应该能独立推出一段异步代码的输出顺序,并知道背后的规则来自哪里。
单线程与运行时结构
Section titled “单线程与运行时结构”JavaScript 引擎在一个线程上执行代码:同一时刻只有一段 JS 在跑,不会被另一段 JS 打断。这是“单线程”的确切含义。但浏览器和 Node.js 本身是多线程的:网络请求、定时器计时、文件读写都由宿主环境在别处完成,完成后把“该执行哪个回调”交回 JS 线程。
运行时可以拆成几个部分:
| 组成 | 作用 |
|---|---|
| 调用栈(call stack) | 记录当前正在执行的函数。每调用一个函数压入一个执行上下文,返回时弹出。 |
| 堆(heap) | 存放对象等动态分配的数据。 |
| 任务队列(task queue) | 存放等待执行的回调,比如定时器到期、事件触发、I/O 完成后的回调。 |
| 微任务队列(microtask queue) | 存放 Promise 回调等,优先级高于任务队列。 |
| 事件循环(event loop) | 不断检查调用栈是否为空,为空就从队列里取回调来执行。 |
基本流程:
- 同步代码依次压入调用栈执行。
- 遇到异步 API(如
setTimeout、fetch、事件监听),把工作交给宿主环境,JS 继续往下执行,不等结果。 - 异步工作完成后,宿主把对应回调放进队列。
- 调用栈清空后,事件循环从队列取出回调执行,如此往复。
一个直接推论:队列里的回调必须等当前调用栈清空才能执行。如果同步代码跑了 2 秒,setTimeout(fn, 0) 也至少要等 2 秒。
宏任务与微任务
Section titled “宏任务与微任务”“宏任务”是社区叫法,HTML 规范里叫 task(任务),微任务叫 microtask。两者的区别不在于“是不是在别的线程完成”,而在于进入哪个队列、什么时候被清空。
| 类型 | 浏览器 | Node.js |
|---|---|---|
| 宏任务 | 整段 <script> 的执行、setTimeout / setInterval、用户交互事件(click、keydown 等)、网络请求回调、MessageChannel 消息 |
setTimeout / setInterval、setImmediate、I/O 回调 |
| 微任务 | Promise.then/catch/finally、queueMicrotask、MutationObserver 回调、await 之后的代码 |
Promise 回调、queueMicrotask、await 之后的代码;另有优先级更高的 process.nextTick 队列 |
注意 requestAnimationFrame 不属于以上两类,它在渲染步骤里执行,后面单独讲。setImmediate 是 Node.js 的 API,浏览器里没有标准实现。
浏览器一轮事件循环可以简化为:
- 从某个任务队列取出一个宏任务执行(浏览器有多个任务队列,取哪个由浏览器决定,比如可能优先处理用户输入)。
- 执行完后进行微任务检查点:清空微任务队列。执行微任务过程中新加入的微任务,也会在这一步被执行完。
- 如果到了渲染时机,执行渲染相关步骤。
- 回到第 1 步。
关键是“一个宏任务、一整批微任务”。正因为微任务会被清空到底,如果微任务里不断递归地添加新微任务,页面会卡死,渲染和后续宏任务都无法执行:
function loop() { queueMicrotask(loop); // 永远清不空,页面失去响应}loop();而用 setTimeout 递归则不会卡死,因为每次只执行一个宏任务,中间有机会处理渲染和其他事件。
queueMicrotask 与 MutationObserver
Section titled “queueMicrotask 与 MutationObserver”queueMicrotask(fn) 直接把函数放进微任务队列,不需要借 Promise.resolve().then(fn) 绕一层,也不会产生多余的 Promise 对象。它适合“在当前同步代码结束后、浏览器渲染前统一处理一次”的场景,比如合并多次状态修改。
MutationObserver 的回调也以微任务方式触发:同一段同步代码里对 DOM 做了多次修改,回调只会在之后被调用一次,拿到这一批变更记录。
const target = document.createElement('div');const observer = new MutationObserver((records) => { console.log('变更数量', records.length);});observer.observe(target, { attributes: true });
target.setAttribute('a', '1');target.setAttribute('b', '2');console.log('同步代码结束');// 同步代码结束// 变更数量 2它的前身是 DOM3 定义的 Mutation Events(DOMNodeInserted 等),那套事件同步触发、性能差,已被废弃,现在应使用 MutationObserver。
渲染时机与 requestAnimationFrame
Section titled “渲染时机与 requestAnimationFrame”浏览器不会在每个宏任务后都渲染。渲染频率通常跟随显示器刷新率,页面不可见时还可能暂停。在一轮循环中如果有渲染机会,“更新渲染”这一步大致按以下顺序进行:
- 处理 resize、scroll 等事件。
- 执行
requestAnimationFrame回调。 - 计算样式、布局。
- 绘制。
因此 requestAnimationFrame 的回调总在下一次绘制之前执行,适合做动画:在里面修改样式,修改结果会体现在这一帧里。
几个推论:
- 同一个宏任务里对 DOM 改了很多次,屏幕上只会看到最终结果,因为渲染发生在宏任务和微任务都执行完之后。
- 微任务在渲染之前被清空,所以在微任务里改 DOM,同样会在这一帧生效。
setTimeout(fn, 0)和requestAnimationFrame(fn)谁先执行不确定,取决于当时离下一次渲染还有多久。不要依赖它们的先后。requestAnimationFrame的回调里如果再调用requestAnimationFrame,新回调会在下一帧执行,而不是本帧。
// 先把元素放到起点,下一帧再触发过渡box.style.transform = 'translateX(0)';requestAnimationFrame(() => { requestAnimationFrame(() => { box.style.transition = 'transform 0.3s'; box.style.transform = 'translateX(100px)'; });});这里套两层,是为了确保起点状态已经被渲染过一次,过渡才有起点。浏览器渲染流程的细节可以参考浏览器工作原理。
async/await 在事件循环中的展开
Section titled “async/await 在事件循环中的展开”async 函数可以理解为基于 Promise 的语法糖:
- 调用
async函数时,函数体同步开始执行,直到遇到第一个await。 await x会把x转成 Promise(相当于Promise.resolve(x)),然后暂停函数,把“剩余部分”注册为这个 Promise 的回调。- 当这个 Promise 完成,剩余部分作为微任务继续执行。
async函数总是返回一个 Promise,函数体的return值就是它的完成值,抛出的异常变成拒绝原因。
下面两段代码的执行时机大致等价:
async function foo() { console.log('A'); await bar(); console.log('B');}
// 近似展开function foo() { console.log('A'); return Promise.resolve(bar()).then(() => { console.log('B'); });}“近似”是因为细节上有差别:await 一个原生 Promise 时,现代引擎会直接复用它,恢复执行只需一个微任务;早期实现会多包几层,需要更多微任务轮次。所以一些较老的资料给出的输出顺序和现在的运行结果不一致,以当前引擎实际运行为准。
另一个容易误会的点:await 后面即使是一个普通值(如 await 1),也会让出执行权,后续代码进入微任务,不会同步继续。
示例一:Promise 与 setTimeout
Section titled “示例一:Promise 与 setTimeout”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。 setTimeout的回调交给定时器,到期后进入任务队列。Promise.resolve()已经是完成状态,第一个then的回调直接进入微任务队列。第二个then依赖第一个返回的 Promise,暂时不入队。- 同步输出
script end。脚本这个宏任务结束。 - 清空微任务:执行
promise1,它返回后第二个then的回调入队,紧接着在同一轮清空中执行promise2。 - 下一轮循环取出定时器回调,输出
setTimeout。
输出:
script startscript endpromise1promise2setTimeout示例二:async/await 混合
Section titled “示例二:async/await 混合”async function async1() { console.log('async1 start'); await async2(); console.log('async1 end');}
async function async2() { console.log('async2');}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => { console.log('promise1'); resolve();}).then(() => console.log('promise2'));
console.log('script end');逐步分析:
- 输出
script start。 - 注册定时器,回调稍后进入任务队列。
- 调用
async1:同步输出async1 start;接着调用async2,同步输出async2,async2返回一个已完成的 Promise。await暂停async1,把“输出async1 end“注册为该 Promise 的回调,因为 Promise 已完成,这个回调进入微任务队列(记为 M1)。 new Promise的执行器函数是同步执行的,输出promise1,随后resolve(),then回调进入微任务队列(记为 M2)。- 输出
script end。 - 清空微任务:先 M1 输出
async1 end,再 M2 输出promise2。 - 下一个宏任务输出
setTimeout。
输出:
script startasync1 startasync2promise1script endasync1 endpromise2setTimeout如果在较老的引擎里运行,async1 end 可能排在 promise2 之后,原因见上一节。
示例三:同一次点击的两个监听器
Section titled “示例三:同一次点击的两个监听器”<button id="btn">点我</button><script> const btn = document.getElementById('btn'); btn.addEventListener('click', () => { Promise.resolve().then(() => console.log('micro 1')); console.log('listener 1'); }); btn.addEventListener('click', () => { Promise.resolve().then(() => console.log('micro 2')); console.log('listener 2'); });</script>用户真实点击时,输出是:
listener 1micro 1listener 2micro 2因为浏览器分发事件时,每个监听器回调执行完、调用栈清空,就会做一次微任务检查点。
如果改成在脚本里调用 btn.click(),输出变成:
listener 1listener 2micro 1micro 2此时两个监听器是在 btn.click() 这次调用内部同步执行的,调用栈上始终还有外层脚本,微任务要等外层脚本执行完才会清空。这个例子说明:微任务清空的时机是“调用栈为空”,而不是简单的“一个宏任务结束”。
Node.js 的事件循环
Section titled “Node.js 的事件循环”Node.js 的事件循环由 libuv 实现,一轮循环分为若干阶段,每个阶段有自己的回调队列:
| 阶段 | 执行内容 |
|---|---|
| timers | 到期的 setTimeout / setInterval 回调 |
| pending callbacks | 部分被推迟到下一轮的系统操作回调(如某些 TCP 错误) |
| idle, prepare | Node 内部使用 |
| poll | 获取新的 I/O 事件,执行 I/O 相关回调;没有其他任务时会在这里等待 I/O |
| check | setImmediate 回调 |
| close callbacks | 关闭事件回调,如 socket.on('close') |
process.nextTick 与微任务
Section titled “process.nextTick 与微任务”process.nextTick 有一个独立的队列,不属于上表任何阶段。规则是:每当一个回调执行完(无论在哪个阶段),先清空 nextTick 队列,再清空 Promise 微任务队列,然后才继续。
// CommonJS 模块中运行Promise.resolve().then(() => console.log('promise'));process.nextTick(() => console.log('nextTick'));console.log('sync');// sync// nextTick// promisenextTick 递归调用同样会饿死事件循环,I/O 回调将永远得不到执行。官方文档建议在大多数场景下优先使用 setImmediate 或 queueMicrotask。
setTimeout 与 setImmediate
Section titled “setTimeout 与 setImmediate”在主模块里直接写:
setTimeout(() => console.log('timeout'), 0);setImmediate(() => console.log('immediate'));两者的先后不确定。Node 会把 0 毫秒的延迟按 1 毫秒处理,进入事件循环时如果 1 毫秒已经过去,timers 阶段先执行 timeout;否则先走到 check 阶段执行 immediate。这取决于进程当时的性能状况。
但放进 I/O 回调里就是确定的:
const fs = require('fs');
fs.readFile(__filename, () => { setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); process.nextTick(() => console.log('nextTick'));});// nextTick// immediate// timeout分析:readFile 的回调在 poll 阶段执行。回调结束后先清空 nextTick 队列,输出 nextTick。poll 之后是 check 阶段,输出 immediate。下一轮循环才回到 timers 阶段,输出 timeout。
与浏览器的差异
Section titled “与浏览器的差异”| 方面 | 浏览器 | Node.js |
|---|---|---|
| 规范来源 | HTML 标准 | libuv 实现与 Node 文档 |
| 宏任务组织 | 多个任务队列,由浏览器调度 | 按固定阶段顺序轮转 |
| 渲染 | 有渲染步骤和 requestAnimationFrame |
没有渲染 |
| 额外队列 | 无 | process.nextTick 队列,优先于 Promise 微任务 |
setImmediate |
非标准,不应使用 | 在 check 阶段执行 |
早期 Node.js(11 之前)在同一阶段里会先把一批定时器回调全部执行完,再统一清微任务,与浏览器行为不一致。从 Node.js 11 起改为每执行完一个 setTimeout / setImmediate 回调就清空一次微任务,与浏览器对齐。旧资料里关于这一点的结论不要照搬。更多 Node.js 运行机制可参考 Node.js 架构与运行机制和 Node.js 异步与并发。
- “微任务不参与事件循环”:微任务是事件循环的一部分,它在每次调用栈清空时被执行,只是不像宏任务那样一轮取一个。
- “宏任务是在别的线程里执行的”:所有 JS 回调都在 JS 线程执行。在别的线程完成的是计时、网络、I/O 等底层工作,完成后回调仍要回到 JS 线程排队。
- “requestAnimationFrame 是宏任务”:它属于渲染步骤,执行频率跟随渲染,而不是像任务队列那样逐个取出。
- “setTimeout(fn, 0) 会立即执行”:它至少要等当前同步代码和所有微任务执行完。HTML 规范还规定,嵌套层级较深(超过 5 层)的定时器最小延迟会被限制为 4 毫秒;后台标签页中的定时器也可能被浏览器进一步降频。
- “Promise 的执行器是异步的”:
new Promise(executor)里的executor是同步执行的,只有then/catch/finally的回调才是微任务。 - “await 之后的代码是同步继续的”:无论
await的是什么,后续代码都会进入微任务。 - “一个宏任务执行完才清微任务”:准确说法是调用栈为空时清微任务,事件分发场景下两者会有差别(见示例三)。
- JS 在单线程上执行,异步工作由宿主环境完成,回调通过任务队列和微任务队列回到 JS 线程。
- 一轮循环执行一个宏任务,随后在调用栈为空时清空全部微任务,再视情况进行渲染。
requestAnimationFrame在渲染步骤中、绘制之前执行,既不是宏任务也不是微任务。await把函数剩余部分变成微任务;现代引擎里await原生 Promise 只需一个微任务轮次。- Node.js 按 timers、poll、check 等阶段轮转,
process.nextTick队列优先于 Promise 微任务;主模块中setTimeout(0)与setImmediate的先后不确定,I/O 回调中setImmediate一定先执行。