单线程与事件循环
JavaScript 为什么是单线程?
- 目的: 作为浏览器脚本语言, 核心职责是用户交互与 DOM 操作;
- 冲突: 多线程同时修改 DOM 会引入复杂的线程同步问题;
- 取舍: 即使可以用锁解决, 也会显著提高语言复杂度, 因此选择单线程;
为什么要引入异步?
- 问题: 单线程下长时间任务会阻塞主线程, 进而阻塞 UI 渲染;
- 方案: 把耗时任务交给宿主, 主线程只处理回调, 避免阻塞;
事件循环由哪些部分组成?
- 主线程: 顺序执行同步代码;
- 宏任务队列: 存放宿主发起的任务;
- 微任务队列: 存放 JavaScript 发起的任务;
- 渲染阶段: 视口刷新与回调调度;
| 类别 | 典型来源 |
|---|---|
| 宏任务 | script 标签、setTimeout/setInterval、postMessage、IO、UI 渲染 |
| 微任务 | Promise、MutationObserver、async/await、process.nextTick |
事件循环的执行顺序是什么?
- 执行主线程同步代码 (只有第一轮事件循环存在同步代码); 遇到宏任务或微任务时分别入队;
- 取出宏任务队列中的第一个任务执行, 完成后出队;
- 清空微任务队列, 且本轮新增的微任务也在本轮清空;
- 执行
requestAnimationFrame回调与 DOM 渲染; - 执行
requestIdleCallback, 本轮结束, 回到第 2 步; - 循环直至宏任务队列清空;
计时器为什么不准时?
- 计时线程:
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 处理完之后派发
};