渲染是浏览器的决定,而不是 JavaScript 的决定
Source: Dev.to
Overview
这是关于 JavaScript 实际运行方式系列的第五篇文章。你可以在 这里 或我的 网站 阅读完整系列。
你修改了 DOM。
你期待屏幕更新。
但它没有。
为什么?
在之前的文章中,我们确立了三个约束:
- JavaScript 运行到完成。
- 任务形成调度边界。
- 微任务必须在继续之前全部清空。
现在我们再添加第四条:
浏览器在宏任务运行期间以及微任务正在清空时都不会渲染。
渲染是浏览器的决定
到目前为止,我们关注了系统的两个部分:
- JavaScript 引擎 – 执行代码并管理调用栈。
- 运行时 – 提供事件循环和调度规则。
这两者都不负责渲染。除此之外,浏览器还包含一个 渲染引擎——负责布局和绘制的子系统。
- 引擎执行你的代码。
- 运行时决定代码 何时 运行。
- 渲染引擎决定结果 何时 可见。
为简化起见,本文将该渲染引擎统称为 浏览器。
渲染误区
当我刚开始学习 JavaScript 时,我抱有几个看似合理的心理模型:
- DOM 更新会立即渲染。
- 如果我更改 UI,用户会立刻看到。
- 浏览器以 60 fps 的速度持续渲染。
这种感觉很自然,因为屏幕经常快速更新,但这些模型并不完整。渲染并 不会 在每次 DOM 变化时发生。相反,它只在出现 “安全时机” 时进行——即当前宏任务完成 且 微任务队列为空之后。
渲染不是由 DOM 变更触发的;它受调度边界的限制。让我们来测试一下。
Source: …
运行实验
这些实验依赖于浏览器的渲染行为。
创建一个包含以下内容的简单 HTML 文件:
Initial Enter fullscreen mode Exit fullscreen mode在浏览器中打开该文件。
你可以通过将本系列中的所有代码片段粘贴到浏览器控制台来运行它们。
注意: 这些示例在 Node.js 中无法工作,因为它们依赖于 DOM 和浏览器渲染。
测试 1:同一宏任务内的 DOM 更新
如果在同一个宏任务中有多个 DOM 更新会怎样?考虑下面的代码,它在最终字符串准备好之前使用了占位符:
const box = document.getElementById("box");
box.textContent = "Temporary string";
for (let i = 0; i {
box.textContent = "Final string of Test 2";
});同样,我们只会看到 “Final string of Test 2”。
初始宏任务运行并设置 “Temporary string.” 在调用栈清空后,微任务立即运行并将 DOM 更新为 “Final string.” 此时浏览器才有机会进行渲染。
微任务会像同步代码一样延迟渲染。
测试 3:引入新任务可触发绘制
现在考虑一个定时器回调:
const box = document.getElementById("box");
box.textContent = "Temporary string";
setTimeout(() => {
box.textContent = "Final string of Test 3";
}, 1000);这一次我们可能会先看到 “Temporary string,”,随后在一秒后看到 “Final string of Test 3”。
我们引入了一个 任务边界。浏览器完成初始宏任务,清空微任务(此处没有),随后获得渲染的机会。如果它选择渲染,“Temporary string” 就会可见。稍后,当定时器的宏任务运行时,DOM 更新为 “Final string,”,下一次渲染会反映这一变化。
渲染在任务边界处是被允许的。 这并 不 意味着在宏任务之间渲染一定会发生;仅表示它可以在那里发生。
为什么渲染会等待
如果浏览器在宏任务执行期间或微任务正在清空时进行渲染,可能会显示半更新的 DOM、布局不一致或状态仅部分计算完成。
在渲染仅在宏任务结束 且 微任务队列为空时才发生的约束下,浏览器只会渲染 稳定状态。没有部分工作在进行中,因此渲染相对于 JavaScript 执行是原子的。
正确的思维模型
从实验中我们已经展示,浏览器并不会在每次 DOM 变化时渲染。相反:
浏览器仅在 JavaScript 完成其一次“轮次”后才进行渲染。
“A turn” 指的是:
- 当前宏任务完成,且
- 微任务队列已被完全清空。
渲染仅在这些边界点被允许。这并不意味着浏览器会在每个轮次后都渲染;它只是不能在轮次期间渲染。渲染的决策受我们在整个系列中构建的相同调度规则的约束。
本系列
这为我们接下来做什么准备
如果渲染仅在特定边界发生,就会出现一个新问题:我们该如何编写在恰当时机运行的代码?
setTimeout 会创建一个新的宏任务,但它并未与浏览器的帧时机对齐。
微任务会延迟渲染,但它们并不调度渲染。如果我们想要流畅的动画和响应式更新,就需要一种在浏览器渲染下一帧之前运行代码的方式。
这正是 requestAnimationFrame 所设计的目标。在下一篇文章中,我们将更仔细地观察浏览器的渲染循环是如何工作的,以及如何与之和谐地安排任务。
本文最初发表于我的网站。