页面上的一个像素要出现在屏幕上,中间要穿过解析、样式计算、布局、绘制、栅格化、合成这几道工序。搞清楚每道工序的输入输出,才能解释为什么改 width 卡而改 transform 不卡。
解析:字节流变成两棵树
HTML 解析器拿到的是字节流,先按编码(<meta charset>、HTTP 头、BOM 推断)解码成字符,再交给分词器产出 token,最后组装成 DOM 节点。这个流式过程意味着浏览器可以在首包还没收完时就开始建树。
CSS 走的是另一条线,产出 CSSOM。CSSOM 必须建成完整规则集才能参与样式计算,所以 CSS 是”解析阻塞渲染”的:<head> 里的外链样式会推迟首次绘制。这也是内联关键 CSS 能改善首屏的底层原因。
JS 的处境更特殊。默认情况下脚本会阻塞 DOM 构建,因为它可以调用 document.write 改变输入流。遇到 <script src> 时解析器会停下来等脚本下载执行完毕。
<!-- defer:DOM 解析完再按序执行,不阻塞解析 -->
<script defer src="app.js"></script>
<!-- async:下载完立刻执行,顺序不保证,适合独立脚本 -->
<script async src="analytics.js"></script>
defer 与 async 的差别不在”快慢”,而在执行时机与顺序保证。有依赖关系的脚本只能 defer,无依赖的埋点类才适合 async。
样式计算:层叠、继承与匹配
浏览器把 CSSOM 规则逐个匹配到 DOM 节点上,得到每个元素的计算样式(computed style)。三步走:
- 收集:把所有命中的声明收集起来,不管优先级;
- 层叠:按来源(
!important作者样式 > 动画 > 作者 > 用户代理)、特异性、书写顺序排序,取胜者; - 继承与默认:未声明的属性从父级继承或用初始值兜底。
选择器匹配是从右往左走的。.list .item 会先找所有 .item,再向上验证祖先。所以最右那一层的选择器越具体越好,写 .item 比写 div span:nth-child(2) 省事得多。
布局:几何计算的成本
布局(Layout / Reflow)是给每个可见盒子算出位置和尺寸。它是一个递归过程:父节点的尺寸依赖子节点,子节点的百分比又依赖父节点。
关键认知是布局的作用域不是元素本身。改一个节点的宽度,可能导致父节点高度变化,再引起兄弟节点位移,一路波及到祖先和后续兄弟。这就是”重排很贵”的来源——它常常不是 O(1) 而是影响一整片子树甚至整个文档。
// 反面教材:读写交替,每次读都强制同步布局
for (const el of items) {
el.style.width = '50%';
console.log(el.offsetWidth); // 读取 → 强制刷新布局
}
// 正确做法:先批量读,再批量写
const widths = items.map(el => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] / 2 + 'px'; });
这个现象叫布局抖动(layout thrashing)。现代浏览器有异步布局缓存,但只要你在写操作之后立刻读几何属性,缓存就会失效,被迫同步计算一次。
绘制与栅格化
布局之后是绘制(Paint):把每个元素按层叠顺序拆成一系列绘制指令(画矩形、画文字、画边框、画阴影),指令序列先被记录进显示列表,还不会变成像素。
栅格化(Rasterize)才真正产生位图,由合成器线程交给 GPU 或光栅线程执行。栅格化的最小单位是图块(tile),浏览器优先栅格化视口附近的图块。
这里有一个容易忽略的点:栅格化结果会被缓存。图块没变就不必重画。把一个高频移动的元素单独提成一层,意味着它移动时不需要重栅格化底下的内容。
合成与 GPU 加速
合成(Composite)阶段把若干图层按 z 序、变换、透明度叠起来输出最终画面,这一步跑在合成器线程上,不受主线程 JS 阻塞影响。
只有 transform、opacity、filter 这类属性在特定条件下可以完全由合成器处理——前提是该元素已经拥有独立的合成层。
属性改动的代价分级
| 改动的属性 | 触发布局 | 触发绘制 | 触发合成 | 说明 |
|---|---|---|---|---|
width / height / padding |
是 | 是 | 是 | 几何变化,波及祖先与兄弟 |
top / left / margin |
是 | 是 | 是 | 相对定位位移仍走布局 |
font-size / line-height |
是 | 是 | 是 | 文本度量变化,重排范围大 |
color / background-color |
否 | 是 | 是 | 跳过布局,仍需重绘该区域 |
box-shadow / border-radius(动画中) |
否 | 是 | 是 | 重绘代价随面积增长 |
opacity |
否 | 否(提层后) | 是 | 提层后仅合成器参与 |
transform |
否 | 否(提层后) | 是 | 位移/缩放/旋转的最优选择 |
判定”某个属性到底走不走布局”的最可靠办法不是背表,而是打开 DevTools 的 Performance 面板录制,看有没有紫色(Layout)和绿色(Paint)条目。
预加载扫描器:解析阻塞的例外
浏览器在遇到阻塞脚本时并不是完全停摆。主线程被脚本卡住期间,有一个独立的预加载扫描器会继续往下扫 HTML,提前发现 <img>、<link>、<script> 引用的资源并发出请求。这解释了为什么即使解析被阻塞,后面的图片仍然可能已经在下载。
但它有盲区:由 JS 动态插入的资源、document.write 生成的内容、CSS 里的 url()(要等 CSSOM 建好才能扫),扫描器都看不到。凡是只能靠 JS 才能发现的资源,都要晚一个环节才能开始加载。
document.write 会把脚本输出直接插入输入流,这让预加载扫描器已经发出的请求可能作废,也是它被列为反模式的原因之一。
图层提升的收益与代价
给元素一个合成层,常见写法:
.card {
will-change: transform; /* 提前提示,浏览器先备好层 */
transform: translateZ(0); /* 旧写法,副作用类似 */
}
收益是动画不再触发重绘,滚动和位移由合成器独立完成。代价同样实在:
- 显存占用:一层就是一张位图,宽 × 高 × 4 字节。一个 1920×1080 的全屏层约 8MB,十几个大层就能吃掉几百 MB 显存,移动端更容易爆。
- 过度分层:
will-change写在几十个元素上,等于把内存压力和管理开销都抬上去,反而变慢。 - 层树遍历成本:层数越多,合成时的遍历与排序越重。
- 文本渲染变化:提层后可能不再走子像素抗锯齿,文字看起来发虚。
正确的用法是动态加、用完就摘:
el.addEventListener('mouseenter', () => { el.style.willChange = 'transform'; });
el.addEventListener('animationend', () => { el.style.willChange = 'auto'; });
层叠上下文与合成层不是一回事
z-index、opacity < 1、transform、filter、will-change、position: fixed 等属性会创建层叠上下文,它只决定绘制顺序,不必然产生独立的合成层。
合成层是合成器线程的管理单位,由浏览器根据启发式规则决定,或者被 will-change 显式提示。一个元素有层叠上下文却没有独立合成层是完全正常的——它仍然和父层一起被栅格化。
混淆这两者的后果是:写了 will-change: opacity 期待加速,但因为层没提成,opacity 动画依然在走重绘。判断有没有真的提层,要看 DevTools 的 Layers 面板,而不是看 CSS 写了什么。
写代码时的具体约束
- 批量改样式用
class切换,而不是逐个改style属性; - 需要脱离文档流的动画元素,给它
position: absolute/fixed减少布局波及; - 用
requestAnimationFrame对齐帧节奏,避免在一帧内多次读写; - 滚动监听里不要读
offsetTop,用IntersectionObserver或缓存值; - 对容器尺寸变化的响应优先用
ResizeObserver,它不依赖布局读取,也不会触发抖动。
理解管线的价值在于:绝大多数”页面卡”可以归结为主线程被 JS 或布局/绘制占满,或者动画走了不该走的路径。定位到具体阶段,优化手段就自动浮现了。
Comments (4)
这玩意儿在生产上真敢上?我们上次试了一下,监控直接报警。
这文章把我说服了,之前的做法确实有问题。
讲道理,这个方案对新人不太友好,学习曲线有点陡。
面试被问过这个问题,当时答得稀烂,现在终于敢说了。
Please Sign in to join the discussion.