跳到主要内容

浏览器渲染原理

问题

请介绍浏览器的渲染原理,包括关键渲染路径、重排重绘等概念。

面试速答版

浏览器是怎么把 HTML/CSS/JS 渲染成页面的?什么是重排重绘? 关键渲染路径(CRP)一共 6 步:解析 HTML 出 DOM 树、解析 CSS 出 CSSOM 树、合并成 渲染树(不渲染的节点如 display:none<head> 不进树)、Layout 算几何信息、Paint 画到位图、Composite 由 GPU 合成图层上屏。

  • 重排(Reflow):影响几何信息时触发,如改宽高、字体、增删 DOM、读 offsetWidth/getBoundingClientRect——必须重新走 Layout、Paint、Composite,开销最大。
  • 重绘(Repaint):只改外观(颜色、visibilitybackground)时触发,跳过 Layout,但仍要 Paint。
  • 只合成transformopacity 这类只在合成层处理,不走 Layout 也不走 Paint,性能最好——这就是动画首选 transform 的原因。
  • 常见坑:JS 阻塞 HTML 解析;CSS 阻塞渲染(CSSOM 没好就不画);连续读写样式会触发强制同步布局,要把读和写分开批量做。

答案

浏览器渲染是将 HTML、CSS、JavaScript 转换为用户可见页面的过程。理解渲染原理对于性能优化至关重要。

渲染流程概览

关键渲染路径(Critical Rendering Path)

关键渲染路径是浏览器将 HTML、CSS 和 JavaScript 转换为屏幕上像素的步骤序列:

  1. 构建 DOM 树:解析 HTML,生成 DOM(Document Object Model)
  2. 构建 CSSOM 树:解析 CSS,生成 CSSOM(CSS Object Model)
  3. 构建渲染树:合并 DOM 和 CSSOM,生成 Render Tree
  4. 布局(Layout/Reflow):计算每个节点的几何信息(位置和大小)
  5. 绘制(Paint):将节点转换为屏幕上的实际像素
  6. 合成(Composite):将多个图层合成为最终画面

详细渲染步骤

1. 构建 DOM 树

  • 输入:从网络/磁盘/内存接收到的 HTML 字节流(通常是 UTF-8 编码的字节序列)
  • 输出DOM 树——以 document 为根、每个节点是 Node 对象的内存树形结构,记录标签名、属性、父子关系、文本内容

浏览器解析 HTML 文档,将标签转换为 DOM 节点,构建树形结构。

处理过程

字节(Bytes) → 字符(Characters) → 词法分析(Tokens) → 语法分析(Nodes) → DOM 树
  • 字节→字符:按 <meta charset> 或 HTTP 头指定的编码(通常 UTF-8)解码
  • 字符→Token:Tokenizer 按 HTML 规范识别开始标签、结束标签、属性、文本、注释等 Token
  • Token→节点:Tree Builder 根据 Token 创建 Element/Text/Comment 节点,按嵌套关系挂到父节点下
  • 边解析边发请求:遇到 <img> 并行请求图片;遇到 <link rel="stylesheet"> 并行下载 CSS;遇到没有 async/defer<script>暂停 HTML 解析直到脚本执行完
给下一步的交接

DOM 树只描述"文档的结构",不包含任何样式信息——元素长什么样,要等 CSSOM 构建完再合并。

// DOM 树的简化表示
interface DOMNode {
tagName: string;
attributes: Record<string, string>;
children: DOMNode[];
textContent?: string;
}

const domTree: DOMNode = {
tagName: 'html',
attributes: {},
children: [
{
tagName: 'head',
attributes: {},
children: [
{ tagName: 'title', attributes: {}, children: [], textContent: 'Page' }
]
},
{
tagName: 'body',
attributes: {},
children: [
{
tagName: 'div',
attributes: { class: 'container' },
children: [
{ tagName: 'h1', attributes: {}, children: [], textContent: 'Hello' }
]
}
]
}
]
};
HTML 解析特点
  • 增量解析:边下载边解析,不需要等待整个文档
  • 容错性强:自动修复不规范的 HTML
  • 可被阻塞:遇到 <script> 标签会暂停解析(除非 async/defer)
预加载扫描器(Preload Scanner)

主 HTML 解析器遇到同步 <script> 会暂停,但浏览器同时运行着一个独立的预加载扫描器——它会提前扫描后续的 HTML 文本,发现 <link><script><img> 等资源就立刻并行发起下载。这是 SSR 首屏优化的核心机制:关键资源必须写在原始 HTML 里(而不是 JS 动态插入)才能被扫描到。详见 Q16

2. 构建 CSSOM 树

  • 输入:所有来源的 CSS 规则——外部样式表(<link>)、内联样式块(<style>)、元素行内样式(style="")、浏览器默认样式(User Agent Stylesheet)
  • 输出CSSOM 树——类似 DOM 的树形结构,节点按选择器匹配的作用对象组织,每个节点携带经过继承、层叠、优先级计算后的完整属性值集合

浏览器解析所有 CSS(外部样式表、内联样式、行内样式),构建 CSSOM 树。

处理过程

  • 解析:把每条 CSS 规则拆成「选择器 + 声明块」,构建选择器索引便于后续匹配
  • 层叠计算:对每个作用对象按 Specificity(优先级)+ 来源权重 + 源码顺序决定哪条规则胜出
  • 继承colorfont-size 等可继承属性从父节点向下传递
  • 值解析:相对值转为绝对值——empxinherit → 实际颜色、rgba(...) → 数值四元组
CSS 阻塞渲染
  • CSS 阻塞渲染:浏览器必须等待 CSSOM 构建完成才能构建渲染树(因为后出现的规则可能覆盖前面的结果)
  • 不阻塞 DOM 解析:HTML 解析可以继续,只是渲染会等待
  • 优化建议:关键 CSS 内联、非关键 CSS 用 media 条件或 preload 异步加载
与上一步的关系

DOM 和 CSSOM 的构建是并行进行的,互不阻塞。但两者都要完整构建完才能合并——因为 CSS 是"自上而下层叠",任意一条后来的规则都可能覆盖前面的计算结果。

3. 构建渲染树(Render Tree)

  • 输入:上两步产出的 DOM 树 + CSSOM 树
  • 输出渲染树(Chromium 内部叫 Layout Tree / RenderObject Tree)——只包含需要渲染的可见节点,每个节点同时带有"DOM 结构信息 + 计算后样式(Computed Style)"

合并 DOM 树和 CSSOM 树,生成渲染树。渲染树只包含可见内容

处理过程

  • 从 DOM 根节点开始遍历
  • 对每个节点在 CSSOM 中查找匹配的样式规则(选择器匹配通常从右向左),得到最终 Computed Style
  • 跳过不渲染的节点<head><script><meta>display: none 的元素(它们在 DOM 中存在,但不在渲染树中)
  • 保留占位节点visibility: hiddenopacity: 0 的元素仍在渲染树里,因为它们仍占布局空间
  • 加入伪元素节点::before::after 不在 DOM 中,但会以"匿名节点"形式并入渲染树

不会出现在渲染树中的元素

元素类型示例原因
display: none隐藏元素不占据空间
<head> 内元素<meta>, <script>非可视内容
<script>脚本标签非可视内容
visibility: hidden会出现占据空间,只是不可见
opacity: 0会出现占据空间,透明
与上一步的关系

DOM 描述"结构"、CSSOM 描述"规则",两者单独都不知道"实际要画什么"。渲染树是第一次把两者融合,回答了"页面上到底有哪些东西、每个东西长什么样"。结果:后续的 Layout/Paint 阶段只关心渲染树,不再回头读 DOM 或 CSSOM。

样式重新计算(Recalculate Style)

Chrome Performance 面板中经常看到一段独立的 Recalculate Style(紫色块),指的就是"为每个元素匹配 CSS 规则、计算 Computed Style"的过程,是渲染树构建的核心子步骤。浏览器用 Bloom Filter 和选择器分桶优化匹配速度,但深嵌套选择器、通用选择器(*)仍然昂贵。选择器是从右向左匹配的(而不是从左向右),这背后也是性能考虑。详见 Q13Q14

// 渲染树节点
interface RenderObject {
domNode: DOMNode;
computedStyle: CSSStyleDeclaration;
children: RenderObject[];
}

// 构建渲染树的简化逻辑
function buildRenderTree(domNode: DOMNode, cssom: CSSOM): RenderObject | null {
const style = cssom.getComputedStyle(domNode);

// display: none 的元素不进入渲染树
if (style.display === 'none') {
return null;
}

// head、script 等不可见元素跳过
if (['head', 'script', 'meta', 'link'].includes(domNode.tagName)) {
return null;
}

const renderObject: RenderObject = {
domNode,
computedStyle: style,
children: []
};

for (const child of domNode.children) {
const childRender = buildRenderTree(child, cssom);
if (childRender) {
renderObject.children.push(childRender);
}
}

return renderObject;
}

4. 布局(Layout / Reflow)

  • 输入:渲染树(知道每个节点"是什么 + 什么样式",但不知道任何几何信息)+ 视口尺寸(viewport size)
  • 输出带几何信息的布局树——每个节点多出一组 LayoutBox 数据:精确的 xywidthheight,以及 margin/padding/border 的实际像素值

计算渲染树中每个节点的几何信息:位置(x, y)和大小(width, height)。

处理过程

  • 递归测量:从渲染树根节点开始,深度优先遍历,逐级计算每个节点的盒模型尺寸
  • 块级元素按文档流垂直排列,宽度默认由父容器决定
  • 内联元素按行内流水平排列,宽度由内容决定,需要进行文本测量(字形宽度、kerning、断行)
  • Flex/Grid 容器需要做约束求解,可能多次遍历子元素(measure pass + layout pass)
  • 相对值换算50%autoemvh 等都要解析为相对视口的绝对像素值

布局计算内容

  • 盒模型:content、padding、border、margin
  • 定位方式:static、relative、absolute、fixed、sticky
  • 文档流:块级元素、内联元素的排列
  • Flex/Grid 布局:弹性盒子、网格布局计算
与上一步的关系

渲染树只有"样式意图"(如 width: 50%margin: auto),布局要把这些相对值、百分比、auto 等翻译为具体的像素值。这一步是整个渲染管线中计算量最大的环节之一,详见下文 Q10"为什么 Layout 慢"。

// 布局信息
interface LayoutBox {
x: number;
y: number;
width: number;
height: number;
margin: { top: number; right: number; bottom: number; left: number };
padding: { top: number; right: number; bottom: number; left: number };
border: { top: number; right: number; bottom: number; left: number };
}

// 简化的布局计算
function calculateLayout(renderTree: RenderObject, viewport: { width: number; height: number }): void {
// 从根节点开始,递归计算每个节点的布局
const rootBox: LayoutBox = {
x: 0,
y: 0,
width: viewport.width,
height: 0,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
padding: { top: 0, right: 0, bottom: 0, left: 0 },
border: { top: 0, right: 0, bottom: 0, left: 0 }
};

layoutNode(renderTree, rootBox);
}

5. 绘制(Paint)

  • 输入:布局树(每个节点已知位置、大小、样式)+ 图层划分结果(浏览器按规则把渲染对象分配到不同的合成层 / Compositing Layer
  • 输出每个图层对应的绘制指令列表(DisplayList / Paint Record)——一串"在这个坐标画这个矩形/这个文字/这张图"的 2D 绘图指令,类似 Canvas API 序列

将渲染树的每个节点转换为屏幕上的实际像素。

处理过程

  • 按"绘制顺序规则"(stacking context → z-index → 源码顺序)遍历布局树
  • 为每个节点生成 Skia 绘图指令:背景色 → 背景图 → 边框 → 文本/子元素 → outline
  • 注意:这一步只是"记账",不实际产出像素——绘制指令不会立即执行
  • 光栅化(Raster) 是 Paint 之后的独立子阶段:合成线程把每个图层的 DisplayList 交给光栅化线程池,在 GPU 或 CPU 上把指令执行为位图瓦片(tiles)

绘制顺序(从后往前,类似图层叠加):

  1. 背景颜色(background-color)
  2. 背景图片(background-image)
  3. 边框(border)
  4. 子元素
  5. 轮廓(outline)
与上一步的关系

布局只回答"每个元素在哪、多大",绘制把这些几何信息 + 样式信息翻译为"具体怎么把像素画出来"。绘制按图层独立进行——单层重绘不会影响其他层的位图,这是重绘局部化的基础。

// 绘制操作记录
interface PaintOperation {
type: 'fillRect' | 'drawText' | 'drawImage' | 'drawBorder';
bounds: { x: number; y: number; width: number; height: number };
style: Record<string, unknown>;
}

// 生成绘制指令
function generatePaintOperations(renderObject: RenderObject): PaintOperation[] {
const operations: PaintOperation[] = [];
const { computedStyle } = renderObject;
const { x, y, width, height } = renderObject.layoutBox;

// 1. 绘制背景
if (computedStyle.backgroundColor !== 'transparent') {
operations.push({
type: 'fillRect',
bounds: { x, y, width, height },
style: { color: computedStyle.backgroundColor }
});
}

// 2. 绘制边框
if (computedStyle.borderWidth !== '0') {
operations.push({
type: 'drawBorder',
bounds: { x, y, width, height },
style: {
color: computedStyle.borderColor,
width: computedStyle.borderWidth,
style: computedStyle.borderStyle
}
});
}

// 3. 绘制文本内容
if (renderObject.domNode.textContent) {
operations.push({
type: 'drawText',
bounds: { x, y, width, height },
style: {
text: renderObject.domNode.textContent,
font: computedStyle.font,
color: computedStyle.color
}
});
}

return operations;
}

6. 合成(Composite)

  • 输入:各个图层光栅化后的位图纹理(tiles)+ 每层的变换信息(transform、opacity、filter、混合模式)
  • 输出最终呈现在屏幕上的一帧画面(framebuffer)

将页面分成多个图层(Layers),分别绘制后合成最终画面。

处理过程

  • 所有图层的位图作为纹理上传到 GPU
  • GPU 按 z 轴顺序叠加纹理,应用 transform/opacity/filter
  • 合成器线程(Compositor Thread) 驱动,不占用主线程——即使主线程繁忙(比如 JS 长任务阻塞),合成仍可继续(这就是 transform 动画即使主线程卡住也能流畅滚动的原因)

触发新图层的条件

条件示例
transform: translateZ(0)3D 变换
will-change: transform提示浏览器优化
position: fixed固定定位
<video>, <canvas>媒体元素
opacity 动画透明度变化
filterCSS 滤镜
GPU 加速

合成操作通常在 GPU 上执行,比 CPU 绑定更快。合理使用图层可以提升动画性能。

与上一步的关系

绘制产出的是"每个图层单独的位图指令",合成把这些独立的图层按正确 z-order 叠起来,处理透明度、混合、变换,最终输出给屏幕。关键差异:修改 transform/opacity 时,图层位图不变,只是合成参数变——GPU 只需重新合成一次,跳过前面所有阶段,这就是"GPU 加速动画"的本质。

浏览器渲染的多线程架构

一个渲染进程(Renderer Process)内部有多个线程协同工作——这是"JS 卡住但滚动不卡"、"CSS 动画比 JS 动画更稳"的核心原因。

线程职责能否被 JS 阻塞
主线程(Main Thread)JS 执行、Style Recalc、Layout、生成 Paint 指令✅ 会
合成器线程(Compositor Thread)图层合成、驱动滚动、驱动 GPU 动画❌ 不会
光栅化线程池(Raster Threads)把 DisplayList 转为位图瓦片(tiles)❌ 不会
GPU 进程/线程纹理上传、最终合成为屏幕帧❌ 不会

关键设计

  • 滚动由合成器线程直接驱动:即使主线程被 JS 卡住,页面滚动仍能流畅
  • transform/opacity 动画完全跳过主线程:合成器线程直接处理插值和变换
  • 事件会跨线程协作:监听 wheel/touchstart 时,合成器默认等待主线程确认是否要 preventDefault()——这就是 passive: true 能优化滚动性能的原因

详见 Q12

合成层(Compositing Layer)

浏览器会把页面拆分为多个合成层,每层拥有独立的 GPU 纹理,可以并行光栅化、独立合成。合成层是"按层重绘"和"GPU 加速动画"的基础。

常见创建条件

  • 3D 变换:transform: translateZ(0)transform: translate3d(...)
  • 显式提示:will-change: transform | opacity
  • 硬件加速元素:<video><canvas><iframe>
  • 滤镜:filterbackdrop-filter
  • 动画中的 opacitytransform
  • 混合模式:mix-blend-modenormal
层爆炸(Layer Explosion)

滥用 will-changetranslateZ(0) 会创建大量合成层,导致 GPU 内存爆炸、合成开销反而增大,在移动端可能直接让页面崩溃。合成层不是越多越好——只给确实会动画的少数元素提升合成层。详见 Q11

合成层 vs 层叠上下文(Stacking Context)

两者都与"图层"相关,但属于完全不同的概念层级,经常被混淆。

对比项层叠上下文(Stacking Context)合成层(Compositing Layer)
所属层级CSS 规范概念浏览器实现细节
核心作用决定绘制顺序(z-index 参考系)决定哪些元素在独立 GPU 纹理上合成
触发条件z-index 非 auto、opacity 小于 1、transformfilterposition: fixedisolation: isolatetransform: translateZ(0)will-change、video/canvas、动画中的 opacity/transform
独立绘制否,仍在同一位图上绘制是,有独立 bitmap/texture
内存开销每层占 GPU 内存
调试工具开发者自行梳理 z-indexDevTools → More tools → Layers

关系:合成层通常也是层叠上下文,但层叠上下文不一定是合成层。详见 Q15

重排与重绘

重排(Reflow / Layout)

当元素的几何属性(尺寸、位置)发生变化时,浏览器需要重新计算布局。

触发重排的操作

// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.padding = '10px';
element.style.margin = '20px';
element.style.border = '1px solid';

// 修改位置
element.style.position = 'absolute';
element.style.top = '50px';
element.style.left = '100px';

// 修改布局相关属性
element.style.display = 'block';
element.style.float = 'left';
element.style.flexDirection = 'column';

// 修改字体
element.style.fontSize = '20px';
element.style.fontFamily = 'Arial';

// 读取布局信息(强制同步布局)
const width = element.offsetWidth;
const height = element.offsetHeight;
const rect = element.getBoundingClientRect();

会触发重排的属性

类别属性
盒模型width, height, padding, margin, border
定位position, top, left, right, bottom
布局display, float, flex, grid
文本font-size, font-family, line-height, text-align
其他overflow, white-space, vertical-align

重绘(Repaint)

当元素的视觉属性(颜色、背景)发生变化,但不影响布局时,只需要重绘。

只触发重绘的操作

// 颜色变化
element.style.color = 'red';
element.style.backgroundColor = 'blue';
element.style.borderColor = 'green';

// 阴影
element.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';

// 可见性(不改变布局)
element.style.visibility = 'hidden';
element.style.outline = '1px solid red';

// 背景
element.style.backgroundImage = 'url(image.png)';

只触发重绘的属性

类别属性
颜色color, background-color, border-color
可见性visibility, outline
背景background-image, background-position
阴影box-shadow, text-shadow

合成(Composite Only)

某些操作只触发合成,不需要重排或重绘,性能最优。

// 只触发合成的属性
element.style.transform = 'translateX(100px)';
element.style.transform = 'scale(1.5)';
element.style.transform = 'rotate(45deg)';
element.style.opacity = '0.5';

性能对比

操作触发步骤性能开销
重排Layout → Paint → Composite最高
重绘Paint → Composite中等
合成Composite最低

渲染优化策略

1. 减少重排

先澄清一个常见误区

连续的样式写入不会触发多次重排。现代浏览器都有"渲染队列"——同步 JS 执行期间,多次 style.xxx = ... 只是标记 dirty,等 JS 执行完、下一帧渲染时才统一重排 1 次

const element = document.getElementById('box')!;
element.style.width = '100px';
element.style.height = '100px';
element.style.padding = '10px';
// ↑ 只触发 1 次重排,不是 3 次

真正导致"多次重排"的是 读写交替(Layout Thrashing),见下一节。

虽然不影响重排次数,但集中修改样式仍是好习惯(代码清晰、减少中间状态、利于维护):

// ✅ 推荐:cssText 一次性替换
element.style.cssText = 'width: 100px; height: 100px; padding: 10px;';

// ✅ 推荐:用 class 切换,样式和逻辑解耦
element.classList.add('new-style');

// ✅ 推荐:离屏操作,修改完再挂回 DOM
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `item ${i}`;
fragment.appendChild(li); // fragment 不在 DOM 树中,不触发重排
}
list.appendChild(fragment); // 最后一次性插入,只重排 1 次

2. 避免强制同步布局(Layout Thrashing)

这才是真正导致多次重排的元凶——写入样式后立刻读取布局属性,浏览器为了返回准确值,不得不立即 flush 渲染队列、同步执行一次 Layout。

// ❌ 错误:读写交替,每次循环都触发一次强制同步重排
const boxes = document.querySelectorAll<HTMLElement>('.box');
boxes.forEach((box) => {
const width = box.offsetWidth; // 读 → 浏览器被迫 flush,同步 reflow
box.style.width = width + 10 + 'px'; // 写 → 标记 dirty
// 下次循环再读 offsetWidth 时,又要 flush 一次
});

// ✅ 正确:先批量读,再批量写,只重排 1 次
const widths = Array.from(boxes, box => box.offsetWidth); // 统一读
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px'; // 统一写
});

会触发强制同步布局的 API(读取前需慎重):

类型属性/方法
尺寸offsetWidth/Height/Top/LeftclientWidth/HeightscrollWidth/Height
位置getBoundingClientRect()getClientRects()
滚动scrollTopscrollLeft
样式getComputedStyle()
其他focus()scrollIntoView()elementFromPoint()

3. 使用 transform 和 opacity 做动画

// ❌ 错误:使用 left/top 做动画(触发重排)
element.style.left = '100px';
element.style.top = '100px';

// ✅ 正确:使用 transform(只触发合成)
element.style.transform = 'translate(100px, 100px)';
/* ✅ CSS 动画使用 transform */
.animate {
transition: transform 0.3s ease;
}

.animate:hover {
transform: scale(1.1) translateY(-10px);
}

4. 使用 will-change 提示浏览器

/* 提前告诉浏览器哪些属性会变化 */
.will-animate {
will-change: transform, opacity;
}

/* 动画结束后移除 */
.animation-done {
will-change: auto;
}
will-change 注意事项
  • 不要滥用:每个 will-change 都会创建新图层,消耗内存
  • 动态添加:在动画开始前添加,结束后移除
  • 不要预设太多:只针对确实需要优化的元素

5. 脱离文档流

// 对频繁操作的元素脱离文档流
const element = document.getElementById('heavy-updates')!;

// 方法 1:使用 display: none
element.style.display = 'none';
// 进行大量 DOM 操作...
element.style.display = 'block';

// 方法 2:使用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
fragment.appendChild(div);
}
document.body.appendChild(fragment); // 只触发一次重排

// 方法 3:克隆节点
const clone = element.cloneNode(true) as HTMLElement;
// 在克隆节点上操作...
element.parentNode!.replaceChild(clone, element);

6. 使用 requestAnimationFrame

// ❌ 错误:同步更新可能导致丢帧
function animate(): void {
element.style.transform = `translateX(${x++}px)`;
setTimeout(animate, 16);
}

// ✅ 正确:使用 requestAnimationFrame
function animateBetter(): void {
element.style.transform = `translateX(${x++}px)`;
requestAnimationFrame(animateBetter);
}

requestAnimationFrame(animateBetter);

7. 虚拟化长列表

// 对于大量数据,只渲染可见区域
interface VirtualListOptions {
container: HTMLElement;
itemHeight: number;
items: unknown[];
renderItem: (item: unknown, index: number) => HTMLElement;
}

function createVirtualList(options: VirtualListOptions): void {
const { container, itemHeight, items, renderItem } = options;
const visibleCount = Math.ceil(container.clientHeight / itemHeight);
const buffer = 5; // 缓冲区

let startIndex = 0;

function render(): void {
const scrollTop = container.scrollTop;
startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleCount + buffer, items.length);

// 清空并只渲染可见项
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.style.height = `${items.length * itemHeight}px`;
wrapper.style.paddingTop = `${startIndex * itemHeight}px`;

for (let i = startIndex; i < endIndex; i++) {
wrapper.appendChild(renderItem(items[i], i));
}

container.appendChild(wrapper);
}

container.addEventListener('scroll', () => {
requestAnimationFrame(render);
});

render();
}

渲染阻塞

CSS 阻塞

<!-- CSS 阻塞渲染,但不阻塞 DOM 解析 -->
<link rel="stylesheet" href="style.css">

<!-- 优化:媒体查询,非匹配的不阻塞 -->
<link rel="stylesheet" href="print.css" media="print">
<link rel="stylesheet" href="mobile.css" media="(max-width: 768px)">

<!-- 优化:preload 预加载 -->
<link rel="preload" href="critical.css" as="style">

JavaScript 阻塞

<!-- 默认:阻塞 DOM 解析和渲染 -->
<script src="app.js"></script>

<!-- async:异步下载,下载完立即执行 -->
<script async src="analytics.js"></script>

<!-- defer:异步下载,DOM 解析完后执行 -->
<script defer src="app.js"></script>

常见面试问题

Q1: 什么是关键渲染路径?如何优化?

答案

关键渲染路径(Critical Rendering Path)是浏览器将 HTML、CSS、JavaScript 转换为屏幕像素的步骤序列:

  1. 构建 DOM 树:解析 HTML
  2. 构建 CSSOM 树:解析 CSS
  3. 构建渲染树:合并 DOM 和 CSSOM
  4. 布局:计算几何信息
  5. 绘制:转换为像素
  6. 合成:图层合成

优化策略

优化方向具体措施
减少关键资源内联关键 CSS、延迟非关键 JS
减少关键路径长度减少请求数量、使用 HTTP/2
减少关键字节数压缩、Tree Shaking、代码分割
优先关键内容首屏内容优先加载
<!-- 优化示例 -->
<head>
<!-- 内联关键 CSS -->
<style>
.header { /* 首屏样式 */ }
.hero { /* 首屏样式 */ }
</style>

<!-- 异步加载非关键 CSS -->
<link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'">

<!-- 预连接关键域名 -->
<link rel="preconnect" href="https://api.example.com">

<!-- defer 加载 JS -->
<script defer src="app.js"></script>
</head>

Q2: 重排和重绘有什么区别?如何减少?

答案

对比项重排(Reflow)重绘(Repaint)
触发条件几何属性变化视觉属性变化
影响范围可能影响整个页面只影响当前元素
性能开销
包含步骤Layout → Paint → CompositePaint → Composite

减少重排的方法

// 1. 批量修改样式
element.style.cssText = 'width: 100px; height: 100px;';
element.classList.add('new-class');

// 2. 避免强制同步布局
// 先读后写,不要交替
const width = element.offsetWidth;
element.style.width = width + 10 + 'px';

// 3. 使用 transform 代替位置属性
element.style.transform = 'translateX(100px)';

// 4. 脱离文档流操作
element.style.display = 'none';
// 多次 DOM 操作
element.style.display = 'block';

// 5. 使用 DocumentFragment
const fragment = document.createDocumentFragment();
// 批量添加节点
document.body.appendChild(fragment);

Q3: 为什么 transform 和 opacity 的动画性能好?

答案

transformopacity 的动画只触发**合成(Composite)**阶段,跳过了重排和重绘:

原因

  1. GPU 加速:transform 和 opacity 可以在 GPU 上合成
  2. 独立图层:这些元素通常在单独的合成层
  3. 不影响布局:不需要重新计算其他元素的位置
  4. 像素级操作:只是对已有像素的变换
// ❌ 性能差:触发重排
function animateWithLeft(element: HTMLElement): void {
let x = 0;
function frame(): void {
element.style.left = `${x++}px`; // 每帧重排
if (x < 100) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}

// ✅ 性能好:只触发合成
function animateWithTransform(element: HTMLElement): void {
let x = 0;
function frame(): void {
element.style.transform = `translateX(${x++}px)`; // 只合成
if (x < 100) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}

Q4: 什么是强制同步布局(Forced Synchronous Layout)?

答案

强制同步布局是指在 JavaScript 中读取布局信息时,浏览器被迫提前执行布局计算。

正常情况:浏览器会批量处理样式修改,在下一帧统一布局。

强制同步布局:读取布局属性时,必须立即计算最新布局。

// ❌ 触发强制同步布局
element.style.width = '100px'; // 样式修改(待执行)
const width = element.offsetWidth; // 强制布局!必须立即计算

// ❌ 更糟糕:循环中强制同步布局(布局抖动)
const boxes = document.querySelectorAll('.box');
boxes.forEach((box) => {
box.style.width = box.offsetWidth + 10 + 'px'; // 每次循环都强制布局!
});

// ✅ 避免:先读后写
const widths = Array.from(boxes).map(box => box.offsetWidth);
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px';
});

会触发强制布局的属性

类别属性
元素尺寸offsetWidth, offsetHeight, clientWidth, clientHeight
元素位置offsetTop, offsetLeft, scrollTop, scrollLeft
方法getBoundingClientRect(), getComputedStyle()
滚动scrollWidth, scrollHeight

Q5: 什么操作会触发重排(Reflow)?如何减少重排?

答案

重排(Reflow)是浏览器重新计算元素几何信息(位置、大小)的过程,开销很大,因为它可能导致整个渲染树或部分子树重新计算布局。

触发重排的三大类操作

1. DOM 结构变化

// 增删 DOM 节点
const container = document.getElementById('list')!;
container.appendChild(document.createElement('li')); // 重排
container.removeChild(container.firstChild!); // 重排

// 移动 DOM 节点
container.insertBefore(nodeA, nodeB); // 重排

2. 几何属性 / 布局属性变化

const el = document.getElementById('box')!;

// 盒模型属性
el.style.width = '200px';
el.style.padding = '10px';
el.style.margin = '20px';
el.style.borderWidth = '2px';

// 定位属性
el.style.position = 'absolute';
el.style.top = '50px';

// 布局属性
el.style.display = 'flex';
el.style.float = 'left';

// 文本属性
el.style.fontSize = '20px';
el.style.lineHeight = '1.5';
el.style.textAlign = 'center';

3. 读取布局信息(强制同步布局)

// 以下属性/方法的读取会强制浏览器立即计算布局
const width = el.offsetWidth; // 强制重排
const height = el.offsetHeight; // 强制重排
const top = el.offsetTop; // 强制重排
const rect = el.getBoundingClientRect(); // 强制重排
const style = getComputedStyle(el); // 强制重排
const scrollTop = el.scrollTop; // 强制重排
布局抖动(Layout Thrashing)

在循环中交替读写布局属性是最严重的性能问题,称为布局抖动。每次读取都会强制浏览器立即重排。

减少重排的 7 种策略

策略说明效果
批量修改样式cssTextclassName 一次性修改合并为一次重排
先读后写读取操作集中在前,写入操作集中在后避免布局抖动
脱离文档流display:none → 操作 → display:block操作期间不触发重排
DocumentFragment在 Fragment 上操作后一次性插入 DOM只触发一次重排
使用 transformtransform 代替 top/left跳过重排,只合成
绝对/固定定位动画元素脱离文档流缩小重排范围
requestAnimationFrame将 DOM 操作放到下一帧与浏览器渲染同步
// ❌ 布局抖动:循环中读写交替
function resizeAllBad(boxes: HTMLElement[]): void {
boxes.forEach((box) => {
const width = box.offsetWidth; // 读 → 强制重排
box.style.width = `${width * 1.1}px`; // 写
});
}

// ✅ 先读后写:分离读写操作
function resizeAllGood(boxes: HTMLElement[]): void {
// 批量读取
const widths = boxes.map((box) => box.offsetWidth);
// 批量写入
boxes.forEach((box, i) => {
box.style.width = `${widths[i] * 1.1}px`;
});
}

// ✅ DocumentFragment:批量插入
function appendItems(container: HTMLElement, items: string[]): void {
const fragment = document.createDocumentFragment();
items.forEach((text) => {
const li = document.createElement('li');
li.textContent = text;
fragment.appendChild(li);
});
container.appendChild(fragment); // 只触发一次重排
}

// ✅ display:none 隔离
function batchUpdate(el: HTMLElement): void {
el.style.display = 'none'; // 触发一次重排(脱离文档流)
el.style.width = '200px'; // 不触发重排
el.style.height = '100px'; // 不触发重排
el.style.padding = '10px'; // 不触发重排
el.style.display = 'block'; // 触发一次重排(回归文档流)
}

Q6: CSS 动画为什么比 JS 动画性能好?(合成层、GPU 加速)

答案

严格来说,并不是所有 CSS 动画都比 JS 动画性能好。关键在于动画修改的是哪些属性。使用 transformopacity 的动画(无论 CSS 还是 JS)都能获得最佳性能,因为它们可以被提升到合成层(Compositing Layer),由 GPU 处理。

渲染管线的三个层次

CSS 动画优势的核心原因

原因说明
GPU 合成transform/opacity 动画在 GPU 上执行,不占用主线程
独立合成层动画元素被提升为独立图层,修改不影响其他图层
不阻塞主线程CSS 动画由浏览器的合成器线程(Compositor Thread)驱动,即使主线程繁忙也能流畅运行
硬件加速浏览器自动将 transform/opacity 动画交给 GPU 处理
/* ✅ 高性能:只触发合成,GPU 加速 */
.slide-in {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-in:hover {
transform: translateX(100px);
opacity: 0.8;
}

/* ❌ 低性能:触发重排 + 重绘 + 合成 */
.slide-in-bad {
transition: left 0.3s ease;
}
.slide-in-bad:hover {
left: 100px;
}

合成层(Compositing Layer)的工作原理

浏览器将页面分成多个图层,每个图层独立绘制、然后在 GPU 上合成为最终画面:

JS 动画 vs CSS 动画的性能对比

// ❌ JS 动画使用 left(每帧触发重排)
function animateWithJS_Bad(el: HTMLElement): void {
let pos = 0;
function frame(): void {
pos += 2;
el.style.left = `${pos}px`; // 重排 → 重绘 → 合成
if (pos < 300) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}

// ✅ JS 动画使用 transform(每帧只合成)
function animateWithJS_Good(el: HTMLElement): void {
let pos = 0;
function frame(): void {
pos += 2;
el.style.transform = `translateX(${pos}px)`; // 只合成
if (pos < 300) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}

// ✅✅ 最佳:使用 Web Animations API
function animateWithWAAPI(el: HTMLElement): void {
el.animate(
[
{ transform: 'translateX(0)' },
{ transform: 'translateX(300px)' }
],
{
duration: 500,
easing: 'ease-out',
fill: 'forwards'
}
);
}
总结
  • 真正决定性能的是动画属性,而不是 CSS 还是 JS
  • transformopacity 是唯二只触发合成的属性
  • CSS 动画的优势在于:浏览器自动优化(合成器线程驱动、自动 GPU 提升),无需开发者额外处理
  • JS 动画只要使用 transform/opacity + requestAnimationFrame,也能达到同等性能

Q7: will-change 属性的作用和注意事项

答案

will-change 是 CSS 属性,用于提前告知浏览器某个元素即将发生哪些变化,让浏览器提前做好优化准备(如创建合成层、分配 GPU 内存)。

基本语法

/* 告诉浏览器 transform 和 opacity 将会变化 */
.will-animate {
will-change: transform, opacity;
}

/* 还原为默认 */
.animation-done {
will-change: auto;
}

will-change 的优化原理

效果说明
提前创建合成层避免动画开始时临时创建图层的开销
预分配 GPU 资源减少动画首帧的延迟
优化渲染管线浏览器可以为指定属性选择最优渲染路径

正确用法

// ✅ 方法一:通过 JS 动态管理(推荐)
const card = document.querySelector('.card') as HTMLElement;

card.addEventListener('mouseenter', () => {
// 交互前设置,给浏览器准备时间
card.style.willChange = 'transform, opacity';
});

card.addEventListener('mouseleave', () => {
// 动画结束后移除
card.style.willChange = 'auto';
});

// 如果是一次性动画
card.addEventListener('animationend', () => {
card.style.willChange = 'auto';
});
/* ✅ 方法二:在父元素 hover 时设置(给子元素准备时间) */
.card-container:hover .card {
will-change: transform;
}
.card-container .card:active {
transform: scale(1.05);
}

/* ✅ 方法三:对持续动画的元素(如 loading 动画)可以保持设置 */
.spinner {
will-change: transform;
animation: spin 1s linear infinite;
}
will-change 的注意事项

1. 不要滥用 -- 每个 will-change 都会创建独立的合成层,消耗额外的 GPU 内存:

/* ❌ 错误:给所有元素添加 */
* {
will-change: transform;
}

/* ❌ 错误:给大量列表项添加 */
.list-item {
will-change: transform, opacity;
}

2. 不要提前太久设置 -- 应在动画即将开始前设置,动画结束后及时移除:

/* ❌ 错误:始终保持 will-change */
.card {
will-change: transform;
transition: transform 0.3s;
}

/* ✅ 正确:交互时才设置 */
.card {
transition: transform 0.3s;
}
.card:hover {
will-change: transform;
transform: scale(1.05);
}

3. 不要用 will-change 代替现有优化 -- 它不能修复本身就有问题的动画:

/* ❌ 错误:试图用 will-change 优化 left 动画 */
.box {
will-change: left;
transition: left 0.3s; /* left 仍然触发重排 */
}

/* ✅ 正确:改用 transform */
.box {
will-change: transform;
transition: transform 0.3s;
}

内存影响对比

场景合成层数量GPU 内存占用
无 will-change1-2 个
合理使用 will-change3-5 个适中
滥用 will-change(100 个元素)100+ 个极高,可能导致页面崩溃
调试合成层

使用 Chrome DevTools → More tools → Layers 面板,可以查看页面的所有合成层数量和内存占用,帮助检测 will-change 是否被滥用。

Q8: 浏览器的 60fps 渲染与性能优化的关系

答案

浏览器通常以 60fps(每秒 60 帧) 刷新画面,这意味着每一帧的时间预算约为 16.67ms(1000ms / 60)。如果某一帧的处理时间超过这个预算,就会出现掉帧(Jank),用户会感受到卡顿。

帧预算分析

实际上,浏览器自身也需要一些时间处理(约 6ms),因此留给 JavaScript 的执行时间大约只有 10ms

阶段耗时预算说明
JavaScript~10ms执行事件回调、动画逻辑
Style~1ms计算匹配的 CSS 规则
Layout~2ms计算元素几何信息
Paint~1ms生成绘制指令
Composite~1ms图层合成
浏览器开销~1.67ms内部调度
总计16.67ms一帧的时间

掉帧的可视化

确保 60fps 的优化策略

1. 使用 requestAnimationFrame 代替 setTimeout/setInterval

// ❌ setTimeout 不保证与屏幕刷新同步
let pos = 0;
function animateBad(): void {
pos += 2;
element.style.transform = `translateX(${pos}px)`;
setTimeout(animateBad, 16); // 实际间隔可能不是 16ms
}

// ✅ requestAnimationFrame 与屏幕刷新率同步
function animateGood(): void {
pos += 2;
element.style.transform = `translateX(${pos}px)`;
requestAnimationFrame(animateGood); // 精确对齐到下一帧
}
requestAnimationFrame(animateGood);

2. 将长任务拆分,避免阻塞主线程

// ❌ 长任务阻塞:处理 10000 项,可能耗时 100ms+
function processAllAtOnce(items: unknown[]): void {
items.forEach((item) => heavyComputation(item)); // 阻塞主线程
}

// ✅ 分片处理:每帧处理一批,保证不超过 10ms
function processInChunks(items: unknown[], chunkSize = 100): void {
let index = 0;

function processChunk(): void {
const startTime = performance.now();

while (index < items.length) {
heavyComputation(items[index]);
index++;

// 每处理一项检查一次时间,超过 10ms 让出主线程
if (performance.now() - startTime > 10) {
requestAnimationFrame(processChunk);
return;
}
}
}

requestAnimationFrame(processChunk);
}

// ✅ 使用 requestIdleCallback 处理非紧急任务
function processWhenIdle(items: unknown[]): void {
let index = 0;

function processItem(deadline: IdleDeadline): void {
while (index < items.length && deadline.timeRemaining() > 1) {
heavyComputation(items[index]);
index++;
}

if (index < items.length) {
requestIdleCallback(processItem);
}
}

requestIdleCallback(processItem);
}

3. 只使用 transform 和 opacity 做动画

/* ✅ 只触发合成,稳定 60fps */
.card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.card:hover {
transform: translateY(-10px) scale(1.02);
opacity: 0.95;
}

4. 使用 Web Worker 卸载计算密集型任务

// main.ts
const worker = new Worker('heavy-task.js');

worker.postMessage({ data: largeDataSet });
worker.onmessage = (e: MessageEvent) => {
// 在主线程更新 UI,Worker 线程不阻塞渲染
updateUI(e.data.result);
};

// heavy-task.js (Worker 线程)
self.onmessage = (e: MessageEvent) => {
const result = heavyComputation(e.data); // 不阻塞主线程
self.postMessage({ result });
};

5. 使用 Performance API 监测帧率

// 实时 FPS 监测器
class FPSMonitor {
private frames = 0;
private lastTime = performance.now();
private fps = 0;

start(): void {
const tick = (): void => {
this.frames++;
const now = performance.now();

if (now - this.lastTime >= 1000) {
this.fps = Math.round(this.frames * 1000 / (now - this.lastTime));
this.frames = 0;
this.lastTime = now;

if (this.fps < 50) {
console.warn(`FPS 过低: ${this.fps}fps,可能存在性能问题`);
}
}

requestAnimationFrame(tick);
};

requestAnimationFrame(tick);
}

getCurrentFPS(): number {
return this.fps;
}
}

// 使用 PerformanceObserver 检测长任务
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`检测到长任务: ${entry.duration.toFixed(2)}ms`, entry);
}
}
});

