文章

浏览器渲染流程深度解析

浏览器渲染流程深度解析

一句话概括

浏览器渲染流程是从服务器返回 HTML/CSS/JS 开始,经过 DOM 树构建、CSSOM 树构建、渲染树合成、布局计算、绘制和合成六个阶段,最终将代码转化为用户屏幕上像素的完整管线(Rendering Pipeline)。

背景与意义

从用户视角看渲染

用户打开一个网页,按下回车键:

1
2
3
4
5
6
7
t=0ms    按下回车
t=50ms   首字节到达 (TTFB)
t=100ms  开始解析 HTML 中的前 3 行
t=200ms  首次内容绘制 (FCP) — 用户看到页面的一块
t=500ms  首次有意义绘制 (FMP) — 页面的主体布局完成
t=1000ms 最大内容绘制 (LCP) — 最大元素渲染完成
t=1200ms 所有资源加载完毕,页面交互可用 (TTI)

从按下键到用户能阅读内容只用 200ms。但这 200ms 里发生了惊人的工作量:HTML 字符串流入解析器、字符转 Token、Token 结构化、同时下载并解析 CSS、构建 CSSOM、合并成渲染树、计算每个元素的位置和大小、然后通过 GPU 命令把这些指令变成屏幕上的像素。

Google 的研究表明:首次内容绘制每慢 100ms,转化率下降 7%。对于亚马逊这样的电商,100ms 的延迟意味着每年损失 16 亿美元的销售额。

理解渲染流程是前端性能优化的基础——不知道绘制哪里卡住,就无法加速。

概念与定义

渲染管线六阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
HTML → DOM 树           (DOM 内容解析)
                          ↓
CSS  → CSSOM 树         (样式内容解析)
                          ↓
DOM + CSSOM → 渲染树    (合并可见元素)
                          ↓
布局 (Layout)           (计算盒模型位置和大小)
                          ↓
绘制 (Paint)            (栅格化像素)
                          ↓
合成 (Composite)        (图层合并,GPU 合成)
                          ↓
显示器刷新
阶段输入输出触发条件
DOM 构建HTML 字节流DOM 节点树HTML 解析器
CSSOM 构建CSS 字节流CSS 节点树(样式规则)CSS 解析器
渲染树DOM + CSSOM可见元素的树结构样式计算
布局渲染树 + 视口尺寸盒模型(位置、大小)样式或 DOM 变化
绘制布局结果绘制指令列表(Display List)布局或样式变化
合成绘制指令GPU 纹理 / 屏幕像素合成属性变化

关键性能指标

缩写全称含义
FCPFirst Contentful Paint首次内容绘制(文本、图片)
FMPFirst Meaningful Paint首次有意义绘制
LCPLargest Contentful Paint最大内容绘制
TTITime to Interactive可交互时间
TBTTotal Blocking Time总阻塞时间
CLSCumulative Layout Shift累计布局偏移

最小示例

可视化渲染管线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<!-- render-demo.html — 用 performance API 追踪渲染过程 -->
<!DOCTYPE html>
<html>
<head>
<style>
  .box {
    width: 200px;
    height: 100px;
    background: #3498db;
    margin: 20px;
    transition: all 0.3s;
  }
  .box.changed {
    width: 300px;
    background: #e74c3c;
    transform: translateX(50px);
  }
</style>
</head>
<body>
<div id="container">
  <div class="box" id="box1">布局+绘制</div>
  <div class="box" id="box2" style="transform:translateX(0)">仅合成</div>
</div>
<button onclick="benchmark()">测试渲染</button>
<pre id="output"></pre>

<script>
// 渲染性能测量工具
async function benchmark() {
  const output = document.getElementById('output');
  const box1 = document.getElementById('box1');
  const box2 = document.getElementById('box2');
  
  output.textContent = '';

  // 测试 1: 改变 width + background (触发布局 + 绘制)
  output.textContent += '=== 测试 1: 改变宽高+背景 ===\n';
  box1.classList.remove('changed');
  await nextFrame();
  
  const layoutStart = performance.now();
  box1.classList.add('changed');
  await nextFrame();
  const layoutEnd = performance.now();
  output.textContent += `  耗时: ${(layoutEnd - layoutStart).toFixed(1)}ms\n`;
  output.textContent += `  触发了: Layout + Paint\n\n`;

  // 测试 2: 改变 transform (仅合成)
  output.textContent += '=== 测试 2: 改变 transform ===\n';
  box2.style.transform = 'translateX(0)';
  await nextFrame();
  
  const compositeStart = performance.now();
  box2.style.transform = 'translateX(100px)';
  await nextFrame();
  const compositeEnd = performance.now();
  output.textContent += `  耗时: ${(compositeEnd - compositeStart).toFixed(1)}ms\n`;
  output.textContent += `  触发了: 仅 Composite\n\n`;

  // 测试 3: 使用 Performance Observer 追踪
  output.textContent += '=== Performance Observer 数据 ===\n';
  if (performance.getEntriesByType) {
    const paintEntries = performance.getEntriesByType('paint');
    paintEntries.forEach(entry => {
      output.textContent += `  ${entry.name}: ${entry.startTime.toFixed(1)}ms\n`;
    });
  }
  
  // 测试 4: Layout Shift 检测
  output.textContent += '\n=== 布局偏移 (CLS) ===\n';
  if (PerformanceObserver && PerformanceObserver.supportedEntryTypes?.includes('layout-shift')) {
    output.textContent += '  ✅ 浏览器支持 CLS 检测\n';
  } else {
    output.textContent += '  ⚠️ 当前浏览器不支持 CLS API\n';
  }
}

function nextFrame() {
  return new Promise(resolve => requestAnimationFrame(resolve));
}

// 使用 PerformanceObserver 监听长任务
if (PerformanceObserver) {
  try {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.duration > 50) {
          console.warn(`⚠️ 长任务 (${entry.duration.toFixed(0)}ms):`, entry.name);
        }
      }
    });
    observer.observe({ entryTypes: ['longtask'] });
  } catch(e) {}
}
</script>
</body>
</html>

打开 Chrome DevTools → Performance 面板 → 录制 → 点击按钮,可以清晰地看到:

  1. 紫色长条(Rendering):布局 + 绘制阶段
  2. 绿色长条(Painting):栅格化 + 合成
  3. 红色三角:长任务警告(>50ms)

核心知识点拆解

1. DOM 树构建:HTML 解析器的工作方式

HTML 解析器是一个”状态机”——它不像 XML 解析器那样是上下文无关文法,因为 HTML 对错误有极高的容忍度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 模拟 HTML 解析器的 Tokenization 阶段
class HTMLTokenizer {
  constructor(html) {
    this.html = html;
    this.pos = 0;
    this.state = 'DATA';   // 当前状态
    this.tokens = [];
  }

  tokenize() {
    while (this.pos < this.html.length) {
      switch (this.state) {
        case 'DATA':
          if (this.html[this.pos] === '<') {
            this.state = 'TAG_OPEN';
            this.pos++;
          } else {
            // 输出字符 token(文本节点)
            this.emitCharacter();
          }
          break;

        case 'TAG_OPEN':
          if (this.html[this.pos] === '/') {
            this.state = 'END_TAG_OPEN';
            this.pos++;
          } else if (this.html[this.pos] === '!') {
            this.state = 'MARKUP_DECLARATION';
            this.pos++;
          } else if (isAlpha(this.html[this.pos])) {
            this.state = 'TAG_NAME';
            this.startTag = { name: '', attrs: [] };
          } else {
            this.state = 'DATA';
          }
          break;

        case 'TAG_NAME':
          if (isWhitespace(this.html[this.pos])) {
            this.state = 'BEFORE_ATTR_NAME';
          } else if (this.html[this.pos] === '>') {
            this.emitStartTag();
            this.state = 'DATA';
          } else {
            this.startTag.name += this.html[this.pos];
          }
          this.pos++;
          break;
          
        // ... 其他状态类似
      }
    }
    return this.tokens;
  }

  emitCharacter() {
    let text = '';
    while (this.pos < this.html.length && this.html[this.pos] !== '<') {
      text += this.html[this.pos];
      this.pos++;
    }
    if (text) this.tokens.push({ type: 'text', value: text });
  }

  emitStartTag() {
    this.tokens.push({ type: 'startTag', name: this.startTag.name, attrs: this.startTag.attrs });
  }
}

// HTML 规范中的 80+ 种状态
// 特殊处理:
// - <noscript> 在不同脚本设置下行为不同
// - <textarea>/<title> 包裹的内容不是 HTML
// - 隐式标签闭合: <li> 遇到下一个 <li> 自动闭合
// - 容错: <table> 中不允许 <div>,浏览器会自动"修复"

Blink (Chromium) 的实现细节:HTMLToken 被构造后传递给 HTMLTreeBuilder,它会根据”插入模式”(Insertion Mode)决定如何处理 token。例如 InTable 模式遇到 <div> 会触发”foster parenting”——把 div 提到 table 外部。

解析的阻塞

  • <script> 标签(非 async/defer)会阻塞 HTML 解析,直到脚本下载和执行完成
  • <link rel="stylesheet"> 也会阻塞解析——浏览器等待 CSS 下载完成才会继续解析后面的 HTML
  • 这就是为什么 CSS 要放 <head> 里,JS 放 <body> 底部

2. CSSOM 构建:样式规则的计算

CSS 解析比 HTML 更传统——每个 CSS 文件(或 <style> 块)被解析成 StyleSheet 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// 模拟 CSSOM 构建
class CSSParser {
  constructor(css) {
    this.css = css;
    this.pos = 0;
  }

  parseStyleSheet() {
    const rules = [];
    while (this.pos < this.css.length) {
      this.skipWhitespace();
      if (this.pos >= this.css.length) break;
      
      // 识别 at-rule 或 普通规则
      if (this.css[this.pos] === '@') {
        rules.push(this.parseAtRule());
      } else {
        rules.push(this.parseRule());
      }
    }
    return { rules };
  }

  parseRule() {
    // 解析选择器
    const selector = this.parseSelector();
    this.skipWhitespace();
    
    // 解析声明块
    let declarations = {};
    this.pos++; // 跳过 '{'
    
    while (this.pos < this.css.length && this.css[this.pos] !== '}') {
      this.skipWhitespace();
      const [prop, value] = this.parseDeclaration();
      
      // 计算优先级
      const priority = this.calculateSpecificity(selector);
      
      declarations[prop] = { value, priority };
      this.skipWhitespace();
    }
    this.pos++; // 跳过 '}'
    
    return { selector, declarations };
  }
  
  // 选择器优先级计算 (Specificity)
  calculateSpecificity(selector) {
    // Specificity = (inline, id, class, element)
    // 用 256 进制编码: a * 256^3 + b * 256^2 + c * 256 + d
    let a = 0, b = 0, c = 0, d = 0;
    
    // #id → b++
    // .class, [attr] → c++
    // element → d++
    
    // 简化实现
    const idCount = (selector.match(/#/g) || []).length;
    const classCount = (selector.match(/\./g) || []).length;
    const attrCount = (selector.match(/\[/g) || []).length;
    const tagCount = (selector.match(/[a-z-]+/g) || []).length;
    
    b = idCount;
    c = classCount + attrCount;
    d = tagCount;
    
    return a * 1000000 + b * 10000 + c * 100 + d;
  }
}

// 样式级联
class StyleResolver {
  constructor(userAgentStyles, authorStyles) {
    this.rules = [...userAgentStyles, ...authorStyles];
  }

  // !important 标记
  // 来源优先级: 用户重要 > 作者重要 > 作者普通 > 用户普通 > 用户代理
  getComputedStyle(element, pseudoElement) {
    const matchedRules = [];
    
    for (const rule of this.rules) {
      if (rule.selector.matches(element, pseudoElement)) {
        matchedRules.push(rule);
      }
    }
    
    // 排序: 优先级高 > 低
    matchedRules.sort((a, b) => a.priority - b.priority);
    
    // 合并级联结果
    const computed = {};
    for (const rule of matchedRules) {
      Object.assign(computed, rule.declarations);
    }
    
    // 初始值 (inherit, initial, unset, revert)
    // 继承: 某些属性(如 color, font)从父元素继承
    return this.applyInheritance(element.parentElement, computed);
  }
}

3. 渲染树合成 (Attachment)

DOM 树 + CSSOM → 渲染树 的过程称为”Attachment”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 模拟渲染树合成
class RenderTreeBuilder {
  build(domNode, computedStyle) {
    if (!domNode || !this.isVisible(computedStyle)) {
      return null;  // display: none → 跳过
    }

    const renderNode = {
      domNode,
      style: computedStyle,
      children: [],
      layoutObject: null,  // 布局对象(后续填充)
      paintLayer: null,    // 绘制层(后续填充)
    };

    // 遍历子节点
    for (const child of domNode.children) {
      const childStyle = this.getComputedStyle(child);
      const childRenderNode = this.build(child, childStyle);
      if (childRenderNode) {
        renderNode.children.push(childRenderNode);
      }
    }

    return renderNode;
  }

  isVisible(style) {
    if (!style) return false;
    // display: none 和 visibility: hidden 的区别:
    if (style.display === 'none') return false;  // 不进渲染树
    // visibility: hidden → 进渲染树(占据空间),只是看不见
    return true;
  }
}

渲染树与 DOM 树的区别

  • display: none 元素 → 在 DOM 树中,不在渲染树中
  • ::before / ::after 伪元素 → 不在 DOM 树中,在渲染树中
  • visibility: hidden → 在渲染树中(有布局,不绘制)

4. 布局计算

布局是渲染管线中最”重”的阶段——计算每个渲染树节点在视口中的确切位置和大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// 简化的块级格式化上下文 (BFC) 布局
class BlockLayoutEngine {
  constructor(renderTree, viewport) {
    this.root = renderTree;
    this.viewport = viewport;
  }

  layout() {
    this.layoutBlock(this.root, {
      x: 0,
      y: 0,
      width: this.viewport.width,
      height: 0  // 初始未知
    });
  }

  layoutBlock(node, containingBlock) {
    const style = node.style;
    
    // 1. 计算盒模型宽度
    const widths = this.computeWidth(node, containingBlock);
    
    // 2. 确定 Y 位置(垂直方向累加)
    let currentY = containingBlock.y + this.getMarginTop(node);
    
    // 3. 依次布局子节点
    for (const child of node.children) {
      const childLayout = this.layoutBlock(child, {
        x: widths.contentLeft,
        y: currentY,
        width: widths.contentWidth,
        height: 0
      });
      
      currentY += childLayout.totalHeight;
      child.layoutResult = childLayout;
    }

    // 4. 计算自身高度
    const height = style.height || currentY - containingBlock.y + this.getPaddingBottom(node);
    
    // 5. 计算盒模型
    const layoutResult = {
      box: {
        x: widths.boxLeft,
        y: containingBlock.y,
        width: widths.boxWidth,
        height: height + this.getMarginTop(node) + this.getMarginBottom(node),
      },
      content: {
        x: widths.contentLeft,
        y: containingBlock.y + this.getMarginTop(node),
        width: widths.contentWidth,
        height: height,
      },
      marginBox: {
        top: this.getMarginTop(node),
        right: this.getMarginRight(node),
        bottom: this.getMarginBottom(node),
        left: this.getMarginLeft(node),
      },
      totalHeight: height + this.getMarginTop(node) + this.getMarginBottom(node),
    };

    node.layoutResult = layoutResult;
    return layoutResult;
  }

  computeWidth(node, parent) {
    const style = node.style;
    
    // 块级元素默认宽度 = 父宽度 - margin
    const parentWidth = parent.width;
    const marginLeft = this.parseLength(style.marginLeft || '0');
    const marginRight = this.parseLength(style.marginRight || '0');
    const paddingLeft = this.parseLength(style.paddingLeft || '0');
    const paddingRight = this.parseLength(style.paddingRight || '0');
    const borderLeft = this.parseLength(style.borderLeftWidth || '0');
    const borderRight = this.parseLength(style.borderRightWidth || '0');
    
    // 如果 width 明确指定
    const specifiedWidth = style.width 
      ? this.parseLength(style.width) 
      : parentWidth - marginLeft - marginRight - paddingLeft - paddingRight - borderLeft - borderRight;
    
    const contentWidth = specifiedWidth;
    const contentLeft = parent.x + marginLeft + paddingLeft + borderLeft;
    const boxWidth = contentWidth + paddingLeft + paddingRight + borderLeft + borderRight;
    const boxLeft = parent.x + marginLeft;
    
    return { contentWidth, contentLeft, boxWidth, boxLeft };
  }
}

// 浏览器中有约 45 种不同的布局算法:
// BlockFlowLayout — 块级元素 (div, p)
// FlexLayout — Flexbox 布局
// GridLayout — CSS Grid 布局
// TableLayout — 表格布局
// FloatLayout — 浮动元素
// AbsolutePositionLayout — 绝对定位
// SVG layout — SVG 文档
// MathML — 数学公式

布局的性能成本:一个包含 5000 个 DOM 节点的页面,完整布局大约需要 5-15ms。看似不多,但如果重复触发布局(如 JavaScript 在循环中频繁读写布局属性),就会引发 “Forced Synchronous Layout”(强制同步布局),瓶颈显著。

5. 绘制(Paint)

布局完成后,浏览器将每个绘制层转换为绘制指令列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 模拟绘制指令生成
class PaintGenerator {
  constructor() {
    this.commands = [];
  }

  paintNode(node) {
    if (!node.layoutResult) return;
    
    const box = node.layoutResult.box;
    const style = node.style;
    
    // 1. 背景
    if (style.backgroundColor && style.backgroundColor !== 'transparent') {
      this.commands.push({
        type: 'drawRect',
        rect: { x: box.x, y: box.y, w: box.width, h: box.height },
        color: style.backgroundColor,
        zIndex: style.zIndex || 0
      });
    }

    // 2. 背景图片
    if (style.backgroundImage) {
      this.commands.push({
        type: 'drawImage',
        rect: { x: box.x, y: box.y, w: box.width, h: box.height },
        src: style.backgroundImage,
        repeat: style.backgroundRepeat || 'no-repeat',
        zIndex: style.zIndex || 0
      });
    }

    // 3. 边框
    if (style.borderWidth > 0) {
      this.commands.push({
        type: 'drawBorder',
        rect: { x: box.x, y: box.y, w: box.width, h: box.height },
        width: style.borderWidth,
        color: style.borderColor,
        radius: style.borderRadius || 0
      });
    }

    // 4. 阴影
    if (style.boxShadow) {
      this.commands.push({
        type: 'drawShadow',
        rect: { x: box.x, y: box.y, w: box.width, h: box.height },
        shadow: style.boxShadow
      });
    }

    // 5. 文本内容
    if (node.domNode?.nodeType === Node.TEXT_NODE) {
      this.commands.push({
        type: 'drawText',
        text: node.domNode.textContent,
        x: box.x,
        y: box.y + this.getBaseline(style),
        font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
        color: style.color,
      });
    }

    // 6. 子节点
    for (const child of node.children) {
      this.paintNode(child);
    }
  }
}

绘制指令生成后存放在 Display List 中,这是一个包含所有绘制操作的列表。然后通过 Skia(Chromium 的 2D 图形库)栅格化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 简化自 Skia: src/core/SkCanvas.cpp
// 绘制指令的栅格化

void SkCanvas::drawRect(const SkRect& rect, const SkPaint& paint) {
    // 1. 裁剪检查
    SkRect devRect;
    this->getTotalMatrix().mapRect(&devRect, rect);
    if (!devRect.intersects(this->getDeviceClipBounds())) {
        return; // 不在视口范围内,跳过
    }

    // 2. 抗锯齿处理
    SkPath path;
    if (paint.isAntiAlias()) {
        path.addRoundRect(rect, ...);
        // 使用 4x MSAA 或更高效的覆盖率计算
    }

    // 3. 逐像素填充
    SkBitmap* bitmap = this->getDevice()->accessBitmap(true);
    for (int y = devRect.fTop; y < devRect.fBottom; ++y) {
        for (int x = devRect.fLeft; x < devRect.fRight; ++x) {
            if (path.contains(x, y)) {
                SkColor color = paint.getColor();
                // 混合 (alpha blending, 颜色空间转换)
                *bitmap->getAddr32(x, y) = blend(
                    *bitmap->getAddr32(x, y), color
                );
            }
        }
    }
}

实战案例

案例:构建高性能图片瀑布流

一个 Pinterest 风格的图片瀑布流布局,同时测量渲染性能瓶颈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
<!-- waterfall.html — 高性能瀑布流 -->
<!DOCTYPE html>
<html>
<head>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  
  .gallery {
    position: relative;
    width: 1200px;
    margin: 0 auto;
  }

  .item {
    position: absolute;
    width: 280px;
    margin: 10px;
    overflow: hidden;
    border-radius: 8px;
    
    /* 使用 will-change 提示浏览器创建独立层 */
    will-change: transform;
    
    /* 触发动画使用仅合成的属性 */
    transition: transform 0.2s, box-shadow 0.2s;
  }

  .item:hover {
    transform: translateY(-4px) scale(1.02);
    box-shadow: 0 8px 24px rgba(0,0,0,0.15);
  }

  .item img {
    width: 100%;
    display: block;
    
    /* 图片加载前占位:使用 CSS 渐变 */
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  }

  .loading-more {
    text-align: center;
    padding: 40px;
    opacity: 0;
    transition: opacity 0.3s;
  }
  .loading-more.visible { opacity: 1; }
</style>
</head>
<body>
<div id="gallery" class="gallery"></div>
<div id="loading" class="loading-more visible">加载更多...</div>

<script>
class WaterfallLayout {
  constructor(container, options = {}) {
    this.container = container;
    this.colWidth = options.colWidth || 280;
    this.gap = options.gap || 20;
    this.colCount = 0;
    this.colHeights = [];
    this.itemCache = new Map();  // id → { element, height }
    this.intersectionObserver = null;
    this.rafPending = false;
    
    this.initLayout();
    this.setupInfiniteScroll();
  }

  initLayout() {
    const containerWidth = this.container.offsetWidth;
    this.colCount = Math.floor((containerWidth + this.gap) / (this.colWidth + this.gap));
    // 重新计算列宽以填满容器
    this.colWidth = (containerWidth - this.gap * (this.colCount - 1)) / this.colCount;
    this.colHeights = new Array(this.colCount).fill(0);
    
    this.container.style.position = 'relative';
  }

  // 批量添加元素 — 使用 DocumentFragment 减少回流
  addItems(items) {
    // 1. 使用 fragment 一次性添加
    const fragment = document.createDocumentFragment();
    
    items.forEach((item, i) => {
      const div = document.createElement('div');
      div.className = 'item';
      
      // 图片懒加载
      const img = document.createElement('img');
      img.dataset.src = item.url;
      img.loading = 'lazy';  // 原生懒加载
      img.alt = item.title;
      img.width = this.colWidth;
      img.height = item.height;  // 提前设置高度避免布局偏移

      const title = document.createElement('p');
      title.textContent = item.title;
      
      div.appendChild(img);
      div.appendChild(title);
      
      // 存储元数据
      div.dataset.index = i;
      div.dataset.height = item.height + 60;  // 图片 + 标题
      
      fragment.appendChild(div);
    });

    // 2. 一次性插入 DOM → 一次布局计算
    this.container.appendChild(fragment);

    // 3. 批量位置计算 (批量读 → 批量写)
    const positions = [];
    for (let i = 0; i < items.length; i++) {
      const element = this.container.children[this.container.children.length - items.length + i];
      const height = parseInt(element.dataset.height);
      
      // 找到最矮的列
      const minCol = this.colHeights.indexOf(Math.min(...this.colHeights));
      
      positions.push({
        element,
        x: minCol * (this.colWidth + this.gap),
        y: this.colHeights[minCol],
      });
      
      this.colHeights[minCol] += height + this.gap;
    }

    // 4. 批量写入位置 — 使用 RAF 保证帧边界
    requestAnimationFrame(() => {
      for (const pos of positions) {
        pos.element.style.left = pos.x + 'px';
        pos.element.style.top = pos.y + 'px';
        pos.element.style.width = this.colWidth + 'px';
        pos.element.style.height = parseInt(pos.element.dataset.height) + 'px';
      }
      
      // 更新容器高度
      this.container.style.height = Math.max(...this.colHeights) + 'px';
    });
  }

  // 懒加载 — 观察图片何时进入视口
  setupLazyLoad() {
    if ('IntersectionObserver' in window) {
      this.intersectionObserver = new IntersectionObserver(
        (entries) => {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              const img = entry.target;
              if (img.dataset.src) {
                img.src = img.dataset.src;
                delete img.dataset.src;
                this.intersectionObserver.unobserve(img);
              }
            }
          });
        },
        { rootMargin: '200px 0px' }  // 提前 200px 预加载
      );

      // 观察所有懒加载图片
      this.container.querySelectorAll('img[data-src]').forEach(img => {
        this.intersectionObserver.observe(img);
      });
    } else {
      // 降级:直接加载
      this.container.querySelectorAll('img[data-src]').forEach(img => {
        img.src = img.dataset.src;
      });
    }
  }

  // 无限滚动
  setupInfiniteScroll() {
    const loading = document.getElementById('loading');
    
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loading.classList.add('visible');
          this.loadMore();
        }
      });
    }, { rootMargin: '100px' });
    
    observer.observe(loading);
  }

  // 性能测量
  measureLayout() {
    // 使用 Performance API
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'Layout') {
          console.log(`📐 布局耗时: ${entry.duration.toFixed(2)}ms`);
          console.log(`   DOM 节点数: ${entry.sourceFrameNumber}`);
        }
      }
    });
    
    try {
      observer.observe({ type: 'layout-shift', buffered: true });
    } catch(e) {}
  }

  async loadMore() {
    // 模拟异步加载图片数据
    const newItems = await this.fetchImages();
    this.addItems(newItems);
    this.setupLazyLoad();
  }
}

// 示例运行
const gallery = new WaterfallLayout(document.getElementById('gallery'));
gallery.loadMore();
</script>
</body>
</html>

底层原理

图层合成 (Compositing)

合成是渲染管线中最后一个阶段,也是性能优化的关键战场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 简化自 Chromium: cc/layers/picture_layer_impl.cc

class LayerImpl {
  // 决定一个元素是否成为独立图层的关键因素:
  // 1. 3D 或透视变换 (transform, perspective)
  // 2. video/canvas/iframe 等特殊元素 
  // 3. opacity < 1
  // 4. filter / clip-path
  // 5. will-change
  // 6. 包含合成层的子元素
  bool ShouldBePromotedToLayer(const RenderObject& object) {
    const ComputedStyle& style = object.Style();
    
    // 3D 变换 → 必须独立层
    if (style.Has3DTransform()) return true;
    
    // will-change → 开发者显式要求
    if (style.HasWillChangeHint()) return true;
    
    // 透明度 < 1 → 独立层优化渲染
    if (style.Opacity() < 1.0f) return true;
    
    // CSS 滤镜
    if (style.HasFilter()) return true;
    
    // 固定定位
    if (style.Position() == EPosition::kFixed) return true;
    
    return false;
  }
};

// 合成指令生成
class CompositorFrameBuilder {
  void BuildCompositorFrame() {
    for (const auto& layer : impl_->GetLayerTree()) {
      // 每个图层生成一个合成图块 (Tile)
      gfx::Rect layer_rect = layer->GetVisibleRect();
      
      // 对图层进行分块 (Tiling)
      // 默认分块大小为 256x256 或 512x512
      for (auto tile : layer->Tiles()) {
        // 检查是否在视口中
        if (!tile->IsVisible(viewport_rect_)) continue;
        
        // 对每个块发起栅格化任务
        RasterTask task;
        task.tile = tile;
        task.layer_id = layer->id();
        task.priority = CalculatePriority(tile);
        
        raster_worker_pool_->AddTask(std::move(task));
      }
    }
    
    // 收集所有图层图块 → 生成 RenderPass
    auto render_pass = cc::RenderPass::Create();
    for each visual rect in sorted by z-order:
        render_pass->AppendDrawQuad(...)
    
    // 最终 → 提交给 GPU
    compositor_->SubmitFrame(render_pass);
  }
}

关键渲染路径优化

从底层理解,优化渲染性能的关键是避免触发重布局和重绘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ❌ 坏代码:强制同步布局
function badAnimation() {
  for (let i = 0; i < 100; i++) {
    const box = document.getElementById('box');
    box.style.left = box.offsetLeft + 1 + 'px';
    // 每次循环都会触发强制布局:
    // 1. 读取 offsetLeft → 浏览器需要返回"当前"值
    // 2. 但 previous style 变更未应用 → 强制同步布局
  }
}

// ✅ 好代码:分批读写
function goodAnimation() {
  const box = document.getElementById('box');
  // 批量读
  let currentLeft = box.offsetLeft;
  
  // 批量写(无读操作打断)
  requestAnimationFrame(() => {
    box.style.left = (currentLeft + 100) + 'px';
  });
}

// Chrome DevTools 中检测强制布局
// Performance 面板 → Layout 事件 → 查看原因
// 或在代码中主动标记:
performance.mark('before-read');
const width = element.offsetWidth;  // 强制布局触发
performance.mark('after-read');
performance.measure('ForcedSyncLayout', 'before-read', 'after-read');

现代浏览器的惰性布局: 浏览器不会立即执行布局计算,而是将布局”标记为脏”(dirty bit),等待下一次帧的开始统一处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 简化自 Blink: core/dom/Document.cpp

class Document {
  bool layout_dirty_ = false;
  
  void SetNeedsLayout() {
    layout_dirty_ = true;
    // 注册生命周期更新
    ScheduleLifecycleUpdate();
  }
  
  // 在每一帧的开头统一执行生命周期更新
  void PerformLifecycleUpdate() {
    if (layout_dirty_) {
      PerformLayout();  // 一次性计算所有脏元素的布局
      layout_dirty_ = false;
    }
  }
};

// 但是!读取布局属性时的强制同步
int GetElementOffsetLeft(Element* element) {
  if (element->NeedsLayout() || document_->NeedsLayout()) {
    // 必须立即布局才能返回值
    PerformImmediateLayout();  // ⚠️ 性能杀手
  }
  return element->Box()->OffsetLeft();
}

高频面试题解析

面试题 1:浏览器从输入 URL 到页面渲染完成,中间发生了什么?(完整渲染流水线)

答案要点

这是一个经典面试题的进阶版——要求深入到渲染管线内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
1. 导航阶段
   1.1 DNS 解析: 浏览器 ⇄ DNS 服务器(获取 IP,通常 1-50ms)
   1.2 TCP 连接: 三次握手(如果 HTTPS,+ TLS 握手)
   1.3 HTTP 请求: 发送请求,获取 HTML

2. 解析阶段  
   2.1 HTML 解析 → Token → DOM 树
   2.2 发现 CSS → 发起 CSS 请求(阻塞解析)
   2.3 发现 JS → 发起 JS 请求(阻塞解析,等待 CSS 加载)

3. 样式阶段
   3.1 CSS 解析 → CSSOM 树
   3.2 DOM + CSSOM → 渲染树

4. 布局阶段
   4.1 计算每个元素的盒模型(位置、尺寸)
   4.2 递归遍历渲染树

5. 绘制阶段
   5.1 生成绘制指令列表(Display List)
   5.2 分块(Tiling)→ 栅格化(软件或 GPU)

6. 合成阶段
   6.1 图层合并(Compositing)
   6.2 提交到 GPU → 显示帧

7. 首次内容绘制(FCP)— 用户看到第一个像素

关键洞察:浏览器不是等所有步骤完成再显示页面的!它是”增量式”的——解析到一部分就立即渲染一部分(尤其是 Chrome 的”分块渲染”)。这解释了为什么你可以看到页面上半部分显示出来,而下半部分还在加载。

面试题 2:什么是重排(Reflow)?什么时候会触发重排?如何避免?

答案要点

重排(在 Blink 中称为 Layout,在 Gecko 中称为 Reflow)是浏览器重新计算元素几何属性(位置、大小)的过程。

触发条件

  1. DOM 操作:添加、删除、修改元素
  2. 样式变化:修改 width/height/margin/padding/font-size/display 等几何属性
  3. 窗口缩放:resize 事件
  4. 计算偏移/大小:读取 offsetTop/offsetLeft/scrollTop/clientTop/getComputedStyle 等

内部机制: 浏览器使用”脏位系统”(Dirty Bit)。当一个元素需要重新布局时,在其上设置脏标记。在下一帧开始时,浏览器遍历所有脏元素及其子树进行布局。

优化策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 1. 批量修改样式(class 操作)
// ❌ 坏:多次单独修改
element.style.width = '100px';
element.style.height = '200px';    // 触发 2 次重排
element.style.marginLeft = '10px';

// ✅ 好:使用 class
element.className = 'new-style';  // 1 次重排

// 2. 缓存布局属性
// ❌ 坏:循环中读写
for (let i = 0; i < 1000; i++) {
  box.style.left = box.offsetLeft + 1 + 'px';  // 每次循环强制重排
}

// ✅ 好:先读后写
const left = box.offsetLeft;  // 读一次
box.style.left = left + 1000 + 'px';  // 写一次

// 3. 使用文档片段 (DocumentFragment)
const fragment = document.createDocumentFragment();
for (const item of items) {
  const el = document.createElement('div');
  el.textContent = item;
  fragment.appendChild(el);
}
container.appendChild(fragment);  // 1 次重排

// 4. 使用 display: none 批量操作
element.style.display = 'none';  // 1 次重排
// 对 element 的子节点做大量操作
element.appendChild(aLotOfChildren);
element.style.display = 'block';  // 1 次重排
// 总共 2 次(vs 1000 次)

// 5. 使用合成属性代替布局属性
// ❌ 修改 top/left → 触发重排
// ✅ 使用 transform → 仅合成,不触发重排
element.style.transform = 'translateX(100px)';

面试题 3:requestAnimationFrame 和 requestIdleCallback 在渲染流程中有什么区别?

答案要点

1
2
3
4
5
6
7
帧时间线 (60fps / 16.6ms 一帧):
|←-------- 16.6ms ----------→|

[事件处理] [rAF回调] [布局] [绘制] [合成] [空闲(rIC)]
  |---------- 帧活动 -------------||--- 帧空闲 ---|
                                    ↑ requestIdleCallback 在此执行
                                    (可能没有空闲时间!)

核心区别

特性requestAnimationFramerequestIdleCallback
执行时机帧开始,布局和绘制之前帧结束,剩余空闲时间
保证执行✅ 一定会执行(只要页面可见)❌ 可能一直不执行(无空闲时间)
时间预算应 < 16ms受 deadline.timeRemaining() 限制
用途更改 DOM、动画非关键任务、数据上报、日志
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// rAF: 用于视觉更新
function animate() {
  // 修改将在下一帧布局/绘制阶段生效
  element.style.transform = `translateX(${pos}px)`;
  pos += 5;
  requestAnimationFrame(animate);
}

// rIC: 用于后台非关键任务
function processAnalytics() {
  requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 5 && queue.length > 0) {
      const data = queue.shift();
      sendToAnalytics(data);
    }
    if (queue.length > 0) {
      // 没处理完 → 下一帧继续
      requestIdleCallback(processAnalytics);
    }
  }, { timeout: 2000 });  // 最多等 2 秒
}

加分项:rAF 的回调参数是 DOMHighResTimeStamp,不是时间余量。rIC 的 timeRemaining() 在页面空闲时最多 50ms(避免恶意页面一直占用主线程)。rAF 与 VSync(垂直同步信号)绑定,帧率由显示器刷新率决定。

总结与扩展

渲染管线核心知识图谱:

1
2
3
4
5
6
7
8
9
10
11
12
13
输入: HTML + CSS + JS
  ↓
解析 (Parse) — 时间关键: CSS <link> 与 <script> 的阻塞策略
  ↓
样式计算 (Style) — 选择器优化,减少特异性计算复杂度 O(n²)
  ↓
布局 (Layout) — 每一次布局都是 O(节点数),减少触发次数
  ↓
绘制 (Paint) — 将层提升为独立合成层,减少重绘面积
  ↓
合成 (Composite) — 使用 transform/opacity 实现亚像素级合成
  ↓
输出: 60fps 流畅画面

作为前端开发者,你需要理解渲染管线的每一阶段,才能写出高性能的代码。核心原则只有三条:

  1. 减少次数:避免频繁的 DOM 操作、CSS 修改
  2. 缩小范围:局部布局(position: absolute/fixed 的元素布局不扩散)优于全局布局
  3. 选择最优路径:合成属性 > 绘制属性 > 布局属性

下一步学习方向

  • Houdini API:Paint Worklet(自定义绘制)、Animation Worklet
  • OffscreenCanvas:在 Worker 线程中渲染,不阻塞主线程
  • WebGPU:下一代 GPU 编程 API,浏览器绘制性能的极限
  • Chrome DevTools Performance 面板:实战渲染性能分析
本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量