← 返回文章

JavaScript / 浏览器 / 前端原理

浏览器事件循环与渲染:从 task、微任务到下一帧

“Promise 一定比 setTimeout 先执行”只是事件循环最表层的结论。真正影响前端体验的问题通常是:DOM 明明改了,为什么页面还没显示?一个 await 能不能让浏览器先渲染?为什么把大任务拆成很多 Promise,页面仍然卡住?动画逻辑为什么应该放进 requestAnimationFrame

要回答这些问题,需要把 JavaScript 队列和浏览器渲染放在同一条时间线上理解。

先建立一条可用的时间线

浏览器会持续运行事件循环。对页面前端来说,可以先使用下面这个简化模型:

  1. 从 task 队列中取出一个可运行任务并执行。
  2. 当前调用栈清空后,执行微任务检查点,持续清空 microtask 队列。
  3. 浏览器判断现在是否有渲染机会;如果有,执行渲染相关步骤。
  4. 在下一次重绘前调用符合条件的 requestAnimationFrame 回调。
  5. 进入后续循环,处理新的 task。

真实 HTML 标准中的事件循环比这复杂,任务也有不同来源和调度规则。这个模型的价值不是替代规范,而是帮助我们解释常见页面行为。

一次脚本执行、点击事件回调、定时器回调,都可以形成 task。Promise reaction 和 queueMicrotask 回调属于 microtask。关键点在于:当前 task 结束后,浏览器会先清空微任务,再可能获得渲染机会。

一段代码的执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('script start');

setTimeout(() => {
console.log('timeout');
}, 0);

Promise.resolve().then(() => {
console.log('promise');
});

queueMicrotask(() => {
console.log('microtask');
});

console.log('script end');

常见输出是:

1
2
3
4
5
script start
script end
promise
microtask
timeout

整个脚本先作为当前 task 同步执行。Promise 回调与 queueMicrotask 按进入微任务队列的顺序运行。setTimeout(..., 0) 只是在计时条件满足后安排一个后续 task,并不代表“立刻执行”,也不保证精确延迟为 0ms。

如果一个微任务运行时继续加入微任务,浏览器会继续处理新加入的项目:

1
2
3
4
5
function keepBusy() {
queueMicrotask(keepBusy);
}

keepBusy();

这会造成微任务饥饿:后续 task 难以运行,渲染机会也迟迟不到。生产代码当然很少直接写出这个无限循环,但递归 Promise、无边界的数据消费和在微任务中不断批处理,可能制造相似问题。

DOM 更新了,不等于像素已经出现

看下面的例子:

1
2
3
4
5
6
7
8
status.textContent = '正在计算…';

const start = performance.now();
while (performance.now() - start < 2000) {
// 模拟繁重的同步计算
}

status.textContent = '计算完成';

JavaScript 确实先把文本改成“正在计算…”,随后又改成“计算完成”。但同一个 task 一直占用主线程,浏览器没有机会在中间绘制。用户大概率只能看到最终状态,还会感到页面冻结了两秒。

加入 await Promise.resolve() 也不能可靠地让出绘制机会:

1
2
3
status.textContent = '正在计算…';
await Promise.resolve();
heavyCalculation();

await 后的延续通常作为微任务执行,仍处在下一次渲染机会之前。要把重活放到后续 task,可以显式让出当前 task:

1
2
3
4
5
6
status.textContent = '正在计算…';

setTimeout(() => {
heavyCalculation();
status.textContent = '计算完成';
}, 0);

这给了浏览器一次可能的渲染机会,但“可能”两个字很重要。浏览器不会承诺每个 task 后都绘制一帧,设备刷新率、页面是否可见和内部调度都会影响结果。如果你的目标是与下一次视觉更新对齐,应该使用 requestAnimationFrame

requestAnimationFrame 解决什么问题

MDN 对 requestAnimationFrame 的描述是:请求浏览器在下一次重绘之前调用指定回调。它适合把动画的状态更新与浏览器刷新节奏对齐:

1
2
3
4
5
6
7
8
9
10
11
function animate(timestamp) {
const progress = Math.min((timestamp - startTime) / 400, 1);
panel.style.transform = `translateX(${progress * 240}px)`;

if (progress < 1) {
requestAnimationFrame(animate);
}
}

const startTime = performance.now();
requestAnimationFrame(animate);

动画应使用回调提供的时间戳计算进度,而不是假设每帧固定 16.67ms。高刷新率屏幕、后台标签页和系统繁忙都会让帧间隔变化。按时间计算,动画即使掉帧也能保持总体时长正确。

requestAnimationFrame 不是通用后台任务队列。把 100ms 的复杂计算塞进回调,照样会错过帧。回调里应该只做下一帧需要的少量状态计算和 DOM 写入。

读取布局和写入样式要分批

浏览器通常会批量处理样式与布局,但读取某些几何属性时,为了返回当前准确结果,可能需要提前完成布局。交替读写会不断打断批处理:

1
2
3
for (const item of items) {
item.style.width = `${container.offsetWidth / 3}px`;
}

循环中每次读取 offsetWidth 都发生在样式写入附近。更合理的方式是先读后写:

1
2
3
4
5
6
7
8
const containerWidth = container.offsetWidth;
const itemWidth = containerWidth / 3;

requestAnimationFrame(() => {
for (const item of items) {
item.style.width = `${itemWidth}px`;
}
});

当然,三列布局本身更适合交给 CSS Grid:

1
2
3
4
.container {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}

理解事件循环的最终目的不是写出更多调度代码,而是判断哪些工作根本不该由 JavaScript 做。

如何拆分不会阻塞交互的计算

假设要在主线程处理 10,000 条记录。一次循环完成会形成长任务;用 Promise 把每条记录包装成微任务,也可能在一次微任务检查点中全部跑完。可以按批次安排后续 task:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function processRecords(records, batchSize = 100) {
let index = 0;

return new Promise((resolve) => {
function runBatch() {
const end = Math.min(index + batchSize, records.length);

while (index < end) {
transform(records[index]);
index += 1;
}

if (index < records.length) {
setTimeout(runBatch, 0);
} else {
resolve();
}
}

runBatch();
});
}

每批之间,事件循环可以处理用户输入和潜在的渲染工作。批次仍然要通过性能记录调整;如果单条 transform 本身就很重,继续减小批次也治标不治本,应考虑算法优化或 Web Worker。

这里还要区分两种“异步”:网络、文件等 I/O 等待不会持续占用主线程;把同步计算放进 async 函数,却不会自动移到另一个线程。async/await 改变的是控制流,不是执行计算的线程。

常见 API 放在哪一层理解

可以用一张简化表帮助记忆:

API 或来源 通常进入哪里 典型用途
<script>、事件回调 task 执行脚本、处理输入
setTimeout 后续 task 延后工作、分批让出主线程
Promise.thenawait 延续 microtask 衔接异步结果、在当前循环末尾更新状态
queueMicrotask microtask 安排轻量且必须先于后续 task 的工作
requestAnimationFrame 下一次重绘前的回调 动画、与视觉帧对齐的 DOM 写入

这张表不能用来推导所有边界顺序。例如不同任务来源之间的选择、浏览器内部渲染条件、后台页面节流都更复杂。业务代码不应该依赖“两个不同来源的 task 谁一定先执行”这类脆弱假设。

调试时看什么

当页面出现卡顿或渲染时序错误时,建议按下面的方式调查:

  1. 在浏览器性能面板录制交互,不只看 Console 输出。
  2. 找到占用主线程的长 task,展开调用栈定位业务函数。
  3. 查看大量微任务是否连续出现,确认是否存在 Promise 链或递归调度。
  4. 检查样式重算、布局和绘制耗时,寻找交替读写 DOM 的代码。
  5. 对动画检查每帧工作量,使用时间戳而不是固定帧率推进。
  6. 对大计算尝试分批或 Worker,并在改造后重新录制对比。

真正实用的结论只有几条:同步 JavaScript 会一直占用当前 task;微任务优先于后续 task,并且会在渲染机会之前清空;await 不等于让浏览器先画一帧;视觉更新使用 requestAnimationFrame 对齐;大计算必须减少、分块或移出主线程。

掌握这些边界后,很多“偶现”的前端问题就不再神秘。它们只是某段工作被放进了错误的队列,或者在浏览器需要交还主线程时仍然没有结束。

参考资料