你写了一行 div.style.left = '100px',屏幕上的盒子就动了。
这中间到底发生了什么?如果你的回答是"浏览器重新渲染了",那基本等于说"电脑帮我算的"------正确但没用。
这篇把"浏览器渲染"这个黑盒拆开,看清里面每一个齿轮怎么咬合。搞明白这条管线,你才能理解为什么有些动画丝滑如德芙,有些卡得像 PPT。
一、全景图:一帧的生命周期
先给一张地图,免得后面迷路。
css
Parse HTML → DOM Tree
↓
Parse CSS → CSSOM Tree
↓
DOM + CSSOM → Render Tree
↓
Layout(布局)
↓
Paint(绘制)
↓
Composite(合成)
↓
屏幕上的像素
每一步都有明确的输入和输出。跳过任何一步,屏幕上就是一片白。
但重点来了------不是每次更新都要跑完整条线 。改个颜色不用重新布局,改个 transform 甚至不用重新绘制。这才是性能优化的核心心智模型。
二、Parse:从字符串到树
HTML → DOM Tree
html
<div class="container">
<p>hello</p>
<img src="cat.jpg" />
</div>
<!--
词法分析器(Tokenizer)把 HTML 切成 token 流:
StartTag:div → StartTag:p → Text:"hello" → EndTag:p → SelfClosingTag:img → EndTag:div
然后按嵌套关系组装成 DOM 树
-->
经典坑:HTML 解析是可被阻塞的。
html
<head>
<!-- 这个 script 会阻塞 DOM 解析 -->
<!-- 浏览器:"等等,万一这脚本里有 document.write 呢?我得停下来" -->
<script src="heavy-bundle.js"></script>
</head>
<body>
<!-- 上面的 JS 没下载完执行完,这里的 DOM 一个都不会构建 -->
<div id="app"></div>
</body>
所以 defer 和 async 不是锦上添花,是必需品。浏览器在等 JS 下载时,整棵 DOM 树的构建就停在那里------用户看到的是白屏。
CSS → CSSOM Tree
CSS 解析独立进行,生成一棵 CSSOM(CSS Object Model)树。但 CSS 会阻塞渲染------浏览器拒绝在 CSSOM 构建完成前渲染任何东西。
为什么?如果先渲染无样式内容,CSS 加载完再闪一下变正常,这就是 FOUC(Flash of Unstyled Content)。浏览器宁愿让你多等 200ms 白屏,也不愿意闪你一脸。
三、Render Tree:真正要画的东西
DOM Tree + CSSOM Tree 合并,生成 Render Tree。
Render Tree 里没有不可见元素:
css
/* display: none → 从渲染世界蒸发,不参与后续任何步骤 */
.hidden {
display: none; /* 不出现在 Render Tree 中 */
}
/* visibility: hidden → 隐身,但位置还在,布局还得算 */
.invisible {
visibility: hidden; /* 仍然出现在 Render Tree 中 */
}
频繁切换显示状态时,visibility 比 display 便宜得多------后者每次切换都要重建 Render Tree 的一部分。
四、Layout:算位置
Layout(也叫 Reflow)要回答一个问题:每个元素在屏幕上的确切位置和大小是多少?
一个元素的位置取决于它的父元素、兄弟元素、子元素、甚至完全不相关的元素(float 和绝对定位表示有话说)。复杂度远比你想的高。
ts
// 触发 Layout 的属性(部分):
// width, height, padding, margin, border
// top, left, right, bottom
// font-size, line-height
// display, position, float
const el = document.getElementById('box')
el.style.width = '200px' // 标记:需要 Layout
el.style.height = '100px' // 标记:需要 Layout
el.style.margin = '10px' // 标记:需要 Layout
// 浏览器会把这三次修改攒成一次 Layout(批处理)
// ❌ 但如果你这样写------
el.style.width = '200px'
const h = el.offsetHeight // 读取布局信息 → 浏览器被迫立即 Layout!
el.style.height = '100px'
const w = el.offsetWidth // 又读了 → 又得 Layout 一次!
强制同步布局(Forced Synchronous Layout)
性能杀手 Top 1。
浏览器本来想攒一攒、统一处理,但你在写入之间插了一次读取,浏览器只能被迫立即结算。
类比:你在超市一边往购物车里放东西一边让收银员结账,收银员每放一件就得扫一次码算一次总价。正常人都会买完再结,对吧?
ts
// ❌ 经典反模式:循环中读写交替
const items = document.querySelectorAll('.item')
items.forEach(item => {
const width = item.offsetWidth // 读 → 强制 Layout
item.style.width = (width * 2) + 'px' // 写 → 下次读又得 Layout
// N 个元素 = N 次 Layout,页面直接卡死
})
// ✅ 读写分离:先统一读,再统一写
const widths = [...items].map(item => item.offsetWidth) // 读(一次 Layout)
items.forEach((item, i) => {
item.style.width = (widths[i] * 2) + 'px' // 写(攒成一次 Layout)
})
React 或 Vue 的虚拟 DOM batch update 帮你避免了大部分这类问题。但操作 Canvas、做拖拽、写自定义组件时,这个坑随时等着你。
五、Paint:画像素
Layout 算出了"在哪",Paint 决定"画成什么样"。
Paint 把每个元素转换成一组绘制指令(paint records):
csharp
1. 在 (10, 20) 画一个 200x100 的矩形,填充 #fff
2. 在 (10, 20) 画一个 1px 边框,颜色 #ccc
3. 在 (20, 35) 绘制文本 "Hello",字号 16px,颜色 #333
// Paint 不直接输出像素
// 真正的像素填充(Rasterize,光栅化)在合成阶段由 GPU 或独立线程完成
哪些属性触发 Paint?
ts
// 只触发 Paint(不触发 Layout)的属性:
// color, background, box-shadow, border-radius, outline, visibility
el.style.backgroundColor = 'red' // ✅ 跳过 Layout,直接 Paint → Composite
el.style.width = '300px' // ❌ Layout → Paint → Composite 全跑
实用原则:能用只触发 Paint 的属性解决的,别动 Layout 属性。
六、Composite:合成------性能优化的终极武器
什么是合成?
浏览器把页面拆成多个图层(Layers),每个图层独立绘制,最后由 GPU 把所有图层叠在一起------就像 Photoshop 的图层合并。
yaml
图层 1: 页面背景 + 文字内容
图层 2: 固定导航栏(position: fixed)
图层 3: 那个正在做动画的弹窗
为什么要分层?弹窗在动,只需要移动它所在的图层,其他图层纹丝不动。 不分层的话,弹窗每动一帧,整个页面都要重新 Paint。
哪些属性只走 Composite?
ts
// transform 和 opacity 是合成层的 VIP
// ❌ 用 left/top 做动画 → 每帧都触发 Layout + Paint
el.style.left = x + 'px' // Layout → Paint → Composite
// ✅ 用 transform 做动画 → 只触发 Composite
el.style.transform = `translateX(${x}px)` // 直接 Composite,GPU 搞定
// 一个是坐绿皮火车,一个是坐高铁,目的地一样,体验天差地别
所有性能优化指南都在喊"用 transform 代替 left/top",不是玄学,是因为走了完全不同的管线路径。
主动提升合成层
css
/* 告诉浏览器:这个元素将来会变,提前给它一个独立图层 */
.animate-target {
will-change: transform;
}
/* 经典 hack(不推荐,但你一定见过) */
.force-layer {
transform: translateZ(0); /* 骗浏览器创建独立合成层 */
}
但图层不是免费的------每个图层都需要显存。滥用反而更卡。
七、为什么浏览器不把所有元素都放到独立图层?
显存是有限的。
一个 1920x1080 的图层,32 位色深,需要 1920 × 1080 × 4 bytes ≈ 8MB。50 个元素各搞一个图层?400MB 显存没了。手机用户直接白屏。
这就是 Layer Explosion(图层爆炸):
css
/* ❌ 千万别这么干 */
* {
will-change: transform; /* 内存爆炸 */
}
/* ✅ 只给真正需要动画的元素提升图层 */
.modal-overlay {
will-change: opacity;
}
.slider-track {
will-change: transform;
}
浏览器的策略:默认最少图层,只在有明确理由时才分层。经典的空间换时间------多用一点显存,省掉大量重绘开销。但空间有上限,不能滥用。
八、一帧 16.6ms 内的完整时间线
屏幕刷新率 60fps,每帧预算 16.6ms:
css
[JS 执行] → [Style 计算] → [Layout] → [Paint] → [Composite] → 🖥️
↑ 你的代码跑在这里
如果你的 JS 跑了 15ms,留给渲染管线的只有 1.6ms
大概率------掉帧了
requestAnimationFrame 的正确理解
ts
// rAF 的回调在"Style 计算"之前执行
// 这是浏览器给你的"最后修改机会"
// ❌ 用 setTimeout 做动画
setTimeout(() => {
el.style.transform = `translateX(${x}px)`
}, 16) // 16ms ≈ 一帧?不,时机完全不可控
// ✅ 用 rAF 做动画
requestAnimationFrame(() => {
el.style.transform = `translateX(${x}px)`
// 保证在下一帧渲染前执行,时机精确
})
setTimeout(fn, 16) 和 requestAnimationFrame(fn) 的区别不是精度问题------它们在事件循环中的执行时机完全不同。rAF 是被渲染管线调度的,setTimeout 是被任务队列调度的。
九、实战:一个列表滚动卡顿的排查
ts
// 场景:10000 行的虚拟列表,滚动时明显掉帧
// 打开 DevTools → Performance 面板 → 录制滚动
// 发现:每帧都有大面积紫色(Layout)
// ❌ 原因:滚动事件回调里读取了 offsetTop
window.addEventListener('scroll', () => {
items.forEach(item => {
const top = item.offsetTop // 强制同步布局!
item.style.display = top > threshold ? 'none' : 'block'
})
})
// ✅ 用 IntersectionObserver 代替手动计算可见性
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// visibility 不触发 Layout,比 display 便宜
entry.target.style.visibility = entry.isIntersecting ? 'visible' : 'hidden'
})
})
items.forEach(item => observer.observe(item))
从"卡成 PPT"到"丝滑如黄油",改的不是算法,改的是对渲染管线的理解。
十、边界与风险
这套心智模型什么时候会失效?
- 浏览器实现差异。上面说的主要是 Chromium(Blink 引擎)的行为。Chrome 的合成策略和 Safari 不同,Firefox 又是另一套。
- 浏览器的启发式优化。现代浏览器会自动做隐式合成层提升之类的优化,你以为没提升但其实浏览器偷偷帮你做了。
- CSS Containment 改变了规则 。
contain: layout可以限制 Layout 的影响范围,让局部修改不触发全局重排。
容易踩的坑:
will-change用完不清理,图层一直驻留显存- 以为
opacity: 0和display: none等价(前者仍在合成层中,仍然占空间) - 在
scroll事件里做同步 Layout 查询
十一、管线思维
浏览器渲染管线的本质是一条分阶段增量处理的流水线。这种模型到处都是:
- 编译器:词法分析 → 语法分析 → IR → 优化 → 代码生成
- 网络协议栈:物理层 → 数据链路层 → 网络层 → 传输层 → 应用层
- CI/CD:Lint → Test → Build → Deploy
共性:每一步只做一件事,输出是下一步的输入;优化的关键是跳过不必要的步骤。
浏览器渲染优化归结为一句话:
能走 Composite 就不走 Paint,能走 Paint 就不走 Layout。管线跑得越短,帧率越高。
下次看到页面卡顿,别对着代码发呆------打开 Performance 面板,看紫色(Layout)、绿色(Paint)、黄色(JS)各占多少,问题就在那里。
不是玄学,是物理。