浏览器渲染原理
What — 是什么
浏览器将 HTML/CSS/JS 转换为像素显示在屏幕上,经历解析 → 布局 → 绘制 → 合成四个阶段。理解渲染流程是前端性能优化的基础。
浏览器多进程架构
现代浏览器采用多进程架构,渲染并非单一进程完成:
| 进程 | 职责 | 说明 |
|---|---|---|
| Browser 进程 | 主控 | 地址栏、书签、网络请求调度、子进程管理 |
| Renderer 进程 | 渲染 | 每个 Tab 一个(同站点可能合并),负责 DOM/CSSOM/布局/绘制 |
| GPU 进程 | 合成 | 接收各 Renderer 的图层数据,合成最终帧并显示 |
| Plugin 进程 | 插件 | 每个 NPAPI 插件一个独立进程(已逐步淘汰) |
| Network 进程 | 网络 | 独立的网络请求处理(Chrome 拆分自 Browser 进程) |
Renderer 进程内部多线程:
- 主线程:DOM 解析、样式计算、布局、绘制、JS 执行
- 合成线程:将图层分块(tile),交给 GPU 光栅化
- 光栅化线程池:将分块转换为位图
- IO 线程:处理进程间通信
完整渲染流程详解:从字节到像素
浏览器渲染一条完整的流水线:
网络响应(字节流)
↓
编码转换(字节 → 字符)
↓
词法分析(字符 → Token)
↓
树构建(Token → Node → DOM/CSSOM)
↓
渲染树构建(DOM + CSSOM → Render Tree)
↓
布局 / 回流(Render Tree → Layout Tree,计算几何信息)
↓
绘制(Layout Tree → Paint Records,生成绘制指令)
↓
合成(分块 → 光栅化 → GPU 合成 → 显示)
HTML 解析算法详解
HTML 解析分为两个阶段:Tokenization(标记化) 和 Tree Construction(树构建)。
1. Tokenization — 词法分析
将字符流转换为 Token 流,遵循 HTML5 规范的状态机算法:
<html><body>Hello</body></html>
字符流 → Token 流:
StartTag: html
StartTag: body
Character: Hello
EndTag: body
EndTag: html
状态机核心状态:Data → TagOpen → TagName → BeforeAttributeName → ... → Data
2. Tree Construction — 树构建
根据 Token 类型执行不同操作:
| Token 类型 | 操作 |
|---|---|
| StartTag | 创建元素节点,插入 DOM 树,压入开放元素栈 |
| EndTag | 弹出开放元素栈,完成当前节点 |
| Character | 创建文本节点,附加到当前节点 |
| Comment | 创建注释节点(不进入 DOM 树) |
// 简化版树构建逻辑
const openStack = [];
function handleToken(token) {
switch (token.type) {
case 'StartTag':
const node = createElement(token.tagName, token.attributes);
if (openStack.length > 0) {
openStack[openStack.length - 1].appendChild(node);
}
openStack.push(node);
break;
case 'EndTag':
openStack.pop();
break;
case 'Character':
const textNode = createTextNode(token.data);
openStack[openStack.length - 1].appendChild(textNode);
break;
}
}
解析中的容错机制:
- 遇到
<p>嵌套时自动关闭外层<p> - 缺少闭合标签时自动补全
- 表格元素自动修正结构(
<table>内非法元素移到表外) </br>转换为<br>
CSS 解析过程和 CSSOM 构建
CSS 解析将样式表转换为 CSSOM(CSS Object Model):
1. CSS 解析流程
CSS 字节流 → 字符 → Token → 规则(Rule)→ CSSOM 树
CSS 解析不像 HTML 那样容错,语法错误的规则会被直接忽略:
/* 这条规则会被忽略(属性值无效) */
.foo { color: invalid-color; }
/* 这条规则正常生效 */
.foo { color: red; }
2. CSSOM 树结构
CSSOM 是一棵带有选择器优先级和层叠规则的树:
CSSOM:
body { font-size: 16px; }
├─ p { color: #333; }
│ └─ p.highlight { color: #e00; font-weight: bold; }
└─ .sidebar { display: none; }
3. 样式计算(Computed Style)
浏览器将 CSSOM 中的规则与 DOM 节点匹配,计算每个节点的最终样式值:
- 选择器匹配:从右到左匹配选择器(效率关键)
- 层叠计算:按
!important > inline > id > class > element优先级 - 继承:font/color 等属性从父元素继承
- 默认值:未指定的属性使用初始值
- 值标准化:
em→px、color: red→rgb(255,0,0)
// 查看元素的计算样式
const el = document.querySelector('.my-element');
const computed = getComputedStyle(el);
console.log(computed.fontSize); // "16px"(已标准化)
console.log(computed.color); // "rgb(51, 51, 51)"(已标准化)
渲染树与 DOM 树的区别
渲染树(Render Tree)是 DOM + CSSOM 合并后的结果,只包含需要显示的节点:
| 元素 | DOM 树 | 渲染树 | 原因 |
|---|---|---|---|
<head> | 存在 | 不存在 | 不可见元素 |
display: none | 存在 | 不存在 | 完全不渲染,不占空间 |
visibility: hidden | 存在 | 存在 | 占据空间,仅不可见 |
::before/::after | 不存在 | 存在 | 伪元素由 CSS 生成,参与渲染 |
opacity: 0 | 存在 | 存在 | 占据空间,仅透明 |
content-visibility: hidden | 存在 | 存在(子树跳过) | 跳过子树布局和绘制 |
<div style="display:none">我不在渲染树中</div>
<div style="visibility:hidden">我在渲染树中,但不可见</div>
<div class="has-pseudo">我有伪元素
<!-- ::before 在 DOM 中不存在,但在渲染树中存在 -->
</div>
<style>
.has-pseudo::before { content: "前缀"; color: red; }
</style>
布局的详细过程(盒模型计算)
布局阶段遍历渲染树,计算每个节点的精确位置和大小:
1. 布局输入与输出
- 输入:渲染树 + 视口(Viewport)尺寸
- 输出:Layout Tree(每个节点带有
x, y, width, height等几何信息)
2. 盒模型计算
┌─────────────────────────────────────────┐
│ margin │
│ ┌─────────────────────────────────┐ │
│ │ border │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ padding │ │ │
│ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ content │ │ │ │
│ │ │ │ (width×height) │ │ │ │
│ │ │ └─────────────────┘ │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
/* box-sizing 决定 width 包含哪些部分 */
.box-standard {
box-sizing: content-box; /* 默认:width 只含 content */
width: 100px; /* 实际占位 = 100 + 20 + 20 + 10 + 10 = 160px */
padding: 20px;
border: 10px solid;
}
.box-border {
box-sizing: border-box; /* width 包含 content + padding + border */
width: 100px; /* content = 100 - 20 - 20 - 10 - 10 = 40px */
padding: 20px;
border: 10px solid;
}
3. 布局算法要点
- 从外到内:先计算根节点(viewport 宽度),再逐级向下
- BFC(块格式化上下文):独立布局区域,内部布局不影响外部
- IFC(行内格式化上下文):行内元素水平排列
- Flex/Grid:按各自算法分配空间
- 百分比/自动值:依赖父容器约束求解
4. 布局树与渲染树的关系
布局阶段会进一步处理渲染树:
- 匿名块盒生成(inline 元素旁的 block 元素会创建匿名块盒)
- 浮动定位计算
- 绝对/固定定位脱离文档流
绘制阶段的绘制记录和绘制顺序
绘制阶段将布局树转换为一系列绘制指令(Paint Records):
1. 绘制记录
绘制记录是一个有序的操作列表,类似 Canvas API 调用:
// 绘制记录示例(概念性展示)
[
{ action: 'drawRect', x: 0, y: 0, width: 1440, height: 900, color: '#fff' },
{ action: 'drawText', x: 100, y: 50, text: 'Hello', font: '16px sans-serif' },
{ action: 'drawImage', x: 200, y: 100, src: 'logo.png' },
{ action: 'drawBorder', x: 100, y: 40, width: 200, height: 30, style: '1px solid #ccc' },
]
2. 绘制顺序(CSS 绘制顺序规范)
同一元素的层叠绘制顺序(从底到顶):
- 背景色(background-color)
- 背景图(background-image)
- 边框(border)
- 子元素
- 轮廓(outline)
/* 层叠上下文(Stacking Context)决定了绘制顺序 */
.stacking-demo {
position: relative;
z-index: 1; /* 创建层叠上下文 */
background: white; /* 1. 先画背景 */
border: 2px solid; /* 3. 再画边框 */
outline: 3px solid red; /* 5. 最后画轮廓 */
}
.stacking-demo::after {
content: "";
position: absolute;
z-index: -1; /* z-index 负值在背景之上、内容之下 */
background: blue;
}
3. 分层(Layer)
绘制之前,浏览器会将页面分为多个图层(Layer),每个图层独立绘制:
- 根元素创建根图层
position: fixed创建新图层will-change、transform、opacity动画元素创建合成层overflow: hidden的裁剪创建新图层
合成层详解
合成层(Compositing Layer)是 GPU 独立处理的图层,修改时无需重新布局和绘制。
1. 显式创建合成层
/* 方式一:will-change */
.gpu-layer {
will-change: transform;
}
/* 方式二:3D transform */
.gpu-layer-3d {
transform: translateZ(0);
}
/* 方式三:硬件加速属性组合 */
.gpu-layer-combo {
position: fixed;
opacity: 0.9;
transform: scale(1.1);
}
2. 隐式提升条件
以下情况浏览器会自动将元素提升为合成层:
| 条件 | 说明 |
|---|---|
transform: translateZ(0) 或 translate3d() | 3D 变换触发硬件加速 |
will-change: transform/opacity | 明确提示浏览器该属性会变化 |
position: fixed | 固定定位在移动端通常提升 |
opacity 动画 / transform 动画 | CSS 动画中使用这两个属性 |
-webkit-overflow-scrolling: touch | 移动端弹性滚动 |
filter 属性 | 滤镜效果 |
backdrop-filter | 背景滤镜(如毛玻璃效果) |
| 被合成层覆盖 | 如果一个非合成层元素与合成层重叠,会被隐式提升 |
3. 隐式提升的风险
<!-- ⚠️ 隐式提升示例 -->
<style>
.composited { will-change: transform; } /* 合成层 */
.sibling { background: red; } /* 普通 */
</style>
<div class="composited">我是合成层</div>
<div class="sibling">我和合成层重叠,会被隐式提升!</div>
4. 合成层的利弊
| 优势 | 劣势 |
|---|---|
| 修改 transform/opacity 不触发布局和绘制 | 每个合成层占用额外 GPU 内存 |
| 动画流畅(60fps) | 过多合成层导致内存压力 |
| 独立光栅化,互不影响 | 隐式提升导致不可控的层爆炸 |
5. 检查合成层
在 Chrome DevTools 中:More tools → Layers 面板可查看所有图层。
事件循环与渲染的关系
浏览器的渲染与事件循环紧密配合,理解时机才能正确调度渲染。
1. 一帧中的执行顺序
宏任务(Task)
↓
微任务(Microtask)— 全部清空
↓
requestAnimationFrame 回调
↓
样式计算 → 布局 → 绘制 → 合成
↓
requestIdleCallback(如果帧内还有空闲时间)
2. requestAnimationFrame 的时机
requestAnimationFrame 在渲染前执行,是操作 DOM 的最佳时机:
// ❌ 在微任务中频繁操作 DOM,可能导致多次重排
Promise.resolve().then(() => {
element.style.width = '100px';
});
Promise.resolve().then(() => {
element.style.height = '200px';
});
// ✅ 在 rAF 中批量操作,只触发一次渲染
requestAnimationFrame(() => {
element.style.width = '100px';
element.style.height = '200px';
});
3. setTimeout vs rAF 的区别
| 特性 | setTimeout | requestAnimationFrame |
|---|---|---|
| 执行时机 | 宏任务队列,可能在渲染后 | 渲染前,紧跟微任务之后 |
| 帧率同步 | 不同步,可能跳帧 | 与显示器刷新率同步(通常 60fps) |
| 后台行为 | 继续执行(最小 1s) | 自动暂停,节省资源 |
| 适用场景 | 延迟执行、轮询 | 动画、DOM 操作、渲染调度 |
4. 渲染不一定每帧都发生
- 浏览器在需要时才渲染(DOM 变化、动画、用户交互)
- 连续的宏任务间如果没有 DOM 变更,浏览器会跳过渲染
requestAnimationFrame只有在下一次渲染前才会触发
// 没有渲染需求的 rAF 不会被调用
setTimeout(() => {
console.log('task 1');
// 没有 DOM 操作,浏览器可能跳过渲染
}, 0);
setTimeout(() => {
console.log('task 2');
document.body.style.background = 'red';
// 有 DOM 变更,浏览器会在 rAF 后渲染
}, 16);
核心概念总结
- DOM 树:HTML 解析生成,描述文档结构
- CSSOM 树:CSS 解析生成,描述样式规则
- 渲染树(Render Tree):DOM + CSSOM 合并,只包含可见元素
- 布局(Layout/Reflow):计算元素的位置和大小
- 绘制(Paint):将元素绘制到图层
- 合成(Composite):GPU 合成图层显示
关键特性:
- JS 会阻塞 DOM 解析(
<script>阻塞) - CSS 会阻塞渲染(需要 CSSOM 才能构建渲染树)
async/defer控制脚本加载时机transform/opacity只触发合成,不触发布局和绘制
Why — 为什么
适用场景:
- 前端性能优化
- 排查渲染卡顿
- 优化关键渲染路径
- 首屏加载速度提升
- 动画性能优化
- 大型列表/长页面滚动优化
关键渲染路径优化
| 优化点 | 影响 | 方法 |
|---|---|---|
| CSS 阻塞渲染 | CSSOM 未完成不渲染 | 关键 CSS 内联,非关键 CSS 异步 |
| JS 阻塞解析 | script 阻塞 DOM 构建 | defer/async/动态 import |
| 重排(Reflow) | 触发布局重计算 | 批量 DOM 操作、避免逐条读布局属性 |
| 重绘(Repaint) | 触发重新绘制 | 用 transform 替代 top/left |
| 关键资源数量 | 每个资源增加往返 | 合并资源、内联关键资源 |
| 关键路径长度 | 链式依赖增加延迟 | 减少串行依赖、预加载 |
| 资源体积 | 下载和解析耗时 | 压缩、Tree-shaking、代码分割 |
首屏性能优化方案
首屏渲染是用户体验的关键,核心指标:FCP(First Contentful Paint)、LCP(Largest Contentful Paint)。
1. 资源层面
| 策略 | 说明 | 效果 |
|---|---|---|
| 关键 CSS 内联 | 首屏所需 CSS 直接写在 <style> 中 | 消除 CSS 请求阻塞 |
| 非关键 CSS 异步 | 使用 preload + onload 切换 | 不阻塞首屏渲染 |
| JS 延迟执行 | defer 或动态 import() | 不阻塞 DOM 解析 |
| 资源预加载 | <link rel="preload"> | 提前下载关键资源 |
| DNS 预解析 | <link rel="preconnect"> | 减少 DNS + TLS 耗时 |
| 图片优化 | WebP/AVIF、响应式 srcset | 减少图片体积 |
| 字体优化 | font-display: swap | 避免字体阻塞文字渲染 |
2. 渲染层面
| 策略 | 说明 | 效果 |
|---|---|---|
| 减少关键资源 | 内联 / 合并 / 延迟 | 缩短关键路径长度 |
| 减少DOM节点 | 虚拟滚动、懒加载 | 减少布局和绘制时间 |
| CSS containment | contain 属性限制布局范围 | 避免大范围重排 |
| content-visibility | 跳过屏幕外元素渲染 | 大列表性能提升 |
| 避免强制同步布局 | 读写分离 | 减少重排次数 |
3. 传输层面
- HTTP/2 多路复用,减少连接开销
- Brotli 压缩(比 gzip 小 15-25%)
- CDN 就近分发,减少 RTT
- Service Worker 缓存,二次访问秒开
How — 怎么用
快速上手
关键渲染路径优化:
<head>
<!-- 关键 CSS 内联,首屏立即渲染 -->
<style>
body { margin: 0; font-family: sans-serif; }
.hero { min-height: 100vh; background: #f0f0f0; }
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="/styles/non-critical.css" as="style"
onload="this.rel='stylesheet'">
<!-- JS 不阻塞解析 -->
<script src="/app.js" defer></script>
</head>
代码示例
示例1:async vs defer 详细对比
<head>
<!-- 1. 普通 script:阻塞 DOM 解析,立即执行 -->
<script src="/blocking.js"></script>
<!-- 2. async:并行下载,下载完立即执行(阻塞解析) -->
<!-- 适用:独立脚本,不依赖 DOM(如统计、广告) -->
<script async src="/analytics.js"></script>
<!-- 3. defer:并行下载,DOM 解析完后按顺序执行 -->
<!-- 适用:需要操作 DOM 的脚本、有依赖关系的脚本 -->
<script defer src="/vendor.js"></script>
<script defer src="/app.js"></script>
<!-- vendor.js 一定在 app.js 之前执行 -->
</head>
| 特性 | 普通 <script> | async | defer |
|---|---|---|---|
| 下载时机 | 阻塞解析,下载完才继续 | 并行下载 | 并行下载 |
| 执行时机 | 下载完立即执行 | 下载完立即执行 | DOM 解析后、DOMContentLoaded 前 |
| 执行顺序 | 按文档顺序 | 谁先下载完谁先执行 | 按文档顺序 |
| DOM 访问 | 可能访问不到后续 DOM | 不保证 | 可以访问完整 DOM |
| 适用场景 | 必须立即执行的脚本 | 独立第三方脚本 | 主应用脚本 |
| 内联脚本 | 不支持 | 不支持 | 不支持 |
选择策略:
// 决策树:
// 1. 脚本需要操作 DOM? → defer
// 2. 脚本之间有依赖? → defer(保证顺序)
// 3. 独立第三方脚本? → async
// 4. 必须尽早执行? → 普通 script(放在 body 末尾)
示例2:关键 CSS 提取(critical 工具)
使用 critical npm 包自动提取首屏关键 CSS:
npm install critical --save-dev
// build 脚本中提取关键 CSS
const critical = require('critical');
critical.generate({
base: 'dist/',
src: 'index.html',
target: {
html: 'index-critical.html' // 内联关键 CSS 的 HTML
},
width: 1440, // 视口宽度
height: 900, // 视口高度
inline: true, // 将关键 CSS 内联到 HTML
extract: true, // 非关键 CSS 异步加载
}).then(() => {
console.log('关键 CSS 提取完成');
});
构建输出:
<!-- 处理后的 HTML -->
<head>
<!-- 关键 CSS 内联 -->
<style>
body{margin:0;font-family:sans-serif}
.hero{min-height:100vh;background:#f0f0f0}
.nav{display:flex;height:60px}
/* ... 首屏可见的样式 */
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="/styles/below-fold.css" as="style"
onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/below-fold.css"></noscript>
</head>
示例3:Resource Hints(preload/prefetch/preconnect)
<head>
<!-- preconnect:提前建立连接(DNS + TCP + TLS),不下载资源 -->
<!-- 适用:即将请求的第三方域名 -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- dns-prefetch:仅预解析 DNS(兼容性更好) -->
<link rel="dns-prefetch" href="https://stats.example.com">
<!-- preload:提前下载资源,当前页面必定使用 -->
<!-- 适用:首屏关键资源、字体 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/js/app.js" as="script">
<link rel="preload" href="/css/critical.css" as="style">
<!-- prefetch:空闲时下载资源,下一页面可能使用 -->
<!-- 适用:下一页面的路由资源 -->
<link rel="prefetch" href="/js/about-page.js" as="script">
<link rel="prefetch" href="/images/hero-next.jpg" as="image">
<!-- modulepreload:预加载 ES Module -->
<link rel="modulepreload" href="/js/utils.mjs">
</head>
| Resource Hint | 时机 | 用途 | 优先级 |
|---|---|---|---|
preconnect | 立即建立连接 | 第三方 API/CDN 域名 | 高 |
dns-prefetch | 仅 DNS 解析 | 低优先级域名 | 中 |
preload | 立即下载 | 当前页面必须资源 | 高 |
prefetch | 空闲时下载 | 下一页面可能需要 | 低 |
modulepreload | 立即下载+解析 | ES Module 依赖 | 高 |
示例4:requestAnimationFrame 调度渲染
// ❌ 用 setTimeout 做动画:不同步屏幕刷新率,可能掉帧
function animateBad() {
element.style.transform = `translateX(${x}px)`;
x += speed;
if (x < target) setTimeout(animateBad, 16); // 约 60fps,但不精确
}
// ✅ 用 rAF:与屏幕刷新率同步,浏览器优化渲染
function animateGood() {
element.style.transform = `translateX(${x}px)`;
x += speed;
if (x < target) requestAnimationFrame(animateGood);
}
requestAnimationFrame(animateGood);
// ✅ rAF 批量 DOM 更新
const pendingUpdates = [];
function scheduleUpdate(element, property, value) {
pendingUpdates.push({ element, property, value });
// 只注册一次 rAF
if (pendingUpdates.length === 1) {
requestAnimationFrame(() => {
// 一次性应用所有更新,只触发一次渲染
pendingUpdates.forEach(({ element, property, value }) => {
element.style[property] = value;
});
pendingUpdates.length = 0;
});
}
}
// 多次调用,只在下一帧统一渲染
scheduleUpdate(el1, 'width', '100px');
scheduleUpdate(el2, 'height', '200px');
scheduleUpdate(el3, 'transform', 'translateX(50px)');
示例5:虚拟滚动减少 DOM 节点
class VirtualScroller {
constructor(container, items, itemHeight = 40) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 2;
this.startIndex = 0;
// 创建可视区域容器
this.viewport = document.createElement('div');
this.viewport.style.height = `${items.length * itemHeight}px`;
this.viewport.style.position = 'relative';
this.container.style.overflow = 'auto';
this.container.appendChild(this.viewport);
// 渲染初始项
this.renderItems();
// 滚动监听(passive 避免阻塞滚动)
this.container.addEventListener('scroll', () => {
this.startIndex = Math.floor(this.container.scrollTop / itemHeight);
this.renderItems();
}, { passive: true });
}
renderItems() {
const fragment = document.createDocumentFragment();
const end = Math.min(this.startIndex + this.visibleCount, this.items.length);
for (let i = this.startIndex; i < end; i++) {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.top = `${i * this.itemHeight}px`;
div.style.height = `${this.itemHeight}px`;
div.textContent = this.items[i];
fragment.appendChild(div);
}
this.viewport.replaceChildren(fragment);
}
}
// 使用:10 万条数据,DOM 中只有几十个节点
const scroller = new VirtualScroller(
document.getElementById('list'),
Array.from({ length: 100000 }, (_, i) => `Item ${i}`),
40
);
示例6:CSS containment(contain 属性)优化
contain 属性告诉浏览器某个元素的布局/样式/绘制不影响外部,浏览器可以优化重排范围:
/* contain 属性值说明 */
.contain-demo {
/* layout:元素内部布局不影响外部 */
contain: layout;
/* paint:元素内部绘制不溢出(创建新图层) */
contain: paint;
/* size:元素尺寸不依赖子元素 */
contain: size;
/* style:CSS 计数器等不影响外部 */
contain: style;
/* inline-size:行内方向尺寸不依赖子元素 */
contain: inline-size;
/* strict = layout + paint + size + style(最严格) */
contain: strict;
/* content = layout + paint + style(推荐,不含 size) */
contain: content;
}
/* 实战:卡片列表 */
.card-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.card {
contain: content; /* 卡片内部变化不影响其他卡片 */
/* 等价于 contain: layout paint style */
}
/* 实战:侧边栏 */
.sidebar {
contain: layout paint; /* 侧边栏独立布局和绘制 */
width: 300px;
}
/* 实战:第三方组件隔离 */
.third-party-widget {
contain: strict; /* 最严格隔离,内部不影响外部 */
width: 300px;
height: 200px;
}
contain 各值的效果对比:
| contain 值 | 布局隔离 | 绘制隔离 | 尺寸独立 | 性能提升 | 安全性 |
|---|---|---|---|---|---|
layout | Yes | No | No | 中 | 低 |
paint | Yes | Yes | No | 高 | 中 |
size | No | No | Yes | 高 | 高(需手动指定尺寸) |
content | Yes | Yes | No | 高 | 中 |
strict | Yes | Yes | Yes | 最高 | 最高 |
示例7:content-visibility 优化大列表
content-visibility: auto 让浏览器跳过屏幕外元素的布局和绘制:
/* 基础用法 */
.long-list-item {
content-visibility: auto; /* 屏幕外自动跳过渲染 */
contain-intrinsic-size: 0 500px; /* 告知浏览器预计高度,避免滚动条跳动 */
}
/* 完整示例:长文章页面 */
.article-section {
content-visibility: auto;
contain-intrinsic-size: 0 800px; /* 宽度 0(不限制),高度 800px(预估) */
}
/* 隐藏时完全跳过渲染 */
.offscreen-panel {
content-visibility: hidden; /* 跳过子树布局和绘制,保留已渲染结果 */
}
/* 重新显示 */
.offscreen-panel.active {
content-visibility: visible;
}
<!-- 实战:大列表优化 -->
<style>
.list-container {
height: 600px;
overflow-y: auto;
}
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 60px;
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
</style>
<div class="list-container">
<!-- 10000 个列表项,只有可见的几十个会真正渲染 -->
<div class="list-item">Item 1</div>
<div class="list-item">Item 2</div>
<!-- ... 9996 more ... -->
<div class="list-item">Item 10000</div>
</div>
content-visibility 值说明:
| 值 | 行为 | 适用场景 |
|---|---|---|
visible | 正常渲染(默认) | — |
auto | 屏幕外自动跳过,进入视口时渲染 | 长列表、长文章分段 |
hidden | 跳过渲染且不保留缓存 | 完全隐藏的内容面板 |
性能对比:
// 没有content-visibility:10000个节点全部布局和绘制
// → 首次渲染可能需要 2000ms+
// 使用content-visibility: auto:只有 ~50 个可见节点渲染
// → 首次渲染可能只需 100ms
// 实际测试代码
performance.mark('render-start');
// ... 渲染列表 ...
requestAnimationFrame(() => {
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');
const measure = performance.getEntriesByName('render')[0];
console.log(`渲染耗时: ${measure.duration}ms`);
});
示例8:避免重排 — 批量 DOM 操作
// ❌ 每次修改触发重排
items.forEach(item => {
item.style.width = '100px'; // 触发重排
console.log(item.offsetHeight); // 强制同步布局!
});
// ✅ 批量修改,一次重排
const fragment = document.createDocumentFragment();
items.forEach(item => {
const clone = item.cloneNode(true);
clone.style.width = '100px';
fragment.appendChild(clone);
});
container.appendChild(fragment); // 只触发一次重排
更多避免重排的技巧:
// ✅ 读写分离:先批量读,再批量写
// ❌ 读写交替导致强制同步布局
elements.forEach(el => {
const height = el.offsetHeight; // 读 → 强制布局
el.style.height = height + 10 + 'px'; // 写 → 标记脏
});
// ✅ 先读完所有值,再统一写
const heights = elements.map(el => el.offsetHeight); // 一次性读
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px'; // 一次性写
});
// ✅ 使用 CSS 类切换代替逐条修改 style
element.classList.add('active'); // 一次重排
// 而不是
// element.style.width = '100px';
// element.style.height = '200px';
// element.style.background = 'red';
// ✅ 离线 DOM 操作
element.style.display = 'none'; // 移出渲染树
// ... 大量 DOM 操作 ...
element.style.display = ''; // 移回渲染树,只触发一次重排
示例9:GPU 合成层优化
/* ❌ 触发 layout + paint */
.animate-bad {
position: absolute;
transition: left 0.3s, top 0.3s;
}
/* ✅ 只触发 composite */
.animate-good {
will-change: transform;
transition: transform 0.3s;
}
/* ✅ 动画结束后移除 will-change */
.animate-on-demand {
transition: transform 0.3s;
}
// 动态控制 will-change
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto'; // 释放 GPU 内存
});
// ❌ 永远不要这样写
document.querySelectorAll('*').forEach(el => {
el.style.willChange = 'transform'; // 层爆炸!
});
触发重排/重绘/仅合成的属性对比:
| 属性 | 触发重排 | 触发重绘 | 仅合成 |
|---|---|---|---|
width/height/margin/padding | Yes | Yes | No |
display/position/float | Yes | Yes | No |
color/background/border-color | No | Yes | No |
visibility/outline | No | Yes | No |
transform | No | No | Yes |
opacity | No | No | Yes |
示例10:Performance API 测量
// 测量首屏关键指标
const [nav] = performance.getEntriesByType('navigation');
console.log('DNS 查询:', nav.domainLookupEnd - nav.domainLookupStart, 'ms');
console.log('TCP 连接:', nav.connectEnd - nav.connectStart, 'ms');
console.log('请求耗时:', nav.responseEnd - nav.requestStart, 'ms');
console.log('DOM 解析耗时:', nav.domInteractive - nav.responseEnd, 'ms');
console.log('首屏渲染 (FCP):', nav.domContentLoadedEventEnd - nav.startTime, 'ms');
console.log('页面完全加载:', nav.loadEventEnd - nav.startTime, 'ms');
// PerformanceObserver 监听 LCP/CLS/FID
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.startTime);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// 自定义性能标记
performance.mark('feature-start');
// ... 执行某功能 ...
performance.mark('feature-end');
performance.measure('feature-duration', 'feature-start', 'feature-end');
const [measure] = performance.getEntriesByName('feature-duration');
console.log('功能耗时:', measure.duration, 'ms');
// Element Timing API(测量特定元素渲染时间)
// <div elementtiming="hero-image"><img src="hero.jpg"></div>
const elementObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`元素 ${entry.identifier} 渲染于 ${entry.startTime}ms`);
}
});
elementObserver.observe({ type: 'element', buffered: true });
Performance 面板使用指南
Chrome DevTools 的 Performance 面板是分析渲染性能的核心工具:
1. 录制与分析
- 打开 DevTools → Performance 面板
- 点击 Record(圆点按钮)或
Ctrl+E - 操作页面(触发需要分析的行为)
- 停止录制,查看火焰图
2. 关键区域解读
| 区域 | 内容 | 关注点 |
|---|---|---|
| FPS | 帧率图表 | 低于 60fps 的红色区域 |
| CPU | CPU 占用 | 黄色(JS)/ 紫色(渲染)/ 绿色(绘制) |
| Net | 网络请求 | 资源加载时序 |
| Main | 主线程火焰图 | 长任务(红色三角标记) |
| Compositor | 合成线程活动 | 合成操作耗时 |
3. 常用分析操作
- 长任务定位:Main 区域中超过 50ms 的任务标红,点击查看详情
- 布局抖动:搜索紫色 Layout 块,频繁出现说明有强制同步布局
- 重绘分析:勾选 Screenshots 逐帧查看,找到异常绘制
- 图层分析:切到 Layers 面板查看合成层数量和内存占用
4. 渲染性能面板
在 Performance 录制时勾选:
- Paint flashing:绿色高亮正在重绘的区域
- Layout Shift Regions:蓝色高亮发生布局偏移的区域
- FPS Meter:实时帧率显示
Lighthouse 性能审计
Lighthouse 是自动化性能审计工具,提供可操作的优化建议:
1. 运行方式
# CLI 方式
npm install -g lighthouse
lighthouse https://example.com --output html --output-path ./report.html
# 只运行性能审计
lighthouse https://example.com --only-categories=performance
# Node.js 方式
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
async function runAudit() {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const options = {
logLevel: 'info',
output: 'json',
port: chrome.port,
};
const runnerResult = await lighthouse('https://example.com', options);
console.log('Performance Score:', runnerResult.lhr.categories.performance.score * 100);
await chrome.kill();
}
2. 核心指标与分数权重
| 指标 | 权重 | 含义 | 目标值 |
|---|---|---|---|
| FCP | 10% | 首次内容绘制 | < 1.8s |
| LCP | 25% | 最大内容绘制 | < 2.5s |
| TBT | 30% | 总阻塞时间 | < 200ms |
| CLS | 25% | 累积布局偏移 | < 0.1 |
| SI | 10% | 速度指数 | < 3.4s |
3. 常见优化建议
- “Eliminate render-blocking resources” — 移除阻塞渲染的资源
- “Properly size images” — 使用响应式图片
- “Remove unused CSS/JS” — 删除未使用代码
- “Serve static assets with an efficient cache policy” — 设置缓存头
- “Avoid enormous network payloads” — 减小资源体积
- “Minimize main-thread work” — 减少主线程工作
- “Avoid large layout shifts” — 避免布局偏移
常见问题与踩坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 首屏白屏时间长 | JS/CSS 阻塞渲染 | 关键 CSS 内联,JS defer/async |
| 滚动卡顿 | scroll 事件触发重排 | transform 替代 top/left,passive: true |
| 强制同步布局 | 读写布局属性交替 | 先批量读,再批量写 |
will-change 滥用 | 每个元素都创建合成层,内存暴增 | 只在动画前设置,动画后移除 |
| 字体闪烁(FOIT/FOUT) | 自定义字体加载延迟 | font-display: swap + preload |
| 布局偏移(CLS) | 图片/广告无固定尺寸 | 预留 aspect-ratio,避免动态插入内容 |
| 长列表卡顿 | DOM 节点过多 | 虚拟滚动 / content-visibility: auto |
| CSS 选择器性能差 | 后代选择器从右到左匹配 | 减少嵌套层级,优先使用 BEM 类名 |
@import 阻塞 | CSS 串行加载 | 使用 <link> 并行加载 |
| 第三方脚本影响 | 分析/广告脚本阻塞主线程 | async 加载 + Web Worker 隔离 |
最佳实践
- 关键 CSS 内联,非关键 CSS 异步
- JS 用
defer或动态import() - 动画只用
transform和opacity - 用 DevTools Performance 面板定位瓶颈
- 预加载关键资源(
preload/preconnect) - 大列表使用虚拟滚动或
content-visibility - 使用
contain属性限制重排范围 - 动画元素提前
will-change,结束后移除 requestAnimationFrame调度 DOM 操作- Lighthouse 审计定期检查性能回归
面试题
Q1: 浏览器的渲染流程是怎样的?
HTML 解析生成 DOM 树 → CSS 解析生成 CSSOM 树 → 两者合并为渲染树(仅包含可见元素)→ 布局(计算位置大小)→ 绘制(填充像素到图层)→ 合成(GPU 合成图层输出到屏幕)。JS 会阻塞 DOM 构建,CSS 会阻塞渲染。完整流程:网络字节流 → 字符 → Token → Node → DOM → 渲染树 → 布局树 → 绘制记录 → 分块光栅化 → GPU 合成。
Q2: 重排(Reflow)和重绘(Repaint)有什么区别?如何减少重排?
重排是元素几何属性(位置、大小)变化触发布局重新计算,代价高;重绘是外观属性(颜色、背景)变化仅触发重新绘制,代价较低。重排必定引起重绘,反之不一定。减少重排:批量 DOM 操作(DocumentFragment)、避免读写布局属性交替(读写分离)、用
transform替代top/left、离线 DOM 操作(display:none)、CSS 类切换代替逐条修改。
Q3: 什么是合成层(Composite Layer)?哪些属性只触发合成不触发重排重绘?
合成层是独立于主图层由 GPU 单独处理的图层,修改时只需重新合成,不影响其他图层。
transform和opacity只触发合成阶段,不触发布局和绘制,因此动画性能最优。可通过will-change提示浏览器提前创建合成层,但不要滥用以免内存暴增。
Q4: 什么是关键渲染路径?如何优化?
关键渲染路径是浏览器从接收 HTML 到首次渲染像素所经历的关键步骤。优化方法:关键 CSS 内联(减少阻塞)、非关键 CSS 异步加载、JS 使用
defer/async避免阻塞解析、减少关键资源数量和体积、优化关键路径长度(减少往返次数)、使用preload/preconnect预加载关键资源。
Q5: 浏览器的事件循环和渲染是什么关系?
一帧中的执行顺序:宏任务 → 微任务(全部清空)→
requestAnimationFrame→ 样式计算 → 布局 → 绘制 → 合成。渲染发生在微任务之后、下一个宏任务之前。requestAnimationFrame在渲染前执行,是操作 DOM 的最佳时机。浏览器不一定每帧都渲染,只有在 DOM 有变化或动画运行时才触发渲染。
Q6: async 和 defer 的区别是什么?怎么选择?
两者都是并行下载不阻塞解析。区别在执行时机:
async下载完立即执行(阻塞解析),执行顺序不确定;defer在 DOM 解析完成后、DOMContentLoaded前按文档顺序执行。选择策略:脚本操作 DOM 或有依赖关系 →defer;独立第三方脚本(统计/广告)→async;必须尽早执行的 → 普通script放 body 末尾。
Q7: 什么是合成层提升?什么操作会导致隐式提升?
浏览器将普通元素提升为 GPU 独立处理的合成层。显式触发:
will-change、transform: translateZ(0)、3D 变换、position: fixed。隐式提升:当一个普通元素与合成层重叠时,浏览器可能将其也提升为合成层以保证正确渲染,这叫隐式提升。风险:大量隐式提升导致”层爆炸”,GPU 内存暴增。应避免过多will-change,用 Chrome Layers 面板监控合成层数量。
Q8: CSS 的 contain 属性有什么用?
contain属性告诉浏览器某元素的变化不影响外部,浏览器可以限制重排/重绘的范围,提升性能。常用值:contain: content(等于layout + paint + style,推荐日常使用,内部变化不影响外部布局和绘制);contain: strict(加上size,需手动指定尺寸);contain: layout(仅布局隔离);contain: paint(仅绘制隔离,自动裁剪溢出)。适用场景:卡片列表、侧边栏、第三方组件隔离。搭配content-visibility: auto可进一步跳过屏幕外元素的渲染。
相关链接: