【前端性能揭秘】从输入URL到页面展示,浏览器背后都干了啥?(深度剖析渲染、回流与重绘)

177 阅读11分钟

作为前端开发者,我们每天都在和浏览器打交道。我们写的 HTML、CSS、JavaScript 代码,最终都是由浏览器这个“翻译官”呈现给用户的。但你有没有想过,从你在浏览器地址栏输入一个网址,到看到色彩斑斓的页面,这中间到底发生了什么?

最近我深入学习了浏览器的渲染机制,特别是回流(Reflow)和重绘(Repaint)这两个核心概念,感觉对性能优化的理解又上了一个台阶。今天,我想把我的学习笔记和思考分享给大家,希望能用最简单易懂的方式,把这个过程讲清楚。

一、宏观视角:从 URL 到页面的“五步走”

我们可以把整个过程粗略地分为五大步:

  1. 处理输入 (Processing) :你输入 "juejin.cn",浏览器开始解析,判断这是个网址还是搜索关键词。
  2. 导航 (Navigation) :浏览器向服务器发起网络请求,获取页面的 HTML 文件。这个过程涉及到 DNS 查询、TCP 连接、HTTP 请求等网络知识。
  3. 解析 (Parsing) :浏览器拿到了 HTML 文件,但它看不懂这些文本。于是,它开始解析 HTML,构建一个叫做 DOM (Document Object Model)  的树形结构。可以理解为,浏览器把你的 HTML 代码在内存里建成了一个“家谱”,每个标签都是一个家庭成员。

DOM树(文档对象模型树):将HTML文档解析为结构化对象树,每个节点对应HTML中的标签、文本或属性。

  1. 渲染 (Rendering) :有了“家谱”还不够,还得知道每个成员该穿什么衣服(样式)。浏览器会解析 CSS 文件,构建 CSSOM (CSS Object Model) 。然后,它会把 DOM 和 CSSOM 结合起来,生成一棵 渲染树 (Render Tree) 。这棵树只包含需要显示在页面上的节点以及它们的样式信息(比如 display:none 的节点就不会出现在渲染树里)。

CSSOM树(CSS对象模型树) :将CSS样式规则解析为结构化对象树,包含所有样式信息及其优先级。

渲染树(Render Tree) :DOM树 + CSSOM树结合产物,仅包含可见元素

  1. 绘制 (Painting) :万事俱备!浏览器根据渲染树,计算出每个节点在屏幕上的确切位置和大小(这个过程叫 布局 Layout),然后调用 GPU 将这些节点一个个地“画”在屏幕上,最终呈现出我们看到的页面。
graph TD
    A[HTML 文件] --> B{解析 HTML};
    B --> C[DOM 树];

    D[CSS 文件] --> E{解析 CSS};
    E --> F[CSSOM 树];

    C & F --> G{渲染树 Render};
    G --> H{布局 Layout}; 
    H --> I{绘制 Paint};

    subgraph "1. 解析 (Parsing)"
        B
        E
    end

    subgraph "2. 渲染 (Rendering)"
        G
        H
        I
    end

    style C fill:#f9f,stroke:#333,stroke-width:2px
    style F fill:#ccf,stroke:#333,stroke-width:2px
    style G fill:#9cf,stroke:#333,stroke-width:2px
    style H fill:#f99,stroke:#333,stroke-width:2px
    style I fill:#9f9,stroke:#333,stroke-width:2px

二、微观聚焦:回流 (Reflow) 与重绘 (Repaint)

上面第五步提到的“布局”和“绘制”,就是我们今天要深入探讨的主角——回流重绘。它们是页面发生变化时,浏览器更新视图的两种方式。

1. 什么是回流 (Reflow)?

回流,也叫重排 (Layout)

定义:当一个元素的几何属性(比如宽度、高度、内外边距、位置等)发生改变,影响了它在文档流中的“占地面积”或位置时,浏览器需要重新计算整个或部分文档的布局,这个过程就叫回流。

可以把它想象成“重新搞装修” 。你把客厅的一面墙砸了(改变了元素的几何属性),这不仅仅是墙本身的事,还会影响到客厅的面积、家具的摆放位置,甚至可能影响到隔壁房间的结构。这个“牵一发而动全身”的过程,就是回流。

看个例子:

const myDiv = document.getElementById('my-div');

// 下面这行代码会触发回流
myDiv.style.width = '500px';

当我们把 my-div 的宽度从 300px 改成 500px 时,它变胖了,可能会挤到旁边的元素,浏览器必须重新计算它和它周围元素的布局。

回流的代价是昂贵的,因为它通常会导致其所有子元素以及文档中紧随其后的元素发生回流。

2. 什么是重绘 (Repaint)?

重绘,也叫重画 (Paint)

定义:当一个元素的外观属性(比如颜色 color、背景色 background-coloroutline 等)发生改变,但不影响它的几何布局时,浏览器只需要重新把这个元素“画”一遍,这个过程就叫重绘。

可以把它想象成“给墙刷个新漆” 。我们只是把墙的颜色从白色刷成了蓝色,墙的大小和位置都没变,自然也不会影响到家具的摆放。这个过程开销就小多了。

const myDiv = document.getElementById('my-div');

// 下面这行代码只会触发重绘
myDiv.style.color = 'red';

我只是把 my-div 的文字颜色改成了红色,它的尺寸和位置都没变,所以浏览器只需要重绘它即可,不需要回流。

关键关系:回流必将导致重绘

记住这个结论:回流一定会触发重绘,但重绘不一定会触发回流。

这很好理解,当我们“重新搞装修”(回流)的时候,墙都砸了,肯定得重新刷漆(重绘)吧?但我们可以只是“重新刷漆”(重绘),不一定需要砸墙(回流)。这样就很好理解回流和重绘的概念。

三、实践出真知:如何避免不必要的回流和重绘?

既然回流的代价那么大,我们在日常开发中就应该尽量避免或减少它。

比如说:

假设我们需要动态修改一个盒子的多个样式。

// 不推荐的写法
const box = document.getElementById('box');

console.time('bad-way');
box.style.width = '200px'; // 触发回流+重绘
box.style.height = '200px'; // 再次触发回流+重绘
box.style.margin = '20px'; // 再次触发回流+重绘
console.timeEnd('bad-way');

上面的代码,每次修改 style 属性,都可能导致一次回流。浏览器很聪明,可能会把这些操作优化合并,但我们不能依赖它的“心情”。这种写法在逻辑上是多次独立的修改,有潜在的性能风险。

优化方案一:使用 class 统一修改

这是最推荐、最常用的方法。

/* 在 CSS 文件中提前定义好样式 */
.active {
  width: 200px;
  height: 200px;
  margin: 20px;
}
// 推荐的写法
const box = document.getElementById('box');
box.classList.add('active'); // 只触发一次回流+重绘

通过切换 class,我们将多次样式更改合并为一次操作,浏览器只需要进行一次回流和重绘,性能显著提升。

优化方案二:批量修改 cssText

如果你不想新增 class,也可以使用 style.cssText

const box = document.getElementById('box');
box.style.cssText = 'width: 100px; height: 100px; margin: 10px;';

这种方式也是将多次修改合并为一次,效果和 classList 类似。

优化方案三:分离读写操作

首先,我们要理解浏览器的一个工作机制——渲染队列(Render Queue)

当你用 JavaScript 修改元素的样式时,浏览器并不会马上就去执行回流和重绘。它会“懒惰”一下,把这些修改操作(我们称之为“写”操作)先存到一个队列里。然后,它会等待一个合适的时机(比如浏览器空闲了,或者队列里的操作够多了),再把队列里的所有操作一次性拿出来,进行一次回流和重绘。

这种“攒一批再处理”的机制非常聪明,它避免了我们每改一个样式就回流一次,大大提升了性能。

然而,这种“懒惰”的优化机制在某些情况下会被我们自己不经意间的代码给打破。

当我们在执行了 “写” 操作(修改样式)之后,立即执行一个 “读” 操作,去获取需要经过计算才能得到的布局信息时,问题就来了。

常见的“读”操作就是访问那些会返回元素尺寸或位置的属性,例如:

  • offsetTopoffsetLeftoffsetWidthoffsetHeight
  • scrollTopscrollLeftscrollWidthscrollHeight
  • clientTopclientLeftclientWidthclientHeight
  • getComputedStyle()
  • getBoundingClientRect()

为了给你一个绝对精确的值,浏览器必须立即清空它刚刚攒下的“渲染队列”,马上执行回流和重绘,然后才能计算出你想要的属性值。这个过程就叫做 强制同步回流(Forced Synchronous Layout)

它打破了浏览器的优化机制,把异步的批量处理变成了同步的单个处理,性能开销自然就大了。

代码演示:错误与正确的写法对比

假设我们有一个需求:获取一个 div 列表,然后把它们的宽度设置成和第一个 div 的宽度一样。

// css样式
.box {
  width: 100px; /* 初始宽度 */
  margin-bottom: 10px;
  background-color: lightcoral;
}

<...>

<div class="box">Box 1</div>
<div class="box">Box 2</div>
<div class="box">Box 3</div>

1. 错误的写法:读写交错

在这种写法中,我们在一个循环里,每次都先读取宽度,然后马上设置宽度。

function updateWidthsBad() {
  const boxes = document.querySelectorAll('.box');
  // 1. 读取第一个盒子的宽度
  const firstBoxWidth = boxes[0].offsetWidth; 

  for (let i = 0; i < boxes.length; i++) {
    // 2. 写入(修改)第二个及以后盒子的宽度
    boxes[i].style.width = firstBoxWidth + 'px'; 
}

虽然我们只在循环外读了一次 firstBoxWidth,但如果循环内有其他读取操作,问题会更严重。

让我们用一个更清晰的“交错读写”的例子来说明问题。假设需求是:动态设置一系列 divmargin-left,值等于它前一个兄弟元素的 offsetWidth

function forceReflow() {
  const boxes = document.querySelectorAll('.box');
  for (let i = 1; i < boxes.length; i++) {
    // 读取前一个元素的 offsetWidth
    const prevWidth = boxes[i - 1].offsetWidth; 
    
    // 写入当前元素的 margin-left
    boxes[i].style.marginLeft = prevWidth + 'px'; 
  }
}

分析:

  • 第一次循环 (i=1) :读取 boxes[0] 的 offsetWidth (读),然后设置boxes[1]marginLeft (写)。
  • 第二次循环 (i=2) :需要读取 boxes[1] 的 offsetWidth。但是,boxes[1] 的布局可能因为上一步 marginLeft 的改变而受到了影响。为了得到精确的 offsetWidth,浏览器必须立即执行回流,来应用 marginLeft 的样式。
  • 第三次循环...  以此类推。

在这个循环中,我们执行了 (n-1) 次“读”和 (n-1) 次“写”,它们交错进行,导致了 (n-1) 次强制同步回流。这种做法大大的降低了性能。

2. 正确的写法:分离读写

优化的思路很简单:把所有的“读”操作集中在一起先完成,再把所有的“写”操作集中在一起完成。

function separateReadWrite() {
  const boxes = document.querySelectorAll('.box');
  const widths = []; // 用一个数组来存储所有读取到的值

  // 第一步:批量读取
  // 在这个循环里,我们只做“读”操作
  for (let i = 0; i < boxes.length - 1; i++) {
    widths.push(boxes[i].offsetWidth);
  }

  // 第二步:批量写入
  // 在这个循环里,我们只做“写”操作
  for (let i = 1; i < boxes.length; i++) {
    // 从数组中获取已经读好的值,而不是直接访问 DOM 属性
    boxes[i].style.marginLeft = widths[i - 1] + 'px'; 
  }
}
  • 第一个循环(批量读) :我们遍历元素,一次性地读取所有需要的 offsetWidth 值,并把它们存进一个普通的 JavaScript 数组 widths 中。这个过程可能只会触发一次回流(或者不触发,因为没有写操作)。
  • 第二个循环(批量写) :我们再次遍历元素,这次只进行 style 的修改。所有的“写”操作都会被放入渲染队列中。因为这个循环里没有任何“读”操作,所以不会打破浏览器的优化机制。
  • 最后:循环结束后,浏览器会将队列里的所有 marginLeft 修改操作,进行一次性的回流和重绘。

通过这种“先读后写”的分离,我们将 n-1 次强制回流优化成了最多只有 1 次回流。当操作的 DOM 元素数量非常多时,性能提升会非常显著。

核心思想:不要在循环中混合进行 DOM 的读写操作。如果需要,先把要读取的值(尺寸、位置等)缓存到变量或数组中,循环结束后再进行 DOM 的写操作。

总结

今天我们从宏观到微观,一起探索了浏览器的渲染世界。

  • 我们知道了浏览器通过导航、解析、渲染、绘制等步骤将代码变成页面。
  • 我们深入理解了回流(Layout)  和 重绘(Paint)  的概念和区别。
  • 我们学会了如何通过合并样式修改(使用 class 或 cssText)分离读写操作等方法来减少不必要的回流,从而优化页面性能。

理解这些底层原理,不仅能帮助我们写出更高性能的代码,也能让我们在面对复杂的面试题时更加从容。希望这篇学习笔记对你有所帮助!