回流与重绘的交锋与和解之道

74 阅读12分钟

最近在钻研前端布局,遭遇了好些棘手难题,像实现列式布局,还有理解 BFC 和 FFC 这些概念,过程中真是充满挑战。

在布局知识的探索里,我了解到 html 根元素作为最外层的第一个 BFC 元素,那可是相当关键。BFC,也就是块级格式化上下文,是 Web 页面可视化 CSS 渲染的重要部分,是块级盒子布局发生的区域,也是浮动元素与其他元素交互的地方 。在 BFC 这个独立渲染区域内,块级元素从上到下排列,行内元素从左到右排列,有了清晰的文档流,里面的元素布局和外面互不干扰。比如设置了overflow: hidden的容器,触发了 BFC 后,内部元素的布局就不会影响到外部元素。

说起列式布局,以前有人用table标签来实现,在table里用tr定义行,td定义单元格 。但现在都不推荐这么做了,为啥呢?首先,table布局会触发太多的回流和重绘,对页面性能影响很大;其次,table语义上是用来展示数据表的,用来做布局语义不符;最后,它的布局方式不够灵活,很难适应各种复杂的页面布局需求 。相比之下,现代的布局方式,像 Flexbox 和 Grid 布局,不仅灵活,性能上也更优,能有效减少回流和重绘的次数。

说到回流和重绘,这可是前端性能优化里绕不开的重要概念。

什么是回流

回流,英文叫 Reflow,也被称为重排 。当RenderTree(由DOM树和CSSOM树组合,包含所有的DOM节点及计算样式) 中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。简单来讲,当 DOM 元素的几何属性,比如宽度、高度、位置等发生变化时,就会触发回流 。这时候,浏览器得重新计算元素的几何属性,然后重新构建渲染树。

浏览器渲染页面的过程是这样的:首先解析 HTML,生成 DOM 树,同时解析 CSS,生成 CSSOM 树,接着将 DOM 树和 CSSOM 树结合,生成渲染树 (Render Tree) 。回流就发生在根据渲染树进行布局 (Layout) 的阶段,这个阶段要得到节点的几何信息,像位置、大小等 。

常见的触发回流的场景可不少 。下面我来总结一下 。

  1. 页面初始渲染:首次加载页面时,浏览器需要构建 DOM 树、CSSOM 树并生成渲染树,这是最耗时的一次回流。
  2. 浏览器窗口变化:调整窗口大小(resize 事件),会导致所有元素的布局重新计算。
  3. 元素尺寸 / 位置改变:修改 width、height、margin、padding、border 等直接影响元素几何结构的属性;或通过 top、left、transform(非 translate 类,如 scale 可能触发)改变位置。
  4. DOM 结构变动:添加 / 删除节点(appendChild、removeChild 等)、修改元素层级(z-index 影响布局时)、改变 display 属性(如从 none 切换到 block)。
  5. 内容变化:元素内文本 / 图片内容改变(如字体大小、图片尺寸调整),导致元素尺寸撑开。
  6. CSS 伪类激活:如:hover、:active 等可能改变元素样式(若涉及布局属性),触发回流。
  7. 读取特定布局属性:调用 getBoundingClientRect ()、offsetWidth、scrollTop 等方法时,浏览器为保证数据准确,会强制触发回流以同步最新布局信息。
  8. 表格布局变动:table 元素的局部修改(如单元格内容变化)可能导致整个表格重新计算布局,这也是不推荐用 table 做布局的原因之一。

这里要着重讲讲查询属性或调用方法触发回流的原理。以getBoundingClientRect()为例,这个方法会返回元素的大小及其相对于视口的位置。当调用它时,浏览器为了给你准确的结果,必须确保此时的布局是最新的,所以会先进行回流计算,保证数据的即时性和准确性 。像offsetTopoffsetLeftclientWidthclientHeight等属性的读取,也会触发类似的机制,因为它们同样需要即时计算才能得出准确值 。

页面首次渲染时,虽然严格意义上不算是从无到有的 “回流”,但这个过程是最耗时的,因为要全面计算所有元素的布局和样式 。而且,回流的影响范围可大可小,全局范围的重排,比如首次加载页面,渲染主线程要重新计算渲染整个 DOM 树,对性能消耗极大;局部范围的重排相对影响小些,但如果频繁发生,也会严重影响页面性能 。例如在一个复杂的页面中,如果不断地动态添加和删除元素,就会频繁触发局部回流,导致页面卡顿。

