B04 事件循环与任务队列
B04 事件循环与任务队列
摘要:JavaScript 单线程执行,异步能力来自事件循环:同步代码跑完,清空微任务队列,再渲染,然后取一个宏任务。
Promise回调是微任务,setTimeout回调是宏任务,因此Promise总在定时器之前落地。长任务阻塞渲染,超过 50 毫秒即可能掉帧。本文给出一条调度规则、一段可预测的输出序列与三种拆分长任务的做法。
关键词:事件循环,微任务,宏任务,渲染时机,长任务
所属:frontend/js / B-进阶 | 上一篇:B03-Promise与async异步 | 下一篇:B05-模块化与ESM
1 引言
同样的代码,setTimeout(fn, 0) 排在 Promise.then(fn) 之后执行;页面上的加载动画在同步循环期间完全静止。两者都由事件循环的调度顺序决定,与代码书写位置无关。
本文回答三个问题:一轮循环做了什么、不同异步 API 排在哪一队、长任务为什么卡住页面。范围见表 1-1。
| 项 | 本文覆盖 | 本文不覆盖 |
|---|---|---|
| 调度 | 调用栈、微任务、宏任务、渲染时机 | Worker 线程(C03) |
| API | Promise、setTimeout、queueMicrotask |
网络与 IO 内部实现 |
| 性能 | 长任务拆分、让出主线程 | 渲染流水线细节(CSS C03) |
2 预备知识
| 项 | 要求 | 核对方式 |
|---|---|---|
| 环境 | Chrome 130+ 或 Node.js 20+ | 能执行脚本 |
| 前置 | B03 | 理解 Promise 状态 |
| 文件 | js-practice/event-loop.js |
编码 UTF-8 |
3 原理与实现
3.1 一轮循环
每轮循环按固定顺序推进:执行一个宏任务 → 清空微任务 → 渲染 → 取下一个宏任务,如图 3-1 所示。
flowchart TD
A["取一个宏任务执行"] --> B["调用栈清空"]
B --> C["清空微任务队列<br/>Promise.then·queueMicrotask"]
C --> D{"微任务是否又产生微任务"}
D -->|是| C
D -->|否| E["浏览器渲染<br/>样式·布局·绘制"]
E --> F{"是否还有任务"}
F -->|是| A
F -->|否| G["等待新任务"]
图 3-1 事件循环一轮的四步:宏任务、微任务、渲染、下一轮
图上可见:微任务队列必须彻底清空才进入渲染,因此微任务里递归添加微任务会让页面无法渲染。
3.2 可预测的输出序列
// js-practice/event-loop.js;运行环境:Node.js 20+
console.log('1 sync start');
setTimeout(() => console.log('6 macrotask timeout'), 0);
Promise.resolve()
.then(() => console.log('4 microtask then-1'))
.then(() => console.log('5 microtask then-2'));
queueMicrotask(() => console.log('4b microtask queued'));
(async () => {
console.log('2 async body start');
await null; // 等价于一个微任务
console.log('4c microtask after await');
})();
console.log('3 sync end');
// 执行顺序:
// 1 sync start
// 2 async body start
// 3 sync end
// 4 microtask then-1
// 4b microtask queued
// 4c microtask after await
// 5 microtask then-2
// 6 macrotask timeout
调度差异见表 3-1。
| API | 队列 | 执行时机 | 典型用途 |
|---|---|---|---|
Promise.then |
微任务 | 当前宏任务结束后立即 | 链式异步结果 |
await 之后的代码 |
微任务 | 同上 | 异步函数续体 |
queueMicrotask |
微任务 | 同上 | 保证在渲染前执行 |
setTimeout(fn, 0) |
宏任务 | 下一轮,最小约 4 毫秒 | 延后执行、拆分任务 |
requestAnimationFrame |
渲染前 | 每次渲染前 | 动画、读取布局 |
| 事件回调(点击等) | 宏任务 | 事件触发时排入 | 用户交互 |
长任务的三种拆法见表 3-2。
| 做法 | 写法 | 代价 |
|---|---|---|
| 分片让出 | 每处理一批 await new Promise(r => setTimeout(r)) |
总耗时略增 |
| 空闲时间执行 | requestIdleCallback |
不保证执行时机 |
| 移出主线程 | new Worker(...) |
需要结构化克隆数据 |
3.3 长任务的三种拆法
一次处理五千条数据的同步循环会让页面停止响应,三种拆法的实现如下。
// js-practice/event-loop.js(续);运行环境:Node.js 20+ 或 Chrome 130+
const rows = Array.from({ length: 5000 }, (_, i) => i);
// 1 分片让出:每批处理完后把主线程交还一次
async function chunked(list, size = 500) {
const out = [];
for (let i = 0; i < list.length; i += size) {
out.push(...list.slice(i, i + size).map((n) => n * 2));
await new Promise((resolve) => setTimeout(resolve)); // 让出一次宏任务
}
return out;
}
// 2 空闲时间执行:把低优先级工作放到浏览器空闲期
function idle(list, done) {
let i = 0;
const step = (deadline) => {
while (i < list.length && deadline.timeRemaining() > 1) i += 1;
if (i < list.length) requestIdleCallback(step);
else done(i);
};
requestIdleCallback(step, { timeout: 1000 });
}
// 3 移出主线程:Worker 处理纯计算,结果通过消息回传
// worker.mjs:self.onmessage = (e) => self.postMessage(e.data.map((n) => n * 2));
function inWorker(list) {
const worker = new Worker('./worker.mjs', { type: 'module' });
return new Promise((resolve) => {
worker.onmessage = (event) => {
resolve(event.data);
worker.terminate();
};
worker.postMessage(list);
});
}
// 4 节流:与防抖相对,保证固定间隔内最多执行一次
function throttle(fn, interval = 200) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last < interval) return;
last = now;
fn(...args);
};
}
const onScroll = throttle(() => console.log('scroll at', Date.now()));
window.addEventListener?.('scroll', onScroll, { passive: true });
const t1 = performance.now();
const doubled = await chunked(rows);
console.log(doubled.length, Math.round(performance.now() - t1)); // 5000 与分片数相关
一轮调度的推演见表 3-3,用于核对复杂混排的输出顺序。
| 步骤 | 执行内容 | 队列变化 |
|---|---|---|
| 1 | 同步代码全部执行完 | 微任务 2 条、宏任务 1 条 |
| 2 | 清空微任务,先入先出 | 微任务清零 |
| 3 | 微任务中新增的微任务继续在本轮清空 | 微任务清零 |
| 4 | 浏览器渲染(样式、布局、绘制) | 无 |
| 5 | 取一个宏任务执行 | 宏任务清零 |
| 6 | 回到步骤 2 | 循环 |
表 3-3 一轮事件循环的六个步骤
4 验证与讨论
- 执行脚本,与注释中的预期顺序逐行对照。
- 把
await null改为await Promise.resolve(),确认顺序不变。 - 在
Promise.then内再链一个then,确认它仍排在setTimeout之前。 - 写一个
while (Date.now() - start < 300) {}的同步循环,中间插入requestAnimationFrame计数,确认 300 毫秒内计数为 0。
| 检查项 | 预期结果 | 说明 |
|---|---|---|
| 同步优先 | 1、2、3 先输出 | 调用栈清空后才处理队列 |
| 微任务优先 | 4 系列在 6 之前 | 一轮内清空微任务 |
| 链式微任务 | then-2 排在本轮微任务末尾 |
每个 then 重新入队 |
| 长任务阻塞 | 同步循环期间无渲染 | 渲染需等待调用栈清空 |
讨论与局限。 各浏览器对嵌套 setTimeout 的最小延迟有 4 毫秒限制,0 不等于立即执行。另,Node.js 20 的 timers 阶段顺序与浏览器略有差异,跨环境验证输出时不要假设完全一致。
5 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
定时器总在 Promise 之后 |
宏任务晚于微任务 | 需要更早执行改用 queueMicrotask |
| 加载动画在计算时卡住 | 同步长任务阻塞渲染 | 分片让出或移到 Worker |
| 微任务死循环导致页面假死 | 微任务内无限递归 | 改为宏任务递归 |
setTimeout(fn, 0) 延迟明显 |
嵌套层级触发 4 毫秒下限 | 改用 requestAnimationFrame |
| 修改 DOM 后立即读尺寸不准 | 读写在同帧混排 | 先读后写,或用 requestAnimationFrame |
6 结论
| 序号 | 结论 | 依据 |
|---|---|---|
| 1 | 一轮顺序为宏任务 → 微任务 → 渲染 | 图 3-1 |
| 2 | 微任务必须清空才渲染,禁止在微任务中递归 | 图 3-1 |
| 3 | 超 50 毫秒的同步任务按长任务处理 | 表 3-2 |
| 4 | 拆分长任务有三种做法,按数据量选择 | 表 3-2 |
- 上一篇:B03-Promise与async异步
- 下一篇:B05-模块化与ESM
- 返回:B00-总结 | 科目 README
参考文献
[1] HTML Standard · Event loops,WHATWG,https://html.spec.whatwg.org/multipage/webappapis.html#event-loops(访问于 2026-10-31)
[2] web.dev · 优化长任务,https://web.dev/articles/optimize-long-tasks(访问于 2026-10-31)

评论 0
还没有评论,快来抢沙发吧~