B04 事件循环与任务队列

2026-10-31 17:160 阅读约 14 分钟JavaScript

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 验证与讨论

  1. 执行脚本,与注释中的预期顺序逐行对照。
  2. 把 await null 改为 await Promise.resolve(),确认顺序不变。
  3. 在 Promise.then 内再链一个 then,确认它仍排在 setTimeout 之前。
  4. 写一个 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

参考文献

[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

0/2000

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