附:浏览器是如何渲染页面的?

《HTML渲染演进之路》讲稿的附稿。用于让听众大概的理解演进中前端为什么要这样优化。

一个简单问题背后的复杂世界

想象一下这样的场景:你在浏览器地址栏输入 https://example.com,按下回车,几百毫秒后,一个精美的网页就出现在你眼前。这个过程看似简单,实际上是现代计算机科学的一个奇迹。

今天我们就来揭开这个黑盒子,看看浏览器是如何把一堆代码变成用户能看到、能交互的页面的。理解这个过程,能帮你:

  • 写出更高性能的代码 - 知道什么操作成本高,什么操作成本低
  • 调试性能问题 - 明白页面为什么卡顿,瓶颈在哪里
  • 做出更好的技术决策 - 比如什么时候用 CSS 动画,什么时候用 JavaScript

让我们从最基础的问题开始:浏览器拿到 HTML、CSS、JavaScript 之后,是怎么变成页面的?


第一节:浏览器渲染的基本流程

主要参考: 深入了解现代网络浏览器

1.1 从代码到像素:渲染管道的五个阶段

浏览器渲染页面的过程,我们称为渲染管道(Rendering Pipeline)。就像工厂的流水线一样,有着清晰的步骤:

flowchart LR A["HTML/CSS
字节流"] --> 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属性等),浏览器会:

  1. 创建图层树:主线程遍历布局树,确定哪些元素需要在独立图层中
  2. 分块光栅化:合成器线程将每个图层划分为图块(tiles),发送给光栅线程
  3. 生成绘制四边形:光栅线程完成后,合成器线程收集图块信息创建绘制四边形
  4. 合成帧:通过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 从单进程到多进程:现代浏览器的架构革命

浏览器是怎么处理这么复杂的流程的?这就要讲到浏览器的架构了。

早期的浏览器都是单进程的,所有功能都在一个进程里。问题很明显:一个标签页崩溃,整个浏览器就挂了。而且网页越来越复杂,单进程根本应付不过来。

现代浏览器采用了多进程架构,就像把不同的工作分给不同的团队:

graph TD A["Browser Process
浏览器主进程
📋 管理其他进程"] 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

这样设计的好处:

  1. 稳定性:一个标签页崩溃不影响其他标签页
  2. 安全性:每个页面都在独立的沙盒中运行
  3. 性能:不同的任务可以并行处理

重点关注渲染进程,因为我们写的前端代码主要在这里执行:

  • 主线程:执行 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)**解决了这个问题:

flowchart LR A["调用栈
(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已更新');
      });
    }
  }
});
相关推荐
拾光拾趣录5 分钟前
从“祖传”构造函数到 `class`
前端·javascript
wmm_会飞的@鱼9 分钟前
FlexSim-汽车零部件仓库布局优化与仿真
服务器·前端·网络·数据库·数学建模·汽车
yvvvy12 分钟前
从“按钮都不会点”到“能撸大厂 UI”:我用 react-vant 踢开组件库的大门!
前端·javascript
安然dn13 分钟前
Cropper.js:JS图像裁剪库
前端·javascript
Serendipity26114 分钟前
微服务架构
前端·微服务
Hilaku30 分钟前
深入background-image:你可能不知道的几个性能优化与高级技巧
前端·css
南岸月明32 分钟前
副业自媒体1年终于明白:为什么会表达的人,能量越来越强,更能赚到钱?
前端
Danny_FD1 小时前
Vue + Element UI 实现模糊搜索自动补全
前端·javascript
gnip1 小时前
闭包实现一个简单Vue3的状态管理
前端·javascript
斐济岛上有一只斐济1 小时前
后端程序员的CSS复习
前端