浏览器渲染原理
以 Chromium 为主,讲清从 HTML/CSS 到屏幕像素的主链路:解析、样式计算、布局、绘制、合成,以及回流与重绘的触发条件和可复现的验证方式。
一、主链路:从 URL 到像素
一次导航大致经过:
- 网络:DNS → TCP/TLS → HTTP 响应,返回 HTML 字节流。
- 解析 HTML:增量解析,边下载边构建 DOM,不必等整个文档到达。
- 遇到 CSS 时并行下载并构建 CSSOM;遇到脚本时按
defer/async规则处理。 - 样式计算(Recalculate Style):把 CSS 规则匹配到 DOM 节点,算出每个节点的最终样式。
- 生成布局树(Layout Tree,旧称 Render Tree):只包含会占据空间的节点。
- 布局(Layout / Reflow):计算每个节点的几何位置与尺寸。
- 绘制(Paint):生成绘制指令列表(display list),此时还没有像素。
- 栅格化(Raster)与合成(Composite):合成线程把图层切成图块交给 GPU 栅格化,再合成上屏。
关键认识:DOM 树 ≠ 布局树 ≠ 屏幕。display: none 的节点在 DOM 里但不在布局树里;伪元素不在 DOM 里却在布局树里。
二、解析阶段的三个阻塞点
| 资源 | 阻塞 HTML 解析 | 阻塞首次渲染 | 说明 |
|---|---|---|---|
| CSS | 否(并行下载) | 是 | 要等 CSSOM 完成才敢首次绘制,避免闪一下无样式内容 |
<script>(无属性) |
是 | 是 | 暂停解析、下载、执行,执行完才继续 |
<script defer> |
否 | 否 | 并行下载,DOM 解析完后按文档顺序执行,DOMContentLoaded 之前 |
<script async> |
否 | 否 | 下载完立即执行,可能打断解析,顺序不保证 |
两个容易记反的点:
- CSS 不阻塞 DOM 解析,但会阻塞它后面的 JS 执行(JS 可能要读样式),从而间接卡住依赖该脚本的解析流程。
defer保证顺序、async不保证;依赖 DOM 或有先后关系的脚本用defer。
三、Layout → Paint → Composite 的职责边界
| 阶段 | 输入 → 输出 | 典型触发 |
|---|---|---|
| Layout(回流) | 布局树 → 每个盒子的坐标与尺寸 | 改宽高、字体、增删节点、窗口 resize、读取几何属性 |
| Paint(重绘) | 布局树 → 绘制指令列表 | 改颜色、背景、阴影、边框样式等不影响几何的属性 |
| Composite(合成) | 图层 → 屏幕 | transform、opacity 在已提升的合成层上做动画 |
三者是递进关系:回流通常伴随重绘,重绘不一定回流,纯合成变化可以两者都不触发。
四、回流、重绘与强制同步布局
会触发回流的常见操作:
- 几何属性:
width/height/padding/margin/top/font-size/display - DOM 结构变化:插入、删除、移动节点
- 读取几何信息:
offsetTop/offsetHeight/scrollTop/getBoundingClientRect()/getComputedStyle()
最后一项最容易被忽略。浏览器会把样式变更攒着批量布局,一旦在写之后立刻读几何值,就被迫立即同步布局:
// 反例:读写交替,n 次循环触发 n 次布局(Layout Thrashing)for (const el of items) { el.style.width = el.offsetWidth + 10 + 'px';}// 正例:先批量读,再批量写const widths = items.map(el => el.offsetWidth);items.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });五、合成层、帧与事件循环
满足条件的元素会被提升到独立合成层,常见的有:3D transform、will-change: transform 或 will-change: opacity、position: fixed、<video> / <canvas>、正在进行 transform 或 opacity 动画。
提升后,只改 transform / opacity 可以跳过 Layout 与 Paint,直接由合成线程处理 —— 这是这类动画流畅的主要原因。代价是每层都占显存,will-change 长期挂着会浪费资源,动画结束应移除。
帧预算:60Hz 屏幕约 16.67ms/帧。主线程要在这段时间里完成 JS、样式计算、布局、绘制;任何长任务超过预算就会掉帧。requestAnimationFrame 在每帧绘制前回调,适合逐帧动画;setTimeout 不与 VSync 对齐,容易抖动。
六、验证方式
不用背结论,用 DevTools 复现一遍:
- Performance 面板录制一次页面加载,Main 轨道能看到
Parse HTML→Recalculate Style→Layout→Paint→Composite Layers的先后;点Layout事件可看到触发布局的调用栈。 - Rendering 面板勾选
Paint flashing(重绘区域闪绿)与Layout Shift Regions,直观区分重绘和布局抖移。 - Layers 面板查看合成层与估算显存,验证
will-change是否真的提升了层。 - 强制同步布局复现:在 Console 执行上一节的反例,Performance 里会看到成排的
Layout,DevTools 也会给出 Forced reflow 警告。 - 线上观测:
new PerformanceObserver(list => { for (const e of list.getEntries()) console.log(e.name, e.startTime, e.duration);}).observe({ type: 'longtask', buffered: true });new PerformanceObserver(list => { for (const e of list.getEntries()) { if (!e.hadRecentInput) console.log('CLS', e.value); }}).observe({ type: 'layout-shift', buffered: true });七、关键渲染路径优化清单
按收益排序,只列能直接验证的:
- 减少关键资源:首屏用不到的 CSS/JS 拆出去;脚本加
defer/async。 - 减少关键资源字节数:压缩、tree shaking、移除未使用 CSS。
- 缩短关键路径长度:
<link rel="preload">提前发现字体与首屏 CSS;字体加font-display: swap。 - 图片 / iframe 懒加载,给
width/height或aspect-ratio占位,避免布局抖移。 - 动画优先用
transform/opacity,避免逐帧改top/left/width。 - 批量读写 DOM;复杂计算放到
requestIdleCallback或 Web Worker。
判断顺序:先在 Performance 里确认瓶颈在哪个阶段,再选优化项。没有测量就加
will-change,通常只是把问题从主线程挪到显存。