observer.observe({ type: 'longtask', buffered: true });
60fps 优化清单
  1. JS 执行时间控制在 10ms 以内
  2. 动画只使用 transformopacity
  3. 使用 requestAnimationFrame 驱动动画
  4. 长任务拆分为小块(Time Slicing)
  5. 计算密集型任务放到 Web Worker
  6. 避免强制同步布局和布局抖动
  7. 使用 Chrome DevTools Performance 面板定位瓶颈

Q9: Paint 和 Composite 是什么关系?是绘制后再合成吗?

答案

是的,Paint 一定在 Composite 之前发生,但二者的工作粒度和执行位置完全不同,不是"把一张画好的大图再处理一下",而是"先按图层画出每一层,再把所有层摞起来"。

两阶段的核心区别

对比项Paint(绘制)Composite(合成)
输入布局树 + 图层分配各图层的位图纹理
输出每个图层的绘制指令列表(DisplayList)屏幕上的最终帧
工作单位单个合成层所有合成层的叠加
执行位置CPU(主线程 + 光栅化线程池)GPU(合成器线程驱动)
是否阻塞主线程部分阻塞(指令生成在主线程)不阻塞
触发场景颜色、背景、阴影等视觉属性变化transformopacity 变化

完整管线分解

为什么不"先把页面画成一整张图再显示"?

拆成"按层绘制 + 最后合成"有三大收益:

  1. 局部重绘:一个图层内容变化时,只需重新 Paint 该图层,其他图层的位图直接复用
  2. GPU 动画免重绘:修改 transform/opacity 时,图层位图不变,GPU 只需用不同参数再合成一次——这是 CSS 动画流畅的根本原因
  3. 并行化:多个图层可以在不同的光栅化线程并行绘制

典型场景对应的管线路径

// 修改 color:只影响该层的 Paint
element.style.color = 'red';
// → Paint(该层)→ Raster → Composite

// 修改 width:可能影响布局,需要从 Layout 开始
element.style.width = '200px';
// → Layout → Paint(可能多个层)→ Raster → Composite

// 修改 transform:跳过 Paint,只合成
element.style.transform = 'translateX(100px)';
// → Composite(直接用已有的图层位图做变换)
记忆要点
  • Paint 是 CPU 活儿:生成 2D 绘图指令,按图层独立进行
  • Composite 是 GPU 活儿:把多个图层的纹理叠起来并应用变换
  • 动画性能排序:Composite-only > Paint-only > 需要 Layout
  • 调试合成层:Chrome DevTools → More tools → Layers / Rendering 面板

Q10: 为什么 Layout 很慢?

答案

Layout(布局)是渲染管线中开销最大的阶段之一,典型场景下耗时可以轻松超过 Paint 和 Composite 几倍甚至数十倍。慢的根本原因是:布局是一个全局耦合、串行计算的约束求解问题

六大原因

1. 全局耦合:一个元素的尺寸依赖其他元素

一个元素的布局结果通常需要三个方向的信息同时就绪:

改一个元素,可能需要重新布局整颗子树,甚至向上影响祖先(如子项高度撑大父容器)。

2. 文本测量开销巨大

文本排版(text shaping)涉及:

  • 查找字体文件(system font fallback)
  • 测量每个字形的宽度(glyph metrics)
  • 处理 kerning(字距调整)、ligatures(连字)
  • 按容器宽度断行(line breaking)
  • 处理 word-breakhyphens、双向文本(BiDi)

每段文字都要经过复杂的 shaping 过程,大段文本的布局耗时可以占整个 Layout 时间的 50% 以上

3. 复杂算法:Flex/Grid 需要约束求解

  • Flex 布局:多趟遍历(measure pass + layout pass),每个子项要解析 flex-growflex-shrinkflex-basismin-contentmax-content
  • Grid 布局:更复杂,要解算 auto 轨道、fr 单位分配、minmax() 约束
  • Table 布局:臭名昭著的"双遍算法"——先测每列最大/最小宽度,再分配实际宽度
<!-- 嵌套 Flex:外层算完才能算内层,复杂度随嵌套深度增长 -->
<div style="display: flex">
<div style="display: flex">
<div style="flex: 1">...</div>
</div>
</div>

4. 级联触发:一个改动牵动整颗子树

修改一个元素的尺寸或位置,浏览器需要判断哪些元素的布局会受影响。虽然现代浏览器有 Layout Boundary(布局边界)优化——如 position: fixed/absoluteoverflow: hidden、固定尺寸的元素可以作为布局隔离点——但在最坏情况下,改动可能影响整颗渲染树

<!-- 改 .a 的 width,可能触发以下所有元素重新布局 -->
<body>
<div class="a" style="width: 200px">
<div>子元素 1</div>
<div>子元素 2</div>
</div>
<div>兄弟元素(文档流后的元素需要重新排位置)</div>
</body>

5. 强制同步布局(Forced Synchronous Layout)

JavaScript 读取布局属性(如 offsetWidthgetBoundingClientRect)会强制浏览器立即执行挂起的布局计算,破坏批处理优化。循环中交替读写更会导致 布局抖动(Layout Thrashing)——每次读取都触发一次完整布局。详见 Q4 强制同步布局

// ❌ 布局抖动:100 次循环 = 100 次 Layout
items.forEach((el) => {
el.style.width = el.offsetWidth + 10 + 'px';
});

6. 主线程独占,无法并行

Paint 可以拆成多个图层在光栅化线程池并行执行,Composite 在 GPU 上并行执行。但 Layout 只能在主线程串行计算,因为 DOM 和样式系统不是线程安全的,且布局的全局依赖性决定了没法简单拆分。

七种减少 Layout 开销的策略

策略原理
避免频繁读取布局属性防止强制同步布局
先读后写,批量读写让浏览器合并多次布局为一次
transform 代替 top/left跳过 Layout,只走 Composite
使用 position: absolute/fixed脱离文档流,缩小布局影响范围
contain: layout 显式声明边界浏览器可跳过子树之外的影响
content-visibility: auto屏幕外元素延迟布局
避免深层嵌套的 Flex/Grid减少约束求解的计算量
/* contain 属性:显式声明隔离边界 */
.card {
contain: layout style paint;
/* 浏览器保证:这个元素内部的变化不会影响外部布局 */
}

