"为什么我的页面那么慢?""为什么改了 CSS 后页面闪一下?""DOM 操作怎么优化?" —— 这些问题的答案都藏在"浏览器渲染流水线"里。这篇文章把浏览器从拿到 HTML 到屏幕显示像素的完整流程讲透,让你看懂 Chrome DevTools Performance 面板的每一个 stage。
渲染流水线总览
HTML -> DOM 树
CSS -> CSSOM 树
↓
Render 树(合并 DOM + CSSOM,只含可见元素)
↓
Layout(布局,算每个元素的位置和大小)
↓
Paint(绘制,把每个元素画到层上)
↓
Composite(合成,把所有层叠起来送显示器)
每一步都可能成为性能瓶颈。理解它们,你就知道为什么"改 width 比改 transform 慢"。
解析 HTML 与构建 DOM
浏览器收到 HTML,从上到下 streaming 解析,边收边建 DOM 树。遇到 <script>(无 async / defer):
- 暂停解析,下载并执行脚本。
- 脚本执行完,继续解析。
这就是为什么"把 script 放底部"或"用 defer / async" —— 避免脚本阻塞解析。defer 是"下载并行,执行延迟到 DOM 解析完",async 是"下载并行,下载完就立刻执行(可能打断解析)"。一般 defer 更安全。
解析 CSS 与 CSSOM
CSS 是render-blocking —— 在 CSSOM 没建好之前,浏览器不会渲染(防止"无样式闪烁")。所以:
- 关键 CSS 内联到
<head>。 - 非关键 CSS 用
<link rel="preload" as="style">异步加载。 - 用
media属性把不同情况的 CSS 标记成不阻塞(media="print"等)。
Layout:Reflow 重排
浏览器计算每个元素在视口里的几何位置和大小。一旦发生,所有受影响的子元素都要重新算。触发 Reflow 的常见操作:
// 改盒模型属性 -> Reflow
element.style.width = '300px';
element.style.padding = '10px';
// 改字体大小 -> Reflow(连父子元素都受影响)
element.style.fontSize = '20px';
// 添加 / 删除 DOM -> Reflow
parent.appendChild(child);
// 读取某些属性 -> 强制 Reflow!
const w = element.offsetWidth; // 浏览器必须立刻 Layout 才能返回值
// 写 + 读混合 -> 频繁强制 Reflow(性能噩梦)
for (let i = 0; i < 100; i++) {
items[i].style.width = items[i].offsetWidth + 10 + 'px';
}
// 这个循环触发 100 次 Reflow
// 优化:先读后写
const widths = items.map(i => i.offsetWidth);
items.forEach((item, i) => item.style.width = widths[i] + 10 + 'px');
// 只 1 次 Reflow
Paint:绘制
Layout 完成后,浏览器把每个元素的"视觉表现"画到栅格化的层(layer)上。颜色、边框、阴影、文字 都是 Paint 阶段做。
只改 Paint 不改 Layout 的属性(如 background-color、color、box-shadow)比改 Layout 快 —— 但还是涉及位图重画。
Composite:合成
页面可能有多个层(transform 元素、video、canvas、fixed 元素等),浏览器把它们按 z-index 叠起来送显示器。这一步是 GPU 做的,极快。
关键认知:仅触发 Composite 的属性最快:
transform:translate / scale / rotate。opacity。filter(部分情况)。
所以"动画用 transform 而不是 left/top"是性能金科玉律 —— transform 不触发 Layout 和 Paint,只触发 Composite。FPS 能稳到 60。
关键渲染路径优化
从用户敲下 URL 到第一次内容渲染(First Contentful Paint,FCP)的优化:
- 减少 HTML / CSS / JS 大小:gzip / brotli 压缩,删未用代码(tree-shaking)。
- 关键资源 preload:浏览器尽早发现并下载。
- CSS 内联:关键 CSS 直接
<style>在<head>,不等外部文件。 - JS defer / async:别让 JS 阻塞解析。
- 图片 lazy load:首屏外的图加
loading="lazy"。 - 用 HTTP/2 / HTTP/3:多路复用减少连接开销。
Core Web Vitals
Google 2020 提出的三大性能指标,SEO 和用户体验都看重:
- LCP(Largest Contentful Paint):最大内容元素渲染完成的时间。< 2.5s 优秀。
- FID(First Input Delay)/ INP(Interaction to Next Paint):用户首次交互到响应的延迟。2024 起 INP 替代 FID。< 200ms 优秀。
- CLS(Cumulative Layout Shift):累计布局偏移。< 0.1 优秀。
# 用 web-vitals 库测量
import { onCLS, onINP, onLCP } from 'web-vitals';
onCLS(console.log);
onINP(console.log);
onLCP(console.log);
# 上报到分析平台
function report(metric) {
fetch('/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
}
onLCP(report);
调试工具:Chrome DevTools
Performance 面板录一段,你能看到:
- Frames:每一帧的渲染时间(理想 16.6ms = 60 FPS)。
- Main:主线程做了什么(JS 执行、Layout、Paint)。
- Network:资源加载时序。
- Layout Shifts:CLS 的具体触发源。
Performance Insights 面板(新版)更友好,直接告诉你"哪段时间在做什么、可以怎么优化"。
真实优化案例
长列表卡顿
渲染 1 万条数据,DOM 节点数过多,Layout / Paint 慢。解决:虚拟滚动(只渲染视口内的几十个)。React 用 react-window,Vue 用 vue-virtual-scroller。
滚动卡顿
常见原因:scroll 监听器里做了 Reflow 触发操作。解决:用 requestAnimationFrame 节流,只在每一帧做一次。
首屏慢
SPA 首次加载白屏几秒。解决:SSR / SSG / 流式渲染(下一篇详谈)。或者用 <link rel="preconnect"> 加速关键域名连接。
合成层(Compositing Layers)
不是所有元素都在同一层渲染。下面这些会触发"独立层":
transform非 none,或will-change: transform。opacity不为 1。position: fixed / sticky。video/canvas/ WebGL。filter(部分)。
独立层的好处:它的动画 / 重绘不影响其他层,GPU 直接合成。代价:每个层占显存(尤其大尺寸)。把动画元素提到独立层是优化技巧,但不要给所有元素加 will-change —— 几千个层比一个大层还慢。
主线程的瓶颈
渲染流水线大部分都在主线程跑,JS、Layout、Paint 都抢同一个线程。一个长 JS 任务(几百 ms)会让页面卡住。
// 长任务示例:在主线程做 1 亿次循环
function heavy() {
let sum = 0;
for (let i = 0; i < 1e8; i++) sum += i;
return sum;
}
heavy(); // 主线程 1-2 秒,页面完全无响应
// 优化 1:任务分片
async function heavyChunked() {
let sum = 0;
for (let i = 0; i < 1e8; i++) {
sum += i;
if (i % 1e6 === 0) {
await new Promise(resolve => setTimeout(resolve, 0)); // 让出主线程
}
}
return sum;
}
// 优化 2:Web Worker
const worker = new Worker('heavy.js');
worker.postMessage({});
worker.onmessage = (e) => console.log(e.data);
// 计算在 Worker 线程,主线程不卡
requestAnimationFrame vs requestIdleCallback
// rAF:在下一帧渲染前执行 —— 适合视觉更新
requestAnimationFrame(() => {
element.style.transform = 'translateX(100px)';
});
// rIC:空闲时执行 —— 适合低优先级任务(数据上报、预加载)
requestIdleCallback(() => {
sendAnalytics(...);
}, { timeout: 2000 });
写在最后
浏览器渲染机制看似底层,但理解它能让你的前端代码从"能跑"升级到"跑得快"。每次卡顿的根源都能追溯到"哪一步触发了过多 Layout / Paint"。掌握这个模型,你看 DevTools 报告不再是猜,而是定向修复。下一篇我们看 V8 引擎是怎么让 JS 跑得这么快的。
一图看懂
浏览器渲染流水线一图看懂:
—— 别看了 · 2026