浏览器渲染管线深度拆解:从 Parse HTML 到 Composite Layers 的每一帧发生了什么

你写了一行 div.style.left = '100px',屏幕上的盒子就动了。

这中间到底发生了什么?如果你的回答是"浏览器重新渲染了",那基本等于说"电脑帮我算的"------正确但没用。

这篇把"浏览器渲染"这个黑盒拆开,看清里面每一个齿轮怎么咬合。搞明白这条管线,你才能理解为什么有些动画丝滑如德芙,有些卡得像 PPT。


一、全景图:一帧的生命周期

先给一张地图,免得后面迷路。

css 复制代码
Parse HTML → DOM Tree
       ↓
Parse CSS → CSSOM Tree
       ↓
DOM + CSSOM → Render Tree
       ↓
Layout(布局)
       ↓
Paint(绘制)
       ↓
Composite(合成)
       ↓
屏幕上的像素

每一步都有明确的输入和输出。跳过任何一步,屏幕上就是一片白。

但重点来了------不是每次更新都要跑完整条线 。改个颜色不用重新布局,改个 transform 甚至不用重新绘制。这才是性能优化的核心心智模型。


二、Parse:从字符串到树

HTML → DOM Tree

html 复制代码
<div class="container">
  <p>hello</p>
  <img src="cat.jpg" />
</div>

<!--
  词法分析器(Tokenizer)把 HTML 切成 token 流:
  StartTag:div → StartTag:p → Text:"hello" → EndTag:p → SelfClosingTag:img → EndTag:div
  然后按嵌套关系组装成 DOM 树
-->

经典坑:HTML 解析是可被阻塞的

html 复制代码
<head>
  <!-- 这个 script 会阻塞 DOM 解析 -->
  <!-- 浏览器:"等等,万一这脚本里有 document.write 呢?我得停下来" -->
  <script src="heavy-bundle.js"></script>
</head>
<body>
  <!-- 上面的 JS 没下载完执行完,这里的 DOM 一个都不会构建 -->
  <div id="app"></div>
</body>

所以 deferasync 不是锦上添花,是必需品。浏览器在等 JS 下载时,整棵 DOM 树的构建就停在那里------用户看到的是白屏。

CSS → CSSOM Tree

CSS 解析独立进行,生成一棵 CSSOM(CSS Object Model)树。但 CSS 会阻塞渲染------浏览器拒绝在 CSSOM 构建完成前渲染任何东西。

为什么?如果先渲染无样式内容,CSS 加载完再闪一下变正常,这就是 FOUC(Flash of Unstyled Content)。浏览器宁愿让你多等 200ms 白屏,也不愿意闪你一脸。


三、Render Tree:真正要画的东西

DOM Tree + CSSOM Tree 合并,生成 Render Tree。

Render Tree 里没有不可见元素:

css 复制代码
/* display: none → 从渲染世界蒸发,不参与后续任何步骤 */
.hidden {
  display: none; /* 不出现在 Render Tree 中 */
}

/* visibility: hidden → 隐身,但位置还在,布局还得算 */
.invisible {
  visibility: hidden; /* 仍然出现在 Render Tree 中 */
}

频繁切换显示状态时,visibilitydisplay 便宜得多------后者每次切换都要重建 Render Tree 的一部分。


四、Layout:算位置

Layout(也叫 Reflow)要回答一个问题:每个元素在屏幕上的确切位置和大小是多少?

一个元素的位置取决于它的父元素、兄弟元素、子元素、甚至完全不相关的元素(float 和绝对定位表示有话说)。复杂度远比你想的高。

ts 复制代码
// 触发 Layout 的属性(部分):
// width, height, padding, margin, border
// top, left, right, bottom
// font-size, line-height
// display, position, float

const el = document.getElementById('box')

el.style.width = '200px'  // 标记:需要 Layout
el.style.height = '100px' // 标记:需要 Layout
el.style.margin = '10px'  // 标记:需要 Layout
// 浏览器会把这三次修改攒成一次 Layout(批处理)

// ❌ 但如果你这样写------
el.style.width = '200px'
const h = el.offsetHeight   // 读取布局信息 → 浏览器被迫立即 Layout!
el.style.height = '100px'
const w = el.offsetWidth     // 又读了 → 又得 Layout 一次!

强制同步布局(Forced Synchronous Layout)

性能杀手 Top 1。

浏览器本来想攒一攒、统一处理,但你在写入之间插了一次读取,浏览器只能被迫立即结算。

