跳到主要内容

单线程与事件循环

JavaScript 为什么是单线程?​

  • 目的: 作为浏览器脚本语言, 核心职责是用户交互与 DOM 操作;
  • 冲突: 多线程同时修改 DOM 会引入复杂的线程同步问题;
  • 取舍: 即使可以用锁解决, 也会显著提高语言复杂度, 因此选择单线程;

为什么要引入异步?​

  • 问题: 单线程下长时间任务会阻塞主线程, 进而阻塞 UI 渲染;
  • 方案: 把耗时任务交给宿主, 主线程只处理回调, 避免阻塞;

事件循环由哪些部分组成?​

  • 主线程: 顺序执行同步代码;
  • 宏任务队列: 存放宿主发起的任务;
  • 微任务队列: 存放 JavaScript 发起的任务;
  • 渲染阶段: 视口刷新与回调调度;
类别典型来源
宏任务script 标签、setTimeout/setInterval、postMessage、IO、UI 渲染
微任务Promise、MutationObserver、async/await、process.nextTick

事件循环的执行顺序是什么?​

  1. 执行主线程同步代码 (只有第一轮事件循环存在同步代码); 遇到宏任务或微任务时分别入队;
  2. 取出宏任务队列中的第一个任务执行, 完成后出队;
  3. 清空微任务队列, 且本轮新增的微任务也在本轮清空;
  4. 执行 requestAnimationFrame 回调与 DOM 渲染;
  5. 执行 requestIdleCallback, 本轮结束, 回到第 2 步;
  6. 循环直至宏任务队列清空;

计时器为什么不准时?​

  • 计时线程: setTimeout/setInterval 由独立线程计时, 到点后把回调推入宏任务队列;
  • 精度: 时间精度约 6ms, 且回调要等当前宏任务与全部微任务执行完;
  • 累积误差: setTimeout 存在延迟积累问题;
  • 任务跳过: setInterval 在前一次回调仍未执行完时会跳过或叠加, 详见 定时器;

微任务与宏任务、渲染的先后关系是什么?​

  • 顺序: 每个宏任务结束, 先清空全部微任务, 再进行渲染, 然后才取下一个宏任务;
  • 环境保证: 微任务之间不插入渲染、UI 事件或网络事件, 因此它们看到的应用状态一致;
  • 调度微任务: queueMicrotask(fn) 把函数排入微任务队列; Promise 的 then/catch/finally 与 await 的恢复也走这条队列;
setTimeout(() => console.log("timeout")); // 宏任务, 最后
Promise.resolve().then(() => console.log("promise")); // 微任务
console.log("code"); // 同步代码
// code → promise → timeout
  • 选择: 想 "异步但不必让出渲染" 用 queueMicrotask (递归重活会一次性做完, 页面上只看到最终结果); 想让页面有机会绘制则用 setTimeout;

渲染发生在事件循环的哪一步?​

  • 位置: 本轮微任务清空之后、下一个宏任务之前, 且只有存在变化时才绘制;
  • requestAnimationFrame: 回调在重绘前执行, 与屏幕刷新率同步, 适合按帧推进动画, 见 动画与 requestAnimationFrame;
  • 关系: rAF 回调本身就在这一渲染时机中被调用, 早于实际绘制;
  • 实践含义: 想在动画前读/改布局, 就放进 rAF; 想尽快跑完逻辑而不关心绘制, 用微任务;

长任务为什么会阻塞渲染与交互?​

  • 渲染时机: 引擎执行任务期间从不渲染, 任务多久都不渲染; DOM 改动只在任务结束、回到事件循环后统一绘制;
  • 交互: 任务太久, 浏览器无法处理点击等其它任务, 卡到一定程度会弹出 "页面无响应" 提示;
  • 诱因: 大量复杂计算, 或死循环等编程错误;
  • 反直觉的好处: 正因任务内不渲染, 一次性创建并插入大量元素时, 用户看不到任何中间态;

如何把 CPU 密集任务切片?​

  • 思路: 大任务拆成小块, 每块做完用零延迟 setTimeout 排下一块, 中间回到事件循环透气;
  • 效果: 界面保持可用并可持续响应, 总耗时与不切片相差不大;
let i = 0;
const start = Date.now();

function count() {
do {
i++;
} while (i % 1e6 !== 0); // 只做一小块

if (i === 1e9) {
console.log("Done in " + (Date.now() - start) + "ms");
} else {
setTimeout(count); // 排下一块
}
}
count();
  • 优化: 把 setTimeout(count) 提到块计算之前, 即 "还要继续算就立刻排队", 总耗时明显缩短;
  • 原因: 嵌套 setTimeout 有最小延迟, 越早排队, 下一块开始得越早;
  • 进度条: 切片期间把 progress.innerHTML = i 写在块内, 每块结束都会绘制一次, 于是能看到递增效果;

setTimeout 的最小延迟是多少?​

  • 规则: 浏览器对大量嵌套的 setTimeout 施加约 4ms 的最小延迟, 写 0 也按 4ms 甚至更久算;
  • 影响: 切片方案的每片固定开销至少约 4ms, 片数过多反而拖慢;

长时间计算还有什么替代方案?​

  • Web Workers: 在另一个并行线程中跑代码, 有独立的变量与事件循环, 不阻塞主线程;
  • 限制: 不能访问 DOM, 只能靠消息与主线程交换数据;
  • 适用: 需要占用多核的重计算, 见 工作者线程概览与专用线程;

如何在事件完全传播后再执行动作?​

  • 场景: 事件处理器里要等事件冒泡到所有层级都处理完再动手;
  • 做法: 用零延迟 setTimeout 把动作推迟到当前宏任务之后;
menu.onclick = function () {
const customEvent = new CustomEvent("menu-open", { bubbles: true });
setTimeout(() => menu.dispatchEvent(customEvent)); // 在 click 处理完之后派发
};