页面渲染原理
用户在地址栏输入网址后,浏览器要经过网络请求、解析、布局、绘制等多个阶段才能把页面呈现在屏幕上。理解页面的渲染过程,是写出流畅、高性能前端应用的基础。本文从关键渲染路径讲起,逐步深入到多进程架构、重排重绘、合成与层,最后介绍衡量页面性能的核心指标。
一、关键渲染路径(Critical Rendering Path)
关键渲染路径指浏览器从拿到 HTML、CSS、JavaScript 字节数据到绘制出第一帧像素的完整过程,可以拆成六个环节:
HTML 解析 → CSSOM 构建 → 渲染树构建 → 布局 → 绘制 → 合成1. HTML 解析生成 DOM 树
浏览器逐字节读取 HTML,把标签解析成节点,生成 DOM(Document Object Model)树。DOM 树描述了页面的结构:每个 HTML 元素是一个节点,父子关系对应标签的嵌套关系。
<!-- 以下 HTML -->
<ul>
<li>苹果</li>
<li>香蕉</li>
</ul>解析后形成一棵树:根节点 document 下有 html,html 下有 body 与 head,body 下的 ul 有两个 li 子节点。HTML 解析过程中一旦遇到 <script> 标签(非异步),解析会暂停,先下载并执行脚本,再继续解析后续 HTML。
2. CSS 解析生成 CSSOM
CSS 会被解析成 CSSOM(CSS Object Model),描述每个元素应该应用哪些样式。CSSOM 的构建具有继承与层叠特性:某个属性会继承自父元素,同一条规则可能被多条选择器命中,需要按优先级计算最终值。
p { font-size: 16px; }
.container p { color: red; } /* 优先级更高 */CSS 解析是阻塞渲染的:没有构建完 CSSOM 之前,浏览器不会绘制任何像素,这是为了避免"样式闪动"(无样式内容闪烁,FOUC)。
3. 渲染树构建
DOM 树与 CSSOM 合并成渲染树(Render Tree)。渲染树只包含可见元素:
| 不进入渲染树的元素 | 原因 |
|---|---|
<head> 及其子元素 | 不产生任何可见内容 |
display: none 的元素 | 不占空间、不可见 |
visibility: hidden 的元素 | 仍在渲染树中,只是不可见 |
/* 该元素不会出现在渲染树中 */
.hidden { display: none; }
/* 该元素仍在渲染树中,只是看不见 */
.invisible { visibility: hidden; }4. 布局(Layout / Reflow)
布局阶段计算每个可见元素在视口中的几何信息:位置(x、y)与尺寸(宽、高)。元素的宽高依赖其内容、父元素尺寸、字体大小等,因此一个元素的尺寸变化可能连锁影响整棵树的几何计算。
5. 绘制(Paint)
绘制阶段把每个元素转换为实际的像素指令,例如先画背景、再画文字、最后画边框。绘制通常按图层进行,页面会被划分成多个绘制层,避免一帧内全量重绘。
6. 合成(Composite)
合成器线程把各个图层按正确的层叠顺序拼接成最终画面,交给 GPU 呈现。合成是渲染管线中最"廉价"的一步,因为合成层的移动与透明变化不需要重新布局和绘制,这是 transform 与 opacity 动画性能优越的根本原因。
二、浏览器多进程架构
现代浏览器普遍采用多进程架构,不同职责的模块运行在不同进程中,彼此隔离,一个进程崩溃不会拖垮整个浏览器。
进程划分
| 进程 | 职责 |
|---|---|
| 浏览器进程 | 管理地址栏、书签、网络请求(统一调度)、各渲染进程的生命周期 |
| 渲染进程 | 每个标签页一个(或几个),负责解析、布局、绘制、JavaScript 执行 |
| GPU 进程 | 负责光栅化与图层合成,把绘制指令输出到屏幕 |
| 网络进程 | 处理网络请求,加载资源(新版浏览器合并进浏览器进程) |
| 插件进程 | 承载 Flash 等插件,已逐渐退出历史舞台 |
渲染进程内部的线程
渲染进程内部又划分为多个线程,各司其职:
| 线程 | 职责 | 备注 |
|---|---|---|
| 主线程 | 解析 HTML/CSS、执行 JavaScript、布局、绘制 | 同一时刻只能做一件事,长任务会阻塞交互 |
| 合成器线程 | 监听输入事件、执行合成、滚动 | 与主线程并行,滚动通常不阻塞主线程 |
| 栅格线程(池) | 把图层光栅化为位图块 | 可多线程并行 |
| Worker 线程 | 运行 Web Worker / Service Worker | 与主线程并行,不能操作 DOM |
主线程的忙碌会直接表现为页面卡顿:点击无响应、动画掉帧。而合成器线程专门处理滚动、缩放这类高频操作,保证即使用户在主线程繁忙时也能流畅滚动页面。
三、重排(Reflow)与重绘(Repaint)
概念对比
| 对比项 | 重排(Reflow / Layout) | 重绘(Repaint / Paint) |
|---|---|---|
| 做了什么 | 重新计算元素的几何信息(位置、尺寸) | 重新绘制元素的外观(颜色、背景、阴影) |
| 影响范围 | 可能波及后代、祖先甚至整页 | 通常只涉及单个图层 |
| 代价 | 高(触发后必然伴随重绘) | 相对较低 |
| 两者关系 | 重排必然引起重绘 | 重绘不一定引起重排 |
触发重排的操作
// 以下操作都会触发重排
const box = document.getElementById("box");
// 1. 修改几何属性
box.style.width = "200px";
box.style.height = "100px";
box.style.marginTop = "20px";
// 2. 添加/删除/移动 DOM 节点
document.body.appendChild(box.cloneNode(true));
// 3. 修改类名、切换样式
box.className = "large";
// 4. 读取几何信息(读取本身不触发,但会"强制刷新"排队中的修改)
const w = box.offsetWidth;
const h = box.clientHeight;
const rect = box.getBoundingClientRect();第 4 类最容易踩坑:浏览器为了性能会把连续的样式修改批量合并到一次重排,但一旦代码去读取几何属性(如 offsetWidth),浏览器必须立刻执行排队中的重排以返回准确值,这就是强制同步布局(Forced Synchronous Layout),频繁读写交替会让性能急剧下降。
触发重绘的操作
只改变外观而不改变几何的属性,如 color、background-color、box-shadow、visibility、outline 等,只触发重绘。
四、合成(Composite)与层(Layer)
什么是合成层
浏览器会把页面划分成多个图层。默认所有元素共用一个图层;当某些元素满足特定条件时,会被提升为独立的合成层,由 GPU 单独处理。
如何创建合成层
| 方式 | 说明 |
|---|---|
will-change: transform | 显式告知浏览器该元素会变化,提前提升为合成层 |
transform / opacity 动画 | 动画期间浏览器自动提升为合成层 |
固定定位元素 position: fixed | 常被单独分层 |
video / canvas / iframe | 本身是独立渲染单元 |
拥有 overflow: scroll 的滚动容器 | 需要独立滚动 |
.card {
/* 提示浏览器为动画做准备,但不要滥用(每个层都占用 GPU 内存) */
will-change: transform;
transition: transform 0.3s ease;
}为什么 transform 动画不卡
对合成层执行 transform: translateX(...) 时,合成器线程只需移动纹理,不需要主线程重新布局和绘制:
修改 transform → 跳过布局与绘制 → 仅合成 → GPU 移动纹理
修改 left → 触发布局 → 触发绘制 → 合成 → GPU 重绘整个区域同样的移动动画,transform 全程流畅不掉帧,left 则可能因每一帧都要重排而卡顿。
五、如何减少重排与重绘
1. 批量修改 DOM
把多次插入合并成一次,减少触发次数:
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = "条目 " + i;
fragment.appendChild(li);
}
list.appendChild(fragment); // 只触发一次重排2. 读写分离
先集中读取几何信息,再集中修改样式,避免读写交替造成强制同步布局:
// 坏的写法:读写交替,每次读都强制刷新
for (let i = 0; i < items.length; i++) {
const w = items[i].offsetWidth; // 读
items[i].style.width = w + "px"; // 写
}
// 好的写法:先全部读完,再统一写
const widths = items.map((el) => el.offsetWidth);
widths.forEach((w, i) => {
items[i].style.width = w + "px";
});3. 用 transform 替代 top/left
/* 差:每次修改都触发重排 */
.box { position: absolute; left: 0; }
/* 好:只触发合成 */
.box { transform: translateX(0); will-change: transform; }4. 合并样式修改
// 差:多次写样式,多次重排
el.style.width = "100px";
el.style.height = "50px";
el.style.margin = "10px";
// 好:一次改完,浏览器合并处理
el.style.cssText = "width: 100px; height: 50px; margin: 10px;";
// 或使用类名切换,交给 CSS 一次处理
el.className = "new-style";5. 其他手段
- 把频繁变化的元素提升为合成层(
will-change),但控制层数量,避免 GPU 内存耗尽。 - 用
display: none隐藏元素后做批量修改,再显示回来(隐藏期间不参与布局)。 - 动画元素尽量不触发布局:避免在动画帧中读取
offsetWidth等属性。 - 图片提前指定宽高,避免加载后导致布局抖动(这也是 CLS 的重要来源)。
六、性能指标与 Core Web Vitals
Google 提出的 Core Web Vitals 是衡量用户体验的核心指标集合,主要包含 LCP、INP、CLS 三项,另有 TTFB、FCP 等辅助指标。
| 指标 | 全称 | 衡量内容 | 良好阈值 |
|---|---|---|---|
| TTFB | Time To First Byte | 浏览器收到服务器第一个字节的时间 | 小于 800ms |
| FCP | First Contentful Paint | 首次绘制出任何内容(文字、图片、画布)的时间 | 小于 1.8s |
| LCP | Largest Contentful Paint | 最大内容元素(通常是首屏大图或标题)绘制完成的时间 | 小于 2.5s |
| CLS | Cumulative Layout Shift | 页面加载过程中布局发生意外偏移的总量 | 小于 0.1 |
| INP | Interaction to Next Paint | 用户交互(点击、按键)到界面响应(下一帧绘制)的延迟 | 小于 200ms |
各指标的关注点
- TTFB 主要由服务器响应速度与网络决定,受 DNS 解析、TLS 握手、后端处理耗时影响。
- FCP 与渲染阻塞资源的多少直接相关,脚本与 CSS 越多越慢。
- LCP 关注最大元素的加载速度,常见优化是给图片预加载、使用 CDN、压缩图片体积。
- CLS 的常见诱因是图片无固定宽高、动态插入内容(如广告位)、web 字体加载导致文字跳动。给图片加
width/height属性或aspect-ratio即可大幅改善。 - INP 关注交互延迟,主线程长任务、事件处理函数过重都会推高它。
如何测量
// 使用 web-vitals 库(国内环境可从 npm 安装)
import { onLCP, onCLS, onINP } from "web-vitals";
onLCP(console.log);
onCLS(console.log);
onINP(console.log);也可以在 Chrome DevTools 的 Performance 面板录制页面加载过程,或在 Lighthouse 面板一键生成包含所有指标的报告。
七、阻塞渲染的资源(Render-Blocking)
哪些资源会阻塞渲染
| 资源 | 是否阻塞渲染 | 说明 |
|---|---|---|
同步 <script>(无 defer/async) | 是 | 阻塞 HTML 解析,也阻塞渲染 |
普通 <link> CSS | 是 | CSSOM 未构建完不绘制 |
defer 脚本 | 否 | HTML 解析完才执行,但保证顺序 |
async 脚本 | 否 | 下载完立即执行,不保证顺序 |
<img> 等资源 | 否 | 不阻塞解析,但影响 LCP 与 CLS |
preload / prefetch 资源 | 否 | 提前下载,不阻塞 |
脚本加载方式对比
| 加载方式 | 是否阻塞解析 | 执行时机 | 顺序保证 | 适用场景 |
|---|---|---|---|---|
| 同步加载 | 是 | 解析到即执行 | 有 | 影响首屏的初始化脚本 |
defer | 否 | DOM 解析完成后 | 有 | 页面逻辑脚本(推荐) |
async | 否 | 下载完成后 | 无 | 独立第三方统计脚本 |
<script src="app.js" defer></script>
<script src="analytics.js" async></script>常用优化手段
- CSS 放在
<head>、JS 用defer/async并放在<body>末尾。 - 首屏不需要的 CSS 用媒体查询拆分:
<link rel="stylesheet" href="print.css" media="print">,非匹配媒体不阻塞渲染。 - 关键资源用
<link rel="preload">提前下载,跨域资源用preconnect/dns-prefetch提前建立连接。 - 内联首屏关键 CSS(Critical CSS),其余 CSS 异步加载,缩短 FCP。
<link rel="preload" href="hero.jpg" as="image">
<link rel="preconnect" href="https://cdn.example.com">掌握渲染原理后,性能优化的方向就非常清晰:减少阻塞资源、减少重排重绘、把动画交给合成器线程处理,并始终用 Core Web Vitals 验证优化效果。