类比:你在超市一边往购物车里放东西一边让收银员结账,收银员每放一件就得扫一次码算一次总价。正常人都会买完再结,对吧?

ts 复制代码
// ❌ 经典反模式:循环中读写交替
const items = document.querySelectorAll('.item')
items.forEach(item => {
  const width = item.offsetWidth       // 读 → 强制 Layout
  item.style.width = (width * 2) + 'px' // 写 → 下次读又得 Layout
  // N 个元素 = N 次 Layout,页面直接卡死
})

// ✅ 读写分离:先统一读,再统一写
const widths = [...items].map(item => item.offsetWidth) // 读(一次 Layout)
items.forEach((item, i) => {
  item.style.width = (widths[i] * 2) + 'px' // 写(攒成一次 Layout)
})

React 或 Vue 的虚拟 DOM batch update 帮你避免了大部分这类问题。但操作 Canvas、做拖拽、写自定义组件时,这个坑随时等着你。


五、Paint:画像素

Layout 算出了"在哪",Paint 决定"画成什么样"。

Paint 把每个元素转换成一组绘制指令(paint records):

csharp 复制代码
1. 在 (10, 20) 画一个 200x100 的矩形,填充 #fff
2. 在 (10, 20) 画一个 1px 边框,颜色 #ccc
3. 在 (20, 35) 绘制文本 "Hello",字号 16px,颜色 #333

// Paint 不直接输出像素
// 真正的像素填充(Rasterize,光栅化)在合成阶段由 GPU 或独立线程完成

哪些属性触发 Paint?

ts 复制代码
// 只触发 Paint(不触发 Layout)的属性:
// color, background, box-shadow, border-radius, outline, visibility

el.style.backgroundColor = 'red' // ✅ 跳过 Layout,直接 Paint → Composite
el.style.width = '300px'          // ❌ Layout → Paint → Composite 全跑

实用原则:能用只触发 Paint 的属性解决的,别动 Layout 属性。


六、Composite:合成------性能优化的终极武器

什么是合成?

浏览器把页面拆成多个图层(Layers),每个图层独立绘制,最后由 GPU 把所有图层叠在一起------就像 Photoshop 的图层合并。

yaml 复制代码
图层 1: 页面背景 + 文字内容
图层 2: 固定导航栏(position: fixed)
图层 3: 那个正在做动画的弹窗

为什么要分层?弹窗在动,只需要移动它所在的图层,其他图层纹丝不动。 不分层的话,弹窗每动一帧,整个页面都要重新 Paint。

哪些属性只走 Composite?

ts 复制代码
// transform 和 opacity 是合成层的 VIP

// ❌ 用 left/top 做动画 → 每帧都触发 Layout + Paint
el.style.left = x + 'px'      // Layout → Paint → Composite

// ✅ 用 transform 做动画 → 只触发 Composite
el.style.transform = `translateX(${x}px)` // 直接 Composite,GPU 搞定

// 一个是坐绿皮火车,一个是坐高铁,目的地一样,体验天差地别

所有性能优化指南都在喊"用 transform 代替 left/top",不是玄学,是因为走了完全不同的管线路径。

主动提升合成层

css 复制代码
/* 告诉浏览器:这个元素将来会变,提前给它一个独立图层 */
.animate-target {
  will-change: transform;
}

/* 经典 hack(不推荐,但你一定见过) */
.force-layer {
  transform: translateZ(0); /* 骗浏览器创建独立合成层 */
}

但图层不是免费的------每个图层都需要显存。滥用反而更卡。


七、为什么浏览器不把所有元素都放到独立图层?

显存是有限的。

一个 1920x1080 的图层,32 位色深,需要 1920 × 1080 × 4 bytes ≈ 8MB。50 个元素各搞一个图层?400MB 显存没了。手机用户直接白屏。

这就是 Layer Explosion(图层爆炸)

css 复制代码
/* ❌ 千万别这么干 */
* {
  will-change: transform; /* 内存爆炸 */
}

/* ✅ 只给真正需要动画的元素提升图层 */
.modal-overlay {
  will-change: opacity;
}
.slider-track {
  will-change: transform;
}

浏览器的策略:默认最少图层,只在有明确理由时才分层。经典的空间换时间------多用一点显存,省掉大量重绘开销。但空间有上限,不能滥用。


八、一帧 16.6ms 内的完整时间线

屏幕刷新率 60fps,每帧预算 16.6ms:

css 复制代码
[JS 执行] → [Style 计算] → [Layout] → [Paint] → [Composite] → 🖥️
  ↑ 你的代码跑在这里

  如果你的 JS 跑了 15ms,留给渲染管线的只有 1.6ms
  大概率------掉帧了

requestAnimationFrame 的正确理解

ts 复制代码
// rAF 的回调在"Style 计算"之前执行
// 这是浏览器给你的"最后修改机会"

// ❌ 用 setTimeout 做动画
setTimeout(() => {
  el.style.transform = `translateX(${x}px)`
}, 16) // 16ms ≈ 一帧?不,时机完全不可控

// ✅ 用 rAF 做动画
requestAnimationFrame(() => {
  el.style.transform = `translateX(${x}px)`
  // 保证在下一帧渲染前执行,时机精确
})

setTimeout(fn, 16)requestAnimationFrame(fn) 的区别不是精度问题------它们在事件循环中的执行时机完全不同。rAF 是被渲染管线调度的,setTimeout 是被任务队列调度的。


九、实战:一个列表滚动卡顿的排查

ts 复制代码
// 场景:10000 行的虚拟列表,滚动时明显掉帧
// 打开 DevTools → Performance 面板 → 录制滚动
// 发现:每帧都有大面积紫色(Layout)

// ❌ 原因:滚动事件回调里读取了 offsetTop
window.addEventListener('scroll', () => {
  items.forEach(item => {
    const top = item.offsetTop        // 强制同步布局!
    item.style.display = top > threshold ? 'none' : 'block'
  })
})

// ✅ 用 IntersectionObserver 代替手动计算可见性
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // visibility 不触发 Layout,比 display 便宜
    entry.target.style.visibility = entry.isIntersecting ? 'visible' : 'hidden'
  })
})
items.forEach(item => observer.observe(item))

从"卡成 PPT"到"丝滑如黄油",改的不是算法,改的是对渲染管线的理解。


十、边界与风险

这套心智模型什么时候会失效?

  1. 浏览器实现差异。上面说的主要是 Chromium(Blink 引擎)的行为。Chrome 的合成策略和 Safari 不同,Firefox 又是另一套。
  2. 浏览器的启发式优化。现代浏览器会自动做隐式合成层提升之类的优化,你以为没提升但其实浏览器偷偷帮你做了。
  3. CSS Containment 改变了规则contain: layout 可以限制 Layout 的影响范围,让局部修改不触发全局重排。

容易踩的坑:

  • will-change 用完不清理,图层一直驻留显存
  • 以为 opacity: 0display: none 等价(前者仍在合成层中,仍然占空间)
  • scroll 事件里做同步 Layout 查询

十一、管线思维

浏览器渲染管线的本质是一条分阶段增量处理的流水线。这种模型到处都是:

  • 编译器:词法分析 → 语法分析 → IR → 优化 → 代码生成
  • 网络协议栈:物理层 → 数据链路层 → 网络层 → 传输层 → 应用层
  • CI/CD:Lint → Test → Build → Deploy

共性:每一步只做一件事,输出是下一步的输入;优化的关键是跳过不必要的步骤。

浏览器渲染优化归结为一句话:

能走 Composite 就不走 Paint,能走 Paint 就不走 Layout。管线跑得越短,帧率越高。

下次看到页面卡顿,别对着代码发呆------打开 Performance 面板,看紫色(Layout)、绿色(Paint)、黄色(JS)各占多少,问题就在那里。

不是玄学,是物理。

相关推荐
大雨还洅下2 小时前
前端手写: Promise封装Ajax
javascript
codeniu2 小时前
@logicflow/vue-node-registry 在 Vite 中无法解析的踩坑记录与解决方案
前端·javascript
Heo2 小时前
深入 React19 Diff 算法
前端·javascript·面试
滕青山2 小时前
个人所得税计算器 在线工具核心JS实现
前端·javascript·vue.js
颜酱3 小时前
从0到1实现LFU缓存:思路拆解+代码落地
javascript·后端·算法
炫饭第一名3 小时前
速通Canvas指北🦮——变形、渐变与阴影篇
前端·javascript·程序员
Neptune13 小时前
让我带你迅速吃透React组件通信:从入门到精通(上篇)
前端·javascript
小星哥哥3 小时前
JavaScript 动态导入 (Dynamic Imports)
javascript
流水白开3 小时前
前端设计模式
javascript·面试