/* content-visibility:屏幕外元素跳过布局和绘制 */
.lazy-section {
content-visibility: auto;
contain-intrinsic-size: 500px; /* 占位高度,防止滚动跳动 */
}
性能调试

Chrome DevTools → Performance 面板中的紫色块(Layout)直接暴露布局耗时。

  • 单次 Layout 超过 5ms 就要警惕
  • 一帧内出现多次 Layout,很可能存在布局抖动
  • 配合 performance.mark() 可以精确定位是哪段 JS 触发的强制同步布局

Q11: 哪些 CSS 属性会创建合成层?如何避免"层爆炸"?

答案

合成层(Compositing Layer)是浏览器为某些元素分配的独立 GPU 纹理,可以独立光栅化、独立合成,不影响其他图层的位图。

触发创建合成层的常见条件

类别具体条件
3D 变换transform: translateZ(0)translate3d(...)rotateX/Y
显式提示will-change: transformwill-change: opacity
硬件加速元素<video><canvas><iframe>、WebGL
滤镜filter: blur(...)backdrop-filter
动画中opacitytransform 正在过渡/动画的元素
混合模式mix-blend-modenormal
裁剪特定的 clip-pathmask
其他position: fixed/sticky(与其他层重叠时)

层爆炸(Layer Explosion)

滥用以上手段会创建过多合成层,导致:

  • GPU 内存占用剧增——每层需要独立的纹理缓冲区(宽高 × 4 字节 RGBA)
  • 合成开销增大——GPU 每帧都要叠加几百个纹理
  • 实际性能反而下降——移动端极端情况下可能直接让浏览器崩溃

常见陷阱

/* ❌ 给所有列表项加 will-change:1000 项 = 1000 个合成层 */
.list-item {
will-change: transform;
}

/* ❌ 大量元素用 translateZ(0) 强制 GPU 加速 */
.card {
transform: translateZ(0);
}

/* ❌ 父子同时设置 will-change,可能触发叠加层 */
.parent, .parent > .child {
will-change: transform;
}

避免层爆炸的策略

策略说明
只给动画元素提升will-change 仅用于确实会动画的少数元素
动态管理 will-change动画开始前加上,结束后立即移除
避免大列表统一提升长列表不要整体加 GPU 加速,配合虚拟列表
工具可视化检查DevTools → Rendering → Layer borders 打开橙色边框
监控 GPU 内存DevTools → More tools → Layers 查看每层内存占用

Q12: 浏览器渲染的多线程架构是怎样的?

答案

Chromium 的一个渲染进程内部运行多个线程协同处理渲染流水线。理解线程分工是理解"为什么 JS 卡住但滚动不卡"、"为什么 CSS 动画更稳"的关键。

四个核心线程(进程)

线程负责什么是否被主线程阻塞
主线程(Main Thread)JS 执行、Style Recalc、Layout、Paint 指令生成
合成器线程(Compositor Thread)图层合成、滚动驱动、GPU 动画❌ 不会
光栅化线程池(Raster Threads)DisplayList → 位图瓦片❌ 不会
GPU 进程 / GPU 线程纹理上传、最终合成为屏幕帧❌ 不会

典型一帧的跨线程流转

三个关键场景

1. 滚动:合成器线程直接响应滚动事件、偏移已有图层,主线程完全不参与——这就是即使 JS 卡住,惯性滚动仍流畅的原因。

2. transform/opacity 动画:合成器线程每帧重新计算变换矩阵,用已有图层纹理再合成一次,完全绕过主线程——这是"GPU 加速动画"的本质。

3. 事件处理的坑wheeltouchstarttouchmove 事件,浏览器不知道 JS 会不会 preventDefault(),所以默认合成器线程会等待主线程确认。这是滚动卡顿的常见原因,解决办法是用 passive: true

// ❌ 默认:合成器等主线程
element.addEventListener('touchstart', handler);

// ✅ passive:告诉浏览器这个回调不会 preventDefault,合成器不用等
element.addEventListener('touchstart', handler, { passive: true });

对应的渲染指令

JS 修改主线程阶段合成器线程阶段
修改 widthStyle → Layout → Paint → 提交重新合成
修改 colorStyle → Paint → 提交重新合成
修改 transformStyle(仅计算样式)直接重新合成

Q13: Style Recalculation(样式重新计算)阶段在做什么?

答案

打开 Chrome Performance 面板,你会在 Layout 之前看到一段单独的紫色块 Recalculate Style——这就是常被忽略的独立渲染阶段,发生在 DOM/CSSOM 构建完成后、Layout 开始前。

核心工作:选择器匹配 + Computed Style 计算

  1. 对每个"脏"元素(有样式变化的元素),遍历 CSS 规则检查是否匹配
  2. 解析所有匹配规则的优先级(Specificity + 来源权重 + 源码顺序),决定哪条胜出
  3. 计算继承colorfont-size 等从父节点传下来)
  4. 相对值转绝对值empx% → 具体像素)
  5. 得到每个元素的最终 Computed Style

性能瓶颈