认识重绘

重绘,英文是 Repaint 。当元素的外观样式改变了,但这个改变又不影响布局的时候,就会触发重绘 。重绘只需要重新绘制受影响的部分,不用重新计算布局。

在渲染流程里,重绘发生在布局之后的绘制 (Painting) 阶段,根据渲染树以及回流得到的几何信息,得到节点的绝对像素,然后将像素发送给 GPU,展示在页面上 。像修改颜色,包括colorbackground-color;修改边框样式,border-style改变;修改透明度opacity;添加或修改阴影box-shadow等,这些操作都会触发重绘 。举个例子,给一个按钮原本的黑色文字改成红色,页面上按钮的位置、大小都没变,只是文字颜色变了,这就是一次重绘 。 下面我来总结一下重绘的触发方式

  1. 修改非布局样式:如 color(文字颜色)、background-color(背景色)、border-color(边框色)、box-shadow(阴影)、opacity(透明度,非 0 到 1 的极端情况)等。
  2. visibility 属性变化:visibility: hidden 会隐藏元素但保留占位,仅触发重绘,从 visible 变为 hidden 也不会触发回流;而 display: none 会移除元素,触发回流。
  3. 背景图变动:更换 background-image 或调整背景图位置(不影响元素尺寸时)。
  4. 文本样式调整:如 font-weight、text-decoration 等仅改变文字外观,不影响元素布局的属性。

不过,有些样式的改变看似只涉及外观,实则可能触发回流。比如visibility属性从hidden变为visible,虽然元素只是从不可见变为可见,但它占据的空间发生了变化,会导致浏览器重新计算布局,进而触发回流和重绘 。还有transform属性,当使用translate进行位移时,一般只会触发重绘,因为它不会改变元素在文档流中的布局位置;但如果使用scale进行缩放或者rotate进行旋转时,元素占据的空间大小或角度变了,可能就会触发回流 。

回流与重绘的关系

回流和重绘的关系很紧密 。简单来说,回流一定会导致重绘 。因为布局改变了,元素的外观肯定也得跟着重新绘制 。但重绘不一定会导致回流,比如刚刚说的修改按钮文字颜色,元素位置和大小都没动,只是外观变化,就只有重绘,没有回流 。

频繁地触发回流和重绘,对页面性能影响非常大 。想象一下,页面上有个动画效果,不停地改变元素的位置和大小,那就会频繁触发回流,浏览器得不停地重新计算布局,重新绘制页面,CPU 和 GPU 资源消耗巨大,页面就容易卡顿,用户体验极差 。再比如,在 JavaScript 里,如果在一个循环里不断读取和修改元素的布局属性,也会引发多次回流,大大降低页面性能 。例如下面这段代码:

const box = document.getElementById('box');
for (let i = 0; i < 100; i++) {
  box.style.width = (box.offsetWidth + 10) + 'px'; 
}

在这个循环里,每次读取box.offsetWidth都会触发回流,然后修改width又触发一次回流,短短几行代码就会引发大量的回流操作,严重影响性能。

优化回流与重绘

在实际开发中,我们得想办法优化,减少回流和重绘带来的性能损耗 。

批量修改 DOM

尽量不要单个地、频繁地修改 DOM 元素 。可以把多个修改操作合并成一次 。比如说,要往页面添加多个新元素,别一个一个地appendChild,可以先创建一个文档碎片DocumentFragment,把所有新元素都添加到文档碎片里,因为文档碎片不在DOM树中,最后再一次性把文档碎片添加到 DOM 树中 。代码大概像这样:

// 创建文档碎片
const fragment = document.createDocumentFragment();
for (let i = 0; i < 10; i++) {
  const newElement = document.createElement('div');
  newElement.textContent = `这是第 ${i + 1} 个元素`;
  fragment.appendChild(newElement);
}
// 一次性添加到DOM树
document.body.appendChild(fragment);

这样就把多次 DOM 操作合并成了一次,大大减少了回流和重绘的次数 。其原理在于,文档碎片DocumentFragment是一个轻量级的 DOM 容器,它存在于内存中,不在文档的渲染树里 。对它进行操作不会触发页面的回流和重绘,只有当把它添加到文档中时,才会触发一次回流和重绘 。

避免强制同步布局

在 JavaScript 里,要避免在循环中多次读取和修改布局相关的属性 。因为每次读取布局属性,浏览器可能就得检查是否有布局变化,有变化就触发回流 。比如下面这段代码就不好:

const box = document.getElementById('box');
for (let i = 0; i < 100; i++) {
  const width = box.offsetWidth; 
  box.style.width = (width + 10) + 'px'; 
}

更好的做法是先一次性读取所有需要的属性,缓存起来,然后再统一进行修改:

const box = document.getElementById('box');
// 一次性读取
const width = box.offsetWidth;
for (let i = 0; i < 100; i++) {
  box.style.width = (width + i * 10) + 'px'; 
}

这是因为浏览器内部有一个渲染队列,它会把一些会触发回流和重绘的操作暂时存起来,等队列满了或者达到一定时间间隔,再批量执行 。但当你在 JavaScript 中同步读取布局属性时,浏览器为了给你最新、准确的值,会立即执行渲染队列中的操作,强制触发回流,破坏了浏览器的优化机制 。

利用 CSS 动画

能用 CSS 动画实现的效果,尽量别用 JavaScript 来做 。因为 CSS 动画通常可以在 GPU 上运行,浏览器对其进行了优化 。像用transform属性来实现元素的移动、缩放等动画,性能就比直接修改lefttop这些属性要好很多 。比如说,要让一个元素从左边移动到右边:

/* CSS动画 */
@keyframes slide {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(100px);
  }
}
.element {
  animation: slide 1s forwards;
}

这样利用 CSS 动画,相比用 JavaScript 不断修改left属性触发回流,性能要高得多 。原因在于,transformopacity等属性触发的动画,浏览器可以利用 GPU 进行加速渲染 。GPU 擅长处理图形相关的计算,它能够独立于 CPU 进行工作,大大减轻了 CPU 的负担 。而且,使用这些属性的动画不会影响元素在文档流中的布局,避免了回流的触发,所以性能表现更优 。

使用 requestAnimationFrame

在 JavaScript 中进行布局相关操作时,使用requestAnimationFrame方法 。它能确保操作在下一个重绘周期之前完成,减少不必要的回流和重绘 。比如,要在页面滚动时动态修改元素的位置,用requestAnimationFrame就很合适:

function updateElementPosition() {
  const element = document.getElementById('element');
  const scrollTop = window.pageYOffset;
  element.style.top = scrollTop + 'px';
  requestAnimationFrame(updateElementPosition);
}
requestAnimationFrame(updateElementPosition);

requestAnimationFrame会在浏览器下一次重绘之前调用传入的回调函数 。它和浏览器的刷新频率保持同步,一般浏览器的刷新频率是 60Hz,也就是每 16.67ms 刷新一次 。通过使用它,我们可以把一些可能触发回流和重绘的操作集中在这个回调函数里,让浏览器在合适的时机统一处理,避免了频繁的、不必要的回流和重绘 。

避免使用表格布局

前面提到过,表格布局的回流成本特别高 。因为一个单元格的变化,可能会影响整个表格的布局,从而触发大量的回流 。所以,尽量使用更灵活、性能更好的布局方式,比如 Flexbox 或者 Grid 布局 。以 Flexbox 为例,它的布局模型更加灵活,元素之间的相互影响相对较小 。当一个 Flex 容器中的子元素发生变化时,只会影响到它周围的直接相关元素,而不会像表格布局那样,一个单元格的变化可能导致整个表格及其相关元素都要重新计算布局 。而且 Flexbox 在现代浏览器中得到了广泛支持,兼容性也较好,非常适合用于构建各种响应式布局 。

回流和重绘是前端开发中必须深入理解的重要概念,通过合理的优化策略,能有效提升页面性能,给用户带来更流畅的体验 。在后续的项目实践中,一定要时刻留意这方面的问题,让我们的前端页面跑得又快又稳 。如果有什么疑问的地方,欢迎在评论区交流。