以“失效”视角思考:一种关于浏览器如何渲染页面的实用模型
通过思考每项变更会使哪些内容失效,来学习理解浏览器的渲染机制,这些方面包括布局重排、绘制开销以及合成、水合和流式渲染等。
几乎每位前端开发者都能背出渲染流程:HTML转化为DOM,CSS转化为CSSOM,随后是布局、绘制和合成阶段。这样的顺序虽可作为入门介绍,但当实际页面出现卡顿时却几乎毫无用处,因为它描述的是单次处理过程,而浏览器实际上会持续进行多次部分性的处理。本指南用以一个问题为核心的实用模型取代了那些需要记忆的流程列表,这样你就能用相同的逻辑来解释布局抖动、绘制缓慢、动画效果不佳以及数据加载延迟等问题,并在开发者工具中验证自己的推测。
渲染是持续的过程,而非一次性事件
一个页面绝不会只渲染一次后就置之不理。脚本会修改DOM结构,类名的变化会改变样式,图片和网页字体会在延迟后加载,用户会悬停和滚动,动画则每一帧都在更新,而框架会在状态改变后重新渲染组件。所有这些事件都会迫使渲染引擎回答同一个问题:
哪些内容已经失效,需要重新处理多少工作?
牢记这个问题比记住各种技术术语更有价值。这就是为什么两个外观完全相同的应用在同一部手机上的表现可能大相径庭:一个会不断触发昂贵的重新计算,而另一个则主要利用浏览器已有的结果。
第一步:浏览器构建内部模型
当HTML内容到达时,解析器会将其转换为DOM。DOM并非页面的图像,而是一棵由元素节点、属性节点和文本节点构成的树,同时还包含了这些节点之间的父子关系和兄弟关系。来看一小段标记代码:
<main>
<h1>Browser Rendering</h1>
<p>Understanding what happens after HTML arrives.</p>
</main>
解析器会将其转换为一棵引擎可以查询和更新的树结构:
Document
└── main
├── h1
│ └── "Browser Rendering"
└── p
└── "Understanding what happens after HTML arrives."
样式表也有自己的处理流程。浏览器会解析它们,应用层叠规则和继承机制,确定哪些规则适用于哪些元素,并计算出每个元素中每个属性的最终值。此时还不会绘制任何内容,引擎正在积累状态信息,在确定几何形状之前需要先掌握结构信息以及计算出的样式信息。以依赖关系为导向的流程示意图如下:
HTML ──→ DOM ───────────┐
├──→ Style + Layout ──→ Paint ──→ Composite
CSS ──→ Styles ────────┘
应将那张图视为依赖关系图,而非实现细节的描述。真正的渲染引擎包含更多内部处理阶段、缓存机制、失效跟踪结构、多线程以及针对特定平台的优化方案。该图表准确展示了哪些输出依赖于哪些输入。如果您想更深入地了解在布局之前样式值是如何被确定的,可以阅读我们关于CSS在浏览器处理流程中的位置的文章。
布局即依赖关系计算
在获得DOM结构和计算出的样式信息后,浏览器会开始进行几何形状的计算。布局需要解决以下问题:
- 每个元素的宽度和高度是多少?
- 每个元素应放置在哪里?
- 文本在容器内应在何处换行?
最后一个问题正是导致布局计算成本高昂的原因。假设某段文字因为字体大小增加而变高,那么其后的所有内容都可能需要向下移动,容器的尺寸也可能随之改变,进而影响容器内的其他元素。看似局部的小变化可能会波及文档的很大范围。正因如此,将布局视为解决一系列相互依赖关系的问题,而非简单地在画布上放置方块,会更有帮助。
布局引擎会尽力将重新计算限制在受影响的子树范围内,但实际需要进行的计算工作量取决于变化的类型以及元素之间的相互关系。例如,固定尺寸容器中的内容通常比自适应尺寸容器中的内容产生的连锁反应更小。
无效化比渲染管线更重要
假设有一个脚本执行了 card.style.width = "500px"。此时浏览器会意识到该卡片之前的几何信息,以及可能相关的其他元素的几何信息都已过时。在下一帧渲染之前,浏览器必须确定需要重新执行哪些步骤。简而言之:
Something changes
│
▼
What does it invalidate?
│
├── Style
├── Layout
├── Paint
└── Composite
关键在于,并非所有的变化都会贯穿整个渲染管线:
- 宽度的改变会影响到几何形状,因此需要重新布局,之后通常还需要重新绘制受影响的区域。
- 背景颜色的改变只会影响外观而不会移动任何元素,因此只需重新绘制,通常无需重新布局。
- 新的
transform值往往可以以更少的工作量应用,但前提是浏览器的图层处理机制及周围环境允许在合成阶段完成更新。
这就是为什么诸如“避免重新渲染”或“只使用GPU”这类笼统的建议在实践中行不通的原因。有用的诊断问题始终是:这一特定更改使哪些操作失效了?我们在关于每项CSS更改会给浏览器带来多大成本的文章中,按这些属性通常会触发的阶段对它们进行了分类。
JavaScript如何迫使渲染工作提前执行
一种常见的意外导致高成本渲染的方式是混合使用布局写入和布局读取操作。下面的循环为每张卡片设置宽度,随即就要求获取该卡片的实际宽度:
for (const card of cards) {
card.style.width = "500px";
const width = card.getBoundingClientRect().width;
console.log(width);
}
getBoundingClientRect()本身没有任何问题,它是一个正常且实用的API。问题出在操作顺序上——样式设置会让布局工作悬而未决,而紧接着的代码又要求获取只有在那些待处理的工作完成后才能确定的值。浏览器别无选择,只能在返回数值之前同步执行样式和布局操作。在循环中就会出现所谓的布局抖动现象:写、读、写、读,每次迭代都强制进行布局更新。
如果重新设计代码,让所有读取操作先执行,所有写入操作后再执行,就能避免这种强制同步的情况:
const widths = cards.map(card =>
card.getBoundingClientRect().width
);
for (const card of cards) {
card.style.width = "500px";
}
现在,测量是在一个已经有效的布局上进行的,而写入操作则由浏览器在下次渲染时统一处理。需要注意的是,这两个版本并非完全等价:第二个版本会在修改之前记录宽度,因此只有在你确实需要这种顺序时才应使用它。如果读取操作确实依赖于之前的写入操作,通常的做法是将写入操作批量处理,并将读取操作安排在下一帧执行,例如使用requestAnimationFrame。
更重要的教训并非“永远不要测量布局”。JavaScript与渲染使用相同的线程和数据,因此代码可能会产生依赖关系,迫使浏览器立即执行原本会按计划高效批量处理的任务。布局抖动还有其他一些隐性的触发因素,我们将在实时DOM集合与布局抖动一文中详细探讨。
绘制是与布局不同的另一项开销
即便已知晓几何形状,浏览器仍需确定每个区域的样式:背景、边框、文本字符、图片、阴影、渐变、裁剪以及其他效果。将这两个阶段分开处理的一种简洁方法如下:
- 布局负责确定元素的位置与大小。
- 绘制负责决定这些元素的显示样式。
更改background-color通常不需要为整个文档生成新的几何图形,而改变尺寸或位置则可能需要。不过也不要认为绘制操作是免费的——绘制大面积区域、复杂的特效如巨大的模糊阴影、频繁更改内容以及过度的无效化操作都可能使绘制时间过长,从而导致帧渲染延迟。
光栅化与合成生成最终帧
绘制操作产生的是绘图指令,而非最终的像素数据。这些指令随后会被光栅化为像素数据,通常会被分割成多个块,并通过平台的图形堆栈进行处理。最后通过合成操作将处理后的各个表面或图层组合起来,形成显示在屏幕上的最终帧。
正是在这个阶段,transform和opacity才展现出其优势。在条件合适的情况下,通过动画调整这些属性可以让元素移动、缩放或淡入淡出,而无需浏览器重新计算文档布局或重绘元素内容——它只需重新组合现有的图层即可。
然而,人们常说的“transform在GPU上运行,因此始终很快”这一说法掩盖了实际的代价:
- 每增加一个图层就会占用更多内存,这在性能较低的移动设备上尤为关键。
- 内容最终仍需被光栅化,而光栅化过程可能相当耗资源。
- 一个元素是否拥有独立的图层取决于浏览器的判断规则及渲染环境,因此相同的CSS在不同情况下表现可能有所不同。
这一基本原则比任何属性列表都更为简单:无效化的数据越少,引擎需要重新计算的部分也就越少。
帧预算是共享的
60Hz的显示器刷新周期约为16.67毫秒,因为1000除以60的结果大约是16.67。而在120Hz下,这一间隔则缩短至约8.33毫秒。
这个时间间隔并非专门为你的代码预留的CPU时间。在这段时间内,浏览器还需要处理脚本执行、样式计算、布局调整、绘制操作、光栅化处理、合成操作、输入响应、网络回调以及自身的各种维护工作。因此,即使渲染任务本身很简单,一个耗时的JavaScript任务也可能会阻碍渲染的进行。
这也正是为何像FCP(首次内容绘制)和INP(从交互到下一次绘制)这样的指标会被单独设定的原因。快速显示内容与快速响应输入是相关的目标,但它们失败的原因各不相同,因此需要分别进行衡量。
UI的构建方式决定了渲染成本
在浏览器开始渲染任何内容之前,架构选择就已经影响了它需要处理的任务量:初始UI是在哪里生成的?
客户端渲染
在CSR模式下,服务器仅发送最基本的HTML框架和应用程序代码,大部分工作由设备来完成:
Server
│
├── HTML shell
└── JavaScript + data
│
▼
Browser
│
├── Parse JS
├── Execute JS
├── Fetch data
├── Build UI
└── Render
服务器端渲染
在SSR模式下,数据获取、应用程序逻辑处理以及HTML生成都在服务器端完成,之后才会发送响应:
Request
│
▼
Server
│
├── Fetch data
├── Execute application logic
└── Generate HTML
│
▼
Browser
│
▼
Parse + Render
SSR能够更快地将有意义的标记传递给浏览器,而CSR则将计算和渲染任务转移到用户的硬件上。总体而言两者并无优劣之分,最佳选择取决于应用的复杂程度、所需数据、响应的缓存效果、用户的设备类型,以及最终需要在浏览器中运行的JavaScript代码量。
水合过程:看起来准备好了并不等于真的准备好了
许多现代框架通过水合机制将这两种方式结合起来。服务器首先发送可直接显示的HTML,随后客户端JavaScript会启动应用并添加实现交互所需的事件处理程序:
Server HTML
│
▼
Browser renders content
│
▼
JavaScript loads
│
▼
Application initializes
│
▼
Hydration
│
▼
Interactive UI
其后果很容易被忽视:页面可能在能够响应点击之前就看似已加载完成。数据同步过程涉及在主线程上下载、解析、编译和执行大量JavaScript代码,初始化组件并设置监听器。因此,大型应用虽然能让内容更快显示,但随后会在主线程上产生大量工作负载,而这恰恰是用户开始点击的时候。
这种矛盾正是当前渲染策略发展方向的由来:减少客户端JavaScript代码的传输量,拆分代码包,并仅激活页面中真正需要交互功能的部分,而非一次性加载整个页面。
工作开始时的流式处理变化
传统的服务器端渲染会在发送任何内容之前先构建完整的响应。而流式处理则是在HTML内容准备好时逐块发送:
Server
│
├── HTML shell ──────────→ Browser
├── Content ─────────────→ Browser
├── More content ────────→ Browser
└── Remaining response ──→ Browser
在服务器仍在生成剩余内容时,浏览器就已经可以解析并渲染早期的数据块。流式传输并不会整体上降低渲染成本。它改变的是可开始执行有用操作的时机,从而缩短从导航到内容显示的时间。渐进式水合机制在客户端也采用了相同思路,通过分阶段进行初始化,而非一次性启动整个应用。
观察流程而非死记硬背
培养直觉最快的方法就是直接观察浏览器。在 Chrome DevTools 中打开性能面板,记录一次交互操作,然后查看界面变化时发生了哪些操作。接着通过立即设置宽度并读取几何信息,人为制造一次强制布局:
element.style.width = "600px";
element.getBoundingClientRect();
再次录制,但这次只进行改变变换值的更新:
element.style.transform = "translateX(100px)";
这项练习的目的并非判定某个属性好而另一个差。对于每段录制内容,请按顺序解答以下问题:
- 样式是否被重新计算?
- 布局是否已执行,且是由脚本强制触发的吗?
- 是否有任何区域被重新绘制?
- 是否发生了光栅化处理?
- 该更新是否完全可以由合成器处理完成?
一旦你以这种方式分析日志,性能面板就不再仅仅是最后的调试工具,而能帮助你了解引擎对代码中每一行的反应。
六步流程图之外的内容
经典的处理流程依然值得了解:
HTML → DOM → CSSOM → Layout → Paint → Composite
真实的渲染系统更为复杂。引擎会始终同步多种相互关联的表示形式,从元素树和计算出的样式,到框几何结构与绘制指令,乃至光栅化图块和合成层。通常情况下,某项更改并不需要重新生成所有这些内容——引擎会判断哪些信息仍然有效,标记出哪些已过时,然后尽可能重复利用现有资源。
关键要点
- 将“我应该应用哪种优化?”替换为“发生了什么变化?哪些内容因此失效?浏览器为何必须重新处理这些工作?”
- 布局本质上是一个依赖关系问题;看似局部的更改可能会影响到整个文档。
- 交替进行的读写操作会导致布局必须同步处理;应将它们分开,或把读取操作推迟到下一帧再执行。
transform 和 opacity 的处理成本才会较低。