场景影响
深嵌套选择器(body div ul li a span每层都要向上验证祖先
通用选择器(*匹配所有元素
属性选择器([data-x="y"]需要运行时读取属性
大量 :hover:nth-child 等伪类状态变化触发整段重算
全局改动(如切换根元素 class触发整颗树 Style Recalc

浏览器的优化手段

  • Bloom Filter:O(1) 快速过滤掉肯定不匹配的规则
  • Rule Bucket / Rule Indexing:按选择器最右侧的 tag/class/id 分桶,查找时只遍历对应桶
  • 脏标记(Dirty Flag):只重算受影响的子树,而不是整颗树
  • Style Sharing:兄弟元素若样式相同可以共享 Computed Style 对象

开发启示

/* ❌ 深嵌套 + 通用选择器,Style Recalc 慢 */
nav ul li a > * { color: red; }

/* ✅ 用单独的 class,命中即结束 */
.nav-link { color: red; }

/* ❌ 根 class 切换:主题变化时触发整颗树重算 */
document.documentElement.classList.toggle('dark-mode');
/* ✅ 改用 CSS 变量:只触发 Paint,不触发 Style Recalc(多数情况下) */
document.documentElement.style.setProperty('--bg', 'black');

Q14: 为什么 CSS 选择器从右向左匹配?

答案

选择器匹配可以选择从左向右从右向左,浏览器(包括 Chrome/Firefox/Safari)都选择从右向左,核心原因是性能

例子:选择器 div.container ul li a

从左向右(假设这样做):

  1. 找所有 div.container
  2. 对每个 div,深度遍历找所有子 ul
  3. 对每个 ul,找所有子 li
  4. 对每个 li,找所有子 a

→ 最坏情况遍历整颗 DOM 子树,每一层分叉都放大搜索空间

从右向左(实际做法):

  1. 找所有 <a>(最右侧的"关键选择器",直接指向实际目标元素)
  2. 对每个 <a>,向上检查祖先链是否依次匹配 liuldiv.container
  3. 任何一级不匹配就立即终止,不必继续向上

→ 每个 <a> 只做有限的祖先检查(通常 ≤ 10 层),匹配失败快速退出。

为什么"右→左"更快

原因说明
页面元素远多于选择器先锁定目标元素再验证条件,比先找候选再筛选更高效
最右侧条件命中率最低最具体的约束(class/id)越早应用,剪枝越快
向上遍历深度有限DOM 深度通常 < 20;向下可能触达整颗子树
早退(Short-circuit)任一级祖先不匹配就立即放弃,平均只检查 1-2 级
匹配单元是"单个元素"样式计算时只需知道"这个元素匹配哪些规则",从右向左正对应

对开发的启示

/* ❌ 过深嵌套:每多一级都多一次祖先检查 */
body .page .content article .card .header h1 { ... }

/* ✅ 直接用 class:匹配一次结束 */
.card-title { ... }

/* ❌ 最右侧是通用选择器:几乎所有元素都是候选 */
.sidebar * { ... }

/* ✅ 最右侧是具体 class */
.sidebar-item { ... }

BEM、CSS Modules、Tailwind 等流派本质上都在解决同一个问题:让选择器的最右侧尽量唯一具体,让每个元素最多匹配 1-2 条规则

Q15: Stacking Context 和合成层有什么区别?

答案

两者都涉及"图层"概念,但属于完全不同的抽象层级,是面试高频混淆点。

本质区别

对比项层叠上下文(Stacking Context)合成层(Compositing Layer)
所属层级CSS 规范定义的概念浏览器实现细节
核心作用决定绘制顺序(z-index 参考系)决定哪些元素在独立 GPU 纹理上合成
触发条件z-index 非 auto、opacity 小于 1、transformfilterisolation: isolateposition: fixedtransform: translateZ(0)will-change、video/canvas、动画中的 opacity/transform
独立位图❌ 否,仍在同一位图上绘制✅ 是,有独立 bitmap/texture
内存开销每层占 GPU 内存
调试工具查看代码梳理 z-index 关系DevTools → More tools → Layers 面板
规范文档W3C CSS Z-Order无(浏览器实现)

两者的关系

合成层  ⊆  层叠上下文  ⊆  所有元素
  • 合成层一定是层叠上下文(但反过来不成立)
  • 大量层叠上下文不会变成合成层(只是绘制顺序概念)
  • opacity: 0.5 创建层叠上下文,不一定创建合成层(取决于场景)
  • transform: translateZ(0) 同时创建层叠上下文和合成层

实际示例

/* 1. 只创建层叠上下文,不创建合成层 */
.a { position: relative; z-index: 1; }

/* 2. 同时创建层叠上下文和合成层 */
.b { transform: translateZ(0); }

/* 3. 静止时不创建合成层;动画时才创建(且自动是层叠上下文) */
.c { transition: opacity 0.3s; }
.c:hover { opacity: 0.5; }

记忆口诀

  • 层叠上下文回答"画在谁前面"——是绘制顺序问题
  • 合成层回答"画在哪张纹理上"——是硬件加速问题
  • 前者是逻辑层,后者是物理层

Q16: HTML 解析中的 Preload Scanner 是什么?

答案

Preload Scanner(预加载扫描器) 是浏览器在主 HTML 解析器之外独立运行的轻量扫描器。它提前扫描 HTML 文本查找需要下载的资源,在主解析器实际到达之前就并行发起请求

为什么需要它

主 HTML 解析器遇到同步 <script>暂停,但浏览器并不想在暂停期间闲着:

<head>
<script src="heavy.js"></script>
<!-- 主解析器在这里阻塞,但 Preload Scanner 继续扫描 ↓ -->
<link rel="stylesheet" href="style.css">
<script src="app.js"></script>
</head>
<body>
<img src="hero.jpg">
<!-- ↑ 这些资源 Preload Scanner 都能提前发现并下载 -->
</body>

heavy.js 还在下载时,style.css、app.js、hero.jpg 已经被扫描到并并行发起请求了。等主解析器恢复工作,这些资源很可能已经下载完。

能识别的资源

  • <link rel="stylesheet"><link rel="preload">
  • <script src="...">
  • <img src="..."><img srcset="...">
  • <video><audio>src
  • ❌ CSS 中的 @import(需要先解析 CSS,来不及)
  • ❌ CSS 中的 background-image: url(...)(同上)
  • ❌ JavaScript 动态创建的 <script><link>(扫描的是原始 HTML)

对开发的关键启示

  1. SSR 首屏优化的核心机制:服务端渲染输出完整 HTML,让 Preload Scanner 最大化提前下载——这是 SSR 相比 CSR 首屏快的真正原因之一

  2. 关键资源必须写在原始 HTML 里

<!-- ✅ 能被 Preload Scanner 发现 -->
<link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
<link rel="stylesheet" href="/css/main.css">

<!-- ❌ 无法被 Preload Scanner 发现 -->
<script>
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/main.css';
document.head.appendChild(link);
</script>
  1. <link rel="preload">:显式提示 Preload Scanner 提前下载某些资源(如字体、关键图片)

  2. 字体文件要用 preload:因为字体是 CSS 发现的,默认要等 CSS 解析完才下载,会造成 FOUT/FOIT。用 <link rel="preload"> 可以和 HTML 一起被扫描到。

Q17: FCP/LCP/TTI 分别对应渲染管线的哪一步?

答案

Web Vitals 指标的触发时机都直接对应渲染管线的特定阶段。理解对应关系才能准确定位优化点。

核心指标映射

指标全称对应管线时机用户感受
FPFirst Paint第一次 Composite 输出非空白像素屏幕从白色变成有颜色(如背景色)
FCPFirst Contentful Paint第一次 Composite 包含 DOM 内容第一次看到"有用的内容"(文字、图片、SVG)
LCPLargest Contentful Paint视口内最大内容元素完成 Composite主视觉内容(Hero 图/标题)出现
TTITime to InteractiveDOM Ready + 主线程连续 5s 无长任务页面"真正能点击响应"的时刻
TBTTotal Blocking TimeFCP 到 TTI 之间主线程的总阻塞时间页面"卡顿"的累计时长
INPInteraction to Next Paint单次交互到下一帧 Composite 的时间点击/输入后多久看到界面响应
CLSCumulative Layout Shift页面稳定前所有 Layout 偏移的累计分内容"乱跳"导致误点的程度

与渲染管线的对应

每个指标的主要优化抓手

指标卡在哪一步优化手段
FCPCSS/JS 阻塞、网络慢减少 render-blocking 资源、内联关键 CSS、SSR
LCP主图加载慢、渲染依赖 JS<link rel="preload"> 主图、AVIF/WebP、CDN、服务端直出
TTI / TBT主线程长任务多、JS 体积大代码分割、延迟非关键 JS、Web Worker
INP事件处理卡主线程事件回调拆分为小任务、scheduler.yield()
CLS图片/广告/字体撑开布局图片设 width/heightfont-display: optionalcontain-intrinsic-size

详见 Web 性能指标与监控系统

Q18: 浏览器什么时候会跳过渲染管线的某些阶段?

答案

浏览器有多种"智能跳过"机制来避免不必要的渲染工作。理解这些机制是高级性能优化的核心思路——能省的步骤越多、越早省,性能越好

1. 不可见元素跳过的阶段各不同

属性Style RecalcLayoutPaintComposite
display: none
visibility: hidden✅(空白)
opacity: 0
content-visibility: hidden
  • display: none:不进入渲染树,只剩 Style Recalc
  • visibility: hidden:仍占布局空间,但 Paint 跳过内容
  • opacity: 0:完整走完管线,只是合成时透明——最贵

2. content-visibility: auto:屏幕外元素延迟渲染

.lazy-section {
content-visibility: auto;
contain-intrinsic-size: 500px; /* 占位高度防止滚动跳动 */
}

屏幕外时跳过 Layout、Paint;进入视口时才恢复正常渲染。长文档可节省大量 Layout 时间。

3. contain 属性:缩小布局/绘制影响范围

.card {
contain: layout style paint;
}

告诉浏览器:"我内部的变化不会影响外部"——浏览器可以跳过外部的级联重排和重绘,把受影响范围限制在这个元素内。

4. transform/opacity 动画跳过 Layout + Paint

直接从"提交现有图层纹理"开始,只走 Composite——这是 GPU 加速动画的本质。

5. 标签页不可见时降频/暂停

document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// requestAnimationFrame 会被降频到约 1Hz 甚至暂停
// setTimeout/setInterval 的最小间隔被限制为 1000ms
pauseExpensiveAnimations();
}
});

6. 合成器线程独立驱动的滚动/动画

滚动和 transform 动画由合成器线程直接处理,完全跳过主线程——即使 JS 卡住也能流畅。

7. 局部更新 vs 全局更新

  • 修改叶子节点:可能只影响自身 Paint
  • 修改根元素 class:触发整颗树 Style Recalc
  • 修改固定尺寸容器内的子元素:可能不触发外部 Layout(Layout Boundary 优化)

优化启示——优先使用能跳过更多阶段的方案:

首选:Composite-only(transform/opacity 动画)
次选:Paint-only(color/background 变化)
避免:Layout(几何属性变化)
最避免:全局 Style Recalc(根元素 class 切换)

相关链接