《HTML渲染演进之路》讲稿的附稿。用于让听众大概的理解演进中前端为什么要这样优化。
一个简单问题背后的复杂世界
想象一下这样的场景:你在浏览器地址栏输入 https://example.com
,按下回车,几百毫秒后,一个精美的网页就出现在你眼前。这个过程看似简单,实际上是现代计算机科学的一个奇迹。
今天我们就来揭开这个黑盒子,看看浏览器是如何把一堆代码变成用户能看到、能交互的页面的。理解这个过程,能帮你:
- 写出更高性能的代码 - 知道什么操作成本高,什么操作成本低
- 调试性能问题 - 明白页面为什么卡顿,瓶颈在哪里
- 做出更好的技术决策 - 比如什么时候用 CSS 动画,什么时候用 JavaScript
让我们从最基础的问题开始:浏览器拿到 HTML、CSS、JavaScript 之后,是怎么变成页面的?
第一节:浏览器渲染的基本流程
主要参考: 深入了解现代网络浏览器
1.1 从代码到像素:渲染管道的五个阶段
浏览器渲染页面的过程,我们称为渲染管道(Rendering Pipeline)。就像工厂的流水线一样,有着清晰的步骤:

字节流"] --> B["1、解析
(Parse)"] B --> C["2、样式计算
(Style)"] C --> D["3、布局
(Layout)"] D --> E["4、绘制
(Paint)"] E --> F["5、合成
(Composite)"] F --> G["用户看到页面"] style A fill:#e3f2fd style G fill:#c8e6c9
阶段1:解析(Parse) - 把文本变成浏览器能理解的结构

html
<!-- 浏览器收到这样的HTML -->
<div class="container">
<h1>欢迎来到我的网站</h1>
<p>这是一个段落</p>
</div>
css
/* 和这样的CSS */
.container {
width: 100%;
padding: 20px;
}
h1 {
color: blue;
font-size: 24px;
}
浏览器会把它们分别解析成:
- DOM 树:表示文档结构
- CSSOM 树:表示样式规则
⚠️ 重要 :当HTML解析器遇到<script>
标签时,会暂停DOM构建去执行JavaScript,因为JS可能会修改DOM结构(比如document.write()
)。这就是为什么建议将script标签放在body底部,或使用async
/defer
属性的原因。
阶段2:样式计算(Style) - 确定每个元素的最终样式

浏览器会遍历 DOM 树的每个节点,根据 CSS 规则计算出最终样式。比如:
javascript
// h1元素的计算样式可能是这样
const h1ComputedStyle = {
color: 'blue', // 来自CSS规则
fontSize: '24px', // 来自CSS规则
display: 'block', // 浏览器默认值
fontFamily: 'Times', // 继承自父元素
width: '1160px' // 根据父容器计算出的值
};
阶段3:布局(Layout) - 计算每个元素的位置和尺寸

有了样式信息,浏览器就知道每个元素长什么样了。接下来要计算它们在页面上的确切位置:
javascript
// 布局计算的结果
const layoutInfo = {
container: { x: 0, y: 0, width: 1200, height: 100 },
h1: { x: 20, y: 20, width: 1160, height: 32 },
p: { x: 20, y: 68, width: 1160, height: 20 }
};
阶段4:绘制(Paint) - 把元素"画"出来

浏览器为每个元素生成绘制指令,计算出元素大小、位置、层级、渲染顺序等,告诉GPU怎么画:
javascript
// 绘制指令(简化版)
const paintInstructions = [
{ type: 'fillRect', color: 'white', x: 0, y: 0, width: 1200, height: 100 },
{ type: 'drawText', text: '欢迎来到我的网站', x: 20, y: 45, color: 'blue' },
{ type: 'drawText', text: '这是一个段落', x: 20, y: 80, color: 'black' }
];
由于计算结构包括层级是树状结构的,所以万一需要更新(元素变动),这个成本是非常高的,从变化节点及其子节点都需要重新绘制。
阶段5:合成(Composite) - 把不同的图层组合起来

现代浏览器采用分层合成 的策略。如果页面有多个图层(比如有透明度、3D变换、will-change
属性等),浏览器会:
- 创建图层树:主线程遍历布局树,确定哪些元素需要在独立图层中
- 分块光栅化:合成器线程将每个图层划分为图块(tiles),发送给光栅线程
- 生成绘制四边形:光栅线程完成后,合成器线程收集图块信息创建绘制四边形
- 合成帧:通过IPC将合成器帧发送给GPU进程显示
合成的最大优势:无需主线程参与。即使JavaScript正在执行耗时任务,滚动和基于合成的动画依然可以流畅运行。
也正是因此,如果页面的设计是只需要合成就能完成的,页面流畅度、渲染成本很低,如果是涉及布局变动或重绘的,就必须依赖主线程,渲染成本就非常高。

1.2 性能的关键:什么操作最昂贵?
现在我们知道了渲染流程,那什么操作会让浏览器重新走一遍这个流程呢?
重排(Reflow) - 最昂贵的操作:
javascript
// 这些操作会触发重排,导致重新执行 Layout → Paint → Composite
element.style.width = '200px'; // 改变尺寸
element.style.display = 'none'; // 改变显示状态
element.style.fontSize = '20px'; // 改变字体大小
// 读取这些属性也会强制触发重排
const width = element.offsetWidth; // 浏览器必须先计算布局
const height = element.offsetHeight;
重绘(Repaint) - 中等开销:
javascript
// 这些操作只会触发 Paint → Composite
element.style.color = 'red'; // 改变颜色
element.style.backgroundColor = 'blue'; // 改变背景
element.style.boxShadow = '2px 2px 4px rgba(0,0,0,0.3)';
只触发合成 - 开销最小:
javascript
// 这些操作只触发 Composite,性能最好
element.style.transform = 'translateX(100px)'; // 位移变换
element.style.opacity = '0.5'; // 透明度变化
element.style.filter = 'blur(5px)'; // 滤镜效果
这就是为什么我们经常听到"用 transform 和 opacity 做动画性能更好"的原因!
1.3 从单进程到多进程:现代浏览器的架构革命
浏览器是怎么处理这么复杂的流程的?这就要讲到浏览器的架构了。
早期的浏览器都是单进程的,所有功能都在一个进程里。问题很明显:一个标签页崩溃,整个浏览器就挂了。而且网页越来越复杂,单进程根本应付不过来。
现代浏览器采用了多进程架构,就像把不同的工作分给不同的团队:

浏览器主进程
📋 管理其他进程"] A --> B["Renderer Process
渲染进程
🎨 负责渲染页面
(每个标签页一个)"] A --> C["Network Process
网络进程
🌐 处理网络请求"] A --> D["GPU Process
GPU进程
⚡ 硬件加速"] B --> B1["Main Thread
主线程
执行JS、计算样式布局"] B --> B2["Compositor Thread
合成线程
处理滚动、动画"] B --> B3["Raster Thread
栅格化线程
把绘制指令变成像素"] style A fill:#bbdefb style B fill:#c8e6c9 style B1 fill:#fff3e0 style B2 fill:#e1f5fe style B3 fill:#f3e5f5


这样设计的好处:
- 稳定性:一个标签页崩溃不影响其他标签页
- 安全性:每个页面都在独立的沙盒中运行
- 性能:不同的任务可以并行处理
重点关注渲染进程,因为我们写的前端代码主要在这里执行:
- 主线程:执行 JavaScript、DOM 操作、样式计算、布局计算
- 合成线程:处理滚动、动画,可以独立于主线程工作
- 栅格化线程:把绘制指令转换成实际的像素
这种架构为什么重要?因为它解释了很多前端性能现象。比如:
javascript
// 这个动画在主线程执行,可能被JS任务阻塞
element.style.left = '100px';
// 这个动画在合成线程执行,不会被JS阻塞
element.style.transform = 'translateX(100px)';
而这,就自然引出了我们下一个话题:为什么 JavaScript 会阻塞渲染?
第二节:JavaScript单线程模型与渲染的关系
现在我们知道了浏览器的多进程架构,但你可能注意到一个问题:在渲染进程中,JavaScript 执行、DOM 操作、样式计算、布局计算都在主线程上。
这就引出了一个关键问题:为什么 JavaScript 是单线程的?这个设计如何影响页面渲染?
2.1 单线程的设计理念
JavaScript 设计为单线程并不是技术限制,而是设计选择。想象一下,如果 JavaScript 是多线程的:
javascript
// 假设JavaScript是多线程的(实际不是)
// 线程1执行:
document.getElementById('myDiv').innerHTML = '线程1的内容';
// 线程2同时执行:
document.getElementById('myDiv').remove();
// 结果是什么?无法预测!这就是竞态条件
为了避免这种混乱和复杂的锁机制,JavaScript 采用了单线程 + 事件循环的模型。这个设计简单可预测,非常适合处理用户界面。
2.2 事件循环:单线程的异步解决方案

JavaScript Visualized: Event Loop, Web APIs, (Micro)task Queue: www.lydiahallie.com/blog/event-... Node.js animated: Event Loop: dev.to/nodedoctors...
但单线程有个问题:如果有耗时操作怎么办?JavaScript 通过**事件循环(Event Loop)**解决了这个问题:
(Call Stack)"] --> B{调用栈是否为空?} B -->|否| A B -->|是| C["检查微任务队列
(Microtask Queue)"] C --> D{有微任务?} D -->|是| E["执行一个微任务"] E --> C D -->|否| F["检查宏任务队列
(Macrotask Queue)"] F --> G{有宏任务?} G -->|是| H["执行一个宏任务"] H --> A G -->|否| I["更新渲染
(如果需要)"] I --> B style A fill:#ffcdd2 style I fill:#c8e6c9 style E fill:#fff3e0 style H fill:#e1f5fe
让我们看个具体例子:
javascript
console.log('1. 开始执行');
// 宏任务
setTimeout(() => {
console.log('4. 宏任务 - setTimeout');
}, 0);
// 微任务
Promise.resolve().then(() => {
console.log('3. 微任务 - Promise');
});
console.log('2. 同步代码结束');
// 输出顺序:1 → 2 → 3 → 4
为什么这个顺序很重要? 因为它直接影响渲染性能。注意流程图中的"更新渲染"步骤,它发生在:
- 所有微任务执行完成后
- 每个宏任务执行完成后
这意味着:
javascript
// 糟糕的写法:阻塞渲染
for (let i = 0; i < 1000000; i++) {
// 同步执行,阻塞主线程
someHeavyCalculation();
}
// 在这个循环执行期间,页面完全无响应
// 好的写法:不阻塞渲染
function processChunk(data, index = 0) {
const chunkSize = 1000;
const chunk = data.slice(index, index + chunkSize);
// 处理当前块
chunk.forEach(item => someHeavyCalculation(item));
if (index + chunkSize < data.length) {
// 让出主线程,允许渲染
setTimeout(() => processChunk(data, index + chunkSize), 0);
}
}
2.3 主线程阻塞对渲染的影响
这里是关键:渲染和 JavaScript 执行都在主线程上,它们是互斥的!
javascript
// 演示主线程阻塞
function blockMainThread() {
const start = Date.now();
// 阻塞主线程500ms
while (Date.now() - start < 500) {
Math.random(); // 做一些无意义的计算
}
}
// 用户点击按钮时
button.addEventListener('click', () => {
blockMainThread(); // 阻塞500ms
// 在这500ms内,页面完全无响应:
// - 无法滚动
// - 无法点击其他按钮
// - 动画停止
// - 输入框无法输入
});
检测长任务的工具:
javascript
// 使用 Performance API 检测长任务
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) { // 超过50ms被认为是长任务
console.warn(`检测到长任务: ${entry.duration.toFixed(2)}ms`);
console.log('任务详情:', entry);
}
}
});
observer.observe({ entryTypes: ['longtask'] });
2.4 现代解决方案:跳出单线程限制
既然主线程这么容易被阻塞,现代浏览器提供了几种解决方案:
Web Workers - 真正的多线程:
javascript
// 主线程代码
const worker = new Worker('heavy-calculation.js');
// 把重任务移到Worker线程
worker.postMessage({
type: 'PROCESS_DATA',
data: largeDataSet
});
worker.onmessage = (event) => {
// Worker线程计算完成,更新UI
const result = event.data;
updateUI(result); // 只有这个在主线程执行
};
// heavy-calculation.js (Worker线程)
self.onmessage = (event) => {
if (event.data.type === 'PROCESS_DATA') {
// 在独立线程中执行,不阻塞主线程
const result = processLargeData(event.data.data);
self.postMessage(result);
}
};
时间分片 - React 的解决方案:
javascript
// React Fiber的时间分片原理
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
// 执行一小块工作
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
// 还有工作要做,让出控制权给浏览器
requestIdleCallback(workLoop);
}
}
requestIdleCallback(workLoop);
合成线程的独立性 - 最重要的优化:
javascript
// 这些动画在合成线程执行,不受主线程阻塞影响
element.style.transform = 'translateX(100px)';
element.style.opacity = '0.5';
// 即使主线程被阻塞,这些动画依然流畅
blockMainThread(); // 主线程阻塞
// transform 和 opacity 动画继续流畅运行
这就解释了为什么我们在做性能优化时总是强调要用 transform 和 opacity!
第三节:性能优化的实战应用
现在我们了解了浏览器渲染的原理和 JavaScript 的执行模型,让我们看看如何在实际开发中应用这些知识。
3.1 避免强制同步布局
什么是强制同步布局?
javascript
// 不好的例子:强制同步布局
function badExample() {
// 修改样式
element.style.width = '200px';
// 立即读取布局属性,强制浏览器同步计算布局
const height = element.offsetHeight; // 💥 强制同步布局!
// 基于读取的值进行更多修改
element.style.height = height + 'px';
}
// 好的例子:批量处理
function goodExample() {
// 先读取所有需要的值
const height = element.offsetHeight;
// 然后批量修改样式
element.style.width = '200px';
element.style.height = height + 'px';
}
布局抖动(Layout Thrashing)的例子:
javascript
// 极糟糕的例子:每次循环都触发布局
function layoutThrashing() {
for (let i = 0; i < elements.length; i++) {
elements[i].style.left = elements[i].offsetLeft + 10 + 'px'; // 💥💥💥
}
}
// 优化版本:分离读写操作
function optimizedLayout() {
// 第一次循环:只读取
const positions = elements.map(el => el.offsetLeft);
// 第二次循环:只写入
elements.forEach((el, i) => {
el.style.left = positions[i] + 10 + 'px';
});
}
3.2 利用CSS动画的性能优势
使用 transform 和 opacity:
css
/* 不好:会触发布局 */
.bad-animation {
transition: left 0.3s ease;
}
.bad-animation:hover {
left: 100px; /* 触发 Layout → Paint → Composite */
}
/* 好:只触发合成 */
.good-animation {
transition: transform 0.3s ease;
}
.good-animation:hover {
transform: translateX(100px); /* 只触发 Composite */
}
创建合成层:
css
.animated-element {
will-change: transform; /* 提示浏览器这个元素会变化 */
/* 或者 */
transform: translateZ(0); /* 强制创建合成层 */
}
3.3 JavaScript性能优化技巧
使用 requestAnimationFrame:
javascript
// 不好:可能在错误的时机执行
function badAnimation() {
element.style.transform = `translateX(${position}px)`;
setTimeout(() => {
position += 1;
if (position < 100) badAnimation();
}, 16); // 假设16ms约等于60fps
}
// 好:与浏览器渲染同步
function goodAnimation() {
element.style.transform = `translateX(${position}px)`;
position += 1;
if (position < 100) {
requestAnimationFrame(goodAnimation);
}
}
避免布局计算:
javascript
// 使用getBoundingClientRect的缓存版本
class ElementCache {
constructor(element) {
this.element = element;
this.cache = null;
}
getBounds() {
if (!this.cache) {
this.cache = this.element.getBoundingClientRect();
}
return this.cache;
}
invalidate() {
this.cache = null;
}
}
3.4 现代框架的优化策略
React的时间分片:
javascript
// React 18的并发渲染
function App() {
const [items, setItems] = useState([]);
// 使用startTransition包装非紧急更新
const handleAddItems = () => {
startTransition(() => {
setItems(prev => [...prev, ...newItems]); // 可中断的更新
});
};
return (
<div>
{/* 紧急更新:用户输入 */}
<input onChange={handleUserInput} />
{/* 非紧急更新:大列表渲染 */}
<Suspense fallback={<Loading />}>
<LargeList items={items} />
</Suspense>
</div>
);
}
Vue的异步更新:
javascript
// Vue会自动批量更新
new Vue({
data: { count: 0 },
methods: {
handleClick() {
this.count++; // 不会立即更新DOM
this.count++; // 不会立即更新DOM
this.count++; // 不会立即更新DOM
// 下一个tick才会批量更新DOM
this.$nextTick(() => {
console.log('DOM已更新');
});
}
}
});