Skip to Content
全部文章万论前端理解文档流利用好回流机制构建高性能前端

理解文档流利用好回流机制构建高性能前端

发布时间: 2025-06-07

前言

💡
Tip

开始之前要先抛出一个核心观点 我们的目标不是阻止回流,而是阻止异常的、不必要的回流

有的时候我们经常会说页面不是很流畅、卡顿。其实除了长任务执行、海量dom的渲染,以及少数情况下的不合理的操作Dom导致Web性能不佳, 由于现在MVVM框架的虚拟Dom,以及便捷的状态驱动Dom变化,我们已经很少会有需要手动操作Dom的情况,这在Jquery时代是非常常见的基础操作, 现如今我们仍然有必要了解一下浏览器的渲染原理,在构建复杂应用的时候会派上用场。

文档流

在理解浏览器渲染页面的 回流 -> 重绘 -> 合成 3个步骤之前,我认为非常有必要先理解一下什么是文档流,这对于理解回流的概念至关重要。

在我们的 web 页面中,元素在页面中的位置始终是遵循从左到右,从上到下的一个顺序,比如我有100个span元素

<body> <span>我是一个span</span> <span>我是一个span</span> <span>我是一个span</span> // 省略95个 <span>我是一个span</span> <span>我是一个span</span> </body>

我们会看到页面上会显示为

Hello

我们可以看到所有的span是从左到右,从上到下这样的一个顺序显示的,如果我们继续添加span,它会追加到页面上,并且依然按照这个规则来显示, 这看起来就像是一条河流一样,这就是文档流。

但实际上我们的元素分为块级元素和行内元素,span是行内元素,他们会一个挨着一个,布局满了之后换行,而像div是块级元素,它会独占一行,

<body> <div>我是一个div</div> <div>我是一个div</div> <div>我是一个div</div> // 省略95个 <div>我是一个div</div> <div>我是一个div</div> </body>

每一个div独占一行,

Hello

还有一种行内块元素(其融合了行内元素的不独占一行,也具有块级元素的可设置宽高边距的能力),比如设置display:inline-block, 它的特点是既不独占一行,也可以支持像block元素一样设置宽高、margin/bottom,是非常常用的一种元素。

浏览器渲染机制

浏览器的渲染从拿到html后解析为css树,dom树之后,就要进入渲染的环节,它会经过3个步骤,三个阶段是递进的,且后一阶段依赖前一阶段:

回流 -> 重绘 -> 合成

如果回流一定会经历重绘、合成

如果只是重绘则不会经过回流

如果只是合成则不会经过回流和重绘

回流

回流的工作是:给每一个dom元素计算出它的宽高、它在页面中的位置、边距边框等信息,回流也成为布局。类似PS设计中我画了一个正方形,设置它的大小、位置、边框粗细。

因为回流涉及布局,首次渲染完成后,任何一个元素的大小、位置变化都会由回流来重新计算变化的元素以及被影响的元素的新位置、大小,

拿开头的span来举例,如果我通过js给其中一个span设置如下样式

const span = document.getElementsByTagName('span'); span[24].style.display='inline-block'; span[24].style.background='red'; span[24].style.width='200px'; span[24].style.height='200px';
Hello

在rendering中勾选layout shift regions之后,回流时,紫色框框起来的部分就是浏览器回流的范围:

Hello

上图是我修改这个span的宽度时,浏览器触发了回流,可以看到2行的元素重新计算了位置、大小。

触发回流是一定的,它会引起你设置的这个span的大小变化、及它后面的span元素位置变化,而回流就是为了重新计算每个受变化的元素,他们在文档流中占据的新的空间大小, 以及新的位置。

只要页面元素大小、位置变化都会触发回流,滚动(滚动本身不触发回流,但是滚动导致元素的属性变化会触发回流)、增删元素都会触发回流。

回流的代价是极高的,我们的优化方向一般从两个方向

  1. 减少回流的次数,频繁的回流是绝对的灾难,比回流范围影响大得多
  2. 减少回流的的范围,就是某个元素引发的回流导致了多少其他元素跟着变化

重绘

重绘的工作是:布局确定后,给所有元素,设置颜色、透明度等工作,简单来说就是不会影响页面布局变动的工作都是重绘。类似PS设计中我给正方形填充了红色。

重绘的代价比回流小很多,让你去计算一堆dom的变化和明确指定某个dom的颜色发生变化,傻子都知道性能肯定不一样。回流一定会引起重绘,因为这是一个先后过程,但是如果你 只改变元素的颜色则不会触发回流

我修改了span的颜色时,可以看到绿框框柱的旧是重绘的部分,没有出现紫色框,就代表没有触发回流:

Hello

但是如果我们修改span的宽度的话,可以看到有紫色和绿色的框,就说明不仅重绘,还回流了。

Hello

合成

合成的工作是:合成是GPU把前面回流+重绘后的产出按一些规则分图层,提交给显卡渲染。类似PS中我把画的所有的正方形、圆形等图层根据规则合并或者拆分。

假如你给一个元素设置了transform位移,实际上这个元素原来在文档流中占用的位置、大小没有任何变化(你可以看如下示例截图,transform之后,原来的文档流没有变化 ),只是gpu把这个图层在显示上做了位置、旋转等操作。

我们来运行一下一段代码看看

if(!temp1.style.transform){temp1.style.transform="translateY(300px)"}else{temp1.style.transform=""}
Hello

咦?不是说transform来操作元素并不会触发重绘吗?为什么出现这么多绿色框?

这就是前面我说的合成阶段,他是有一些规则来合并、拆分图层的,说明当前这个div并不是一个独立的图层,你对它修改transform,依然会引发重绘。

因此对于复杂的动画时,可以通过css will-change: transform; 来让此元素拥有自己的独立图层,我们再来看看

Hello

这就很完美了,它既没有出现绿框(重绘),也没有出现紫框(回流)。

为什么明明改变了span的位置,但是却只触发了合成?

前面我说了合成是GPU渲染的工作,你仔细看这个span原来的位置,是不是出现了一块空白?后面的span没有自动填充到空白的位置去, 这说明文档布局并没有发生变化,我们已经通过will-change: transform;把这个span单独作为了一个独立图层,只是GPU把渲染的图层换到了一个新位置去渲染。

通俗理解:设计师画了图纸提交给施工方,施工方把楼挪了几百米修建起来了,但是设计师并不会管你有没有按照我的要求去修建房子。

回流触发机制

前面讨论了回流的开销最大,因此我们重点要关注回流的优化,开头就说了,回流是一种正常的逻辑,我们要解决和阻止的是异常的、不必要的回流,为了能控制回流, 因此必须知道回流的一些知识点。

异步重排(延迟布局)

前面我们说了回流是一个耗时的工作,对于这段js,浏览器会执行多少次回流?

const span = document.getElementsByTagName('span'); span[24].style.display='inline-block'; span[24].style.background='red'; span[24].style.width='100px'; span[24].style.height='200px';

显然对于一个耗时的工作来说,比如修改width我执行一次回流,修改height我再执行一次回流,这是不科学的,因此浏览器有一个异步重排的概念,他能合并多次细碎 的布局改变,累积到一个契机来执行一次回流。

渲染队列

上面说的累积就是维护了一个队列,每一个会导致回流的样式修改、添加dom、删除dom等会导致布局变化的操作,都会丢到队列中,等一个时间点从队列中拿出所有还 没执行的修改,全部执行,然后清空队列。

上面的例子有2条会导致回流(修改display在这个示例中从inline改为inline-block不会导致回流,但是修改为block的话会导致回流)

queue.push(/*span[24].style.width='100px';*/) queue.push(/*span[24].style.height='200px';';*/)

等到延迟布局执行时会取出全部来处理,然后清空队列

for(i=0;i<queue.length,i++){ const reflow = queue[i]; // 执行修改 } queue=[];

同步强制回流

前面的异步重排是浏览器的自动的执行回流机制,还有一种导致的回流是我们的代码强制导致的。

比如说像我们读取某个元素的一些宽高信息,会导致浏览器需要。强制把渲染队列中的所有未处理的修改,全部处理(处理就是回流),然后再返回最新的你要的数据给你。

const span = document.getElementsByTagName('span'); span[24].style.display='inline-block'; span[24].style.background='red'; // 给渲染队列添加一个width=100px span[24].style.width='100px'; // 给渲染队列添加一个height=200px span[24].style.height='200px'; // 执行这行代码,为了获取到准确的offsetWidth值,浏览器会先处理(执行回流)上面的width和height // 然后再读取最新的offsetWidth值 console.log(span[24].offsetWidth)

这就是强制同步,但是需要注意的是,强制同步它并不是你每执行一次获取offsetWidth它就会回流一次, 而是你在读取 offsetWidth 的时候,如果渲染队列中还有没回流的操作,才会执行一次回流。

const span = document.getElementsByTagName('span'); span[24].style.display='inline-block'; span[24].style.background='red'; // 给渲染队列添加一个width=100px span[24].style.width='100px'; // 给渲染队列添加一个height=200px span[24].style.height='200px'; // 强制回流,然后读取offsetWidth console.log(span[24].offsetWidth) // 不会触发回流,因为渲染队列是空的 console.log(span[24].offsetWidth)

如果你这样写,就会导致回流两次

const span = document.getElementsByTagName('span'); span[24].style.display='inline-block'; span[24].style.background='red'; // 给渲染队列添加一个width=100px span[24].style.width='100px'; // 强制回流,然后读取offsetWidth console.log(span[24].offsetWidth) // 给渲染队列添加一个height=200px span[24].style.height='200px'; // 强制回流,然后读取offsetHeight console.log(span[24].offsetHeight)

这一点异常关键!!!!!!!!!

这一点异常关键!!!!!!!!!

这一点异常关键!!!!!!!!!

请一定要记住一个关键知识点,只有渲染队列里面有没有处理的操作,且你调用了读取宽高之类的值才会导致强制回流。 这一点异常关键!!!!!!!!!

哪些操作会导致强制回流

布局相关属性

offsetWidth/offsetHeight/offsetLeft/offsetTop、clientWidth/clientHeight/clientLeft/clientTop、scrollWidth/scrollHeight/scrollLeft/scrollTop

布局相关方法

getBoundingClientRect()、getClientRects()、scrollIntoView()、scrollTo()

计算样式且包含布局属性

getComputedStyle(el)(若读取的样式是width/height/margin/top等布局属性,会触发回流;仅读color/background等视觉属性则不会);

避免异常回流的场景

避开强制回流

这个例子很常见,请重视

const box = document.getElementById('box'); // 读操作放到最前面,此时因为缓存队列中没有值,无论你读多少次, // 最多只会触发1次回流(假如在执行读之前缓存队列没有值的话,这里触发0次回流) const oldWidth = box.offsetWidth; const oldTop = box.getBoundingClientRect().top; // 写操作统一执行,中间不要穿插读样式操作 box.style.width = '200px'; box.style.height = '200px'; // 如果一定要写了样式后,还要读样式,可通过rAF在回流后读取 // rAF 的回调会在「浏览器下一次渲染前」执行,已经完成回流了 requestAnimationFrame(() => { console.log(box.offsetWidth); // 此时已完成异步回流,值为最新 });

requestAnimationFrame

requestAnimationFrame是什么?为什么要使用它?有什么好处? requestAnimationFrame是一个按照固定频率执行的函数,他会在每次浏览器绘制帧到屏幕上之前执行,浏览器刷新率60Hz,每秒刷新60次,requestAnimationFrame 执行的频率就是每隔16.6ms检查一次当前有没有需要执行的回调函数,有就全部拿出来执行。如果在执行的时候正好有长任务阻塞,会跳过当前这个执行机会,放到下一帧渲染 的时间点再检查,直到正好是requestAnimationFrame的执行时间点,且线程空闲时才会执行未运行的回调函数。

这个图是我自己随手画的,每一个刻度就是浏览器渲染的帧的时间点,requestAnimationFrame会回调函数就是在每个刻度的时候检查,如果线程空闲,则把所有执行requestAnimationFrame添加的 回调函数取出来,一次执行完。

Hello

上面的例子中把最后的box.offsetWidth放到requestAnimationFrame的回调函数执行有2个好处。

  1. 同步执行读取offsetWidth会触发强制回流,暂停当前js函数的执行,直到回流完成。
  2. 假如你有10个组件都是写样式、读offsetWidth,10个组件同时运行时,如果同步读取offsetWidth,浏览器会回流10次,但是你把他们放到requestAnimationFrame中, 由于requestAnimationFrame回调函数的执行时间点都是固定的,当第一个组件添加的回调运行时,它读offsetWidth,触发了回流,清空缓存队列,后面的9个组件的回调运行时, 读取offsetWidth,因为缓存队列是空的,所以并不会触发回流,直接减少了9次回流。

所以这里其实是借助requestAnimationFrame的执行机制来让多次强制回流合并为只回流1次。

脱离文档流

前面说了什么是文档流了,脱离文档流就是让某元素不再在文档流中,方法就是设置position为[absolute、sticky、fixed],脱离文档流之后再对元素设置宽高之类的, 他会回流,但是它只影响当前元素,相当于减少了回流的范围。

使用transform

动画尽量使用transform来做,只会执行合成阶段的操作,但是要搭配will-change: transform;来让元素用于独立图层,否则可能还是会触发重绘。

离线处理

先隐藏处理再显示,该元素此时不在渲染树中,浏览器引擎认为这些修改不影响当前的页面布局,只会将样式记录在DOM节点上。不管你修改多少次,都只触发2次回流。

修改样式:

// 触发一次回流 xxx.style.display = 'none'; // 改宽度、改高度、改字体、改边距等 // 触发一次回流 xxx.style.display = 'block';

批量操作dom

const ul = document.getElementById('list'); const data = [1,2,3,4,5,6,7,8,9,10]; // 第一步:隐藏元素,触发1次回流 ul.style.display = 'none'; // 第二步:逐行操作(隐藏状态下无回流) data.forEach(item => { const li = document.createElement('li'); li.innerText = `列表项${item}`; ul.appendChild(li); }); // 第三步:显示元素,触发1次回流,总计2次 ul.style.display = 'block';

使用DocumentFragment也可以批量操作dom,DocumentFragment和React的Fragment()是类似的,本身在真正插入的时候会自动被忽略掉。

const ul = document.getElementById('list'); const data = [1,2,3,4,5,6,7,8,9,10]; const fragment = document.createDocumentFragment(); // 文档碎片,无DOM树节点 // 第一步:离线将所有li挂载到碎片(无回流) data.forEach(item => { const li = document.createElement('li'); li.innerText = `列表项${item}`; fragment.appendChild(li); }); // 第二步:一次性将碎片挂载到DOM树,仅触发1次回流 ul.appendChild(fragment);

最后

现在MVVM框架的虚拟dom已经帮我们避免了大部分处理回流的情况了,平时要避免使用调用offsetXXX之类的纯js去获取样式的情况,然后就是在设计复杂应用的时候, 尤其要注意,循环、滚动等会频繁执行的地方不要避免交叉读写,会Boom。然后就是像足够复杂的地方还是使用图形化编程,而不是使用Dom来实现,图形化编程靠Gpu渲染, 操作内部的绘制是不会触发回流的,例如像腾讯文档excel表格就用了大量的canvas绘制,而不是靠Dom去堆表格。

最后编辑于

hi