浏览器渲染原理

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 等属性从父元素继承
  • 默认值:未指定的属性使用初始值
  • 值标准化empxcolor: redrgb(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 绘制顺序规范)

同一元素的层叠绘制顺序(从底到顶):

  1. 背景色(background-color)
  2. 背景图(background-image)
  3. 边框(border)
  4. 子元素
  5. 轮廓(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-changetransformopacity 动画元素创建合成层
  • 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 的区别

特性setTimeoutrequestAnimationFrame
执行时机宏任务队列,可能在渲染后渲染前,紧跟微任务之后
帧率同步不同步,可能跳帧与显示器刷新率同步(通常 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 containmentcontain 属性限制布局范围避免大范围重排
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>asyncdefer
下载时机阻塞解析,下载完才继续并行下载并行下载
执行时机下载完立即执行下载完立即执行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 值布局隔离绘制隔离尺寸独立性能提升安全性
layoutYesNoNo
paintYesYesNo
sizeNoNoYes高(需手动指定尺寸)
contentYesYesNo
strictYesYesYes最高最高

示例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/paddingYesYesNo
display/position/floatYesYesNo
color/background/border-colorNoYesNo
visibility/outlineNoYesNo
transformNoNoYes
opacityNoNoYes

示例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. 录制与分析

  1. 打开 DevTools → Performance 面板
  2. 点击 Record(圆点按钮)或 Ctrl+E
  3. 操作页面(触发需要分析的行为)
  4. 停止录制,查看火焰图

2. 关键区域解读

区域内容关注点
FPS帧率图表低于 60fps 的红色区域
CPUCPU 占用黄色(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. 核心指标与分数权重

指标权重含义目标值
FCP10%首次内容绘制< 1.8s
LCP25%最大内容绘制< 2.5s
TBT30%总阻塞时间< 200ms
CLS25%累积布局偏移< 0.1
SI10%速度指数< 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/leftpassive: 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()
  • 动画只用 transformopacity
  • 用 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 单独处理的图层,修改时只需重新合成,不影响其他图层。transformopacity 只触发合成阶段,不触发布局和绘制,因此动画性能最优。可通过 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-changetransform: 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 可进一步跳过屏幕外元素的渲染。


相关链接: