再次理解reflow重排和repaint重绘
前言
我们都知道浏览器中有 “重绘” 和 “重排” 两个概念,但浏览器对于我们是一个黑盒,我们很难真正弄清楚其真实的代码逻辑如何,除非去研究浏览器内核源码。
这里本文我也是结合了一些实践经验后提出自己对 重绘、重排 的运作的新的猜测,如有错误欢迎指正。
怎样理解重排重绘
在本节,我们抛出 3 个独立的概念:
- js 执行
- dom 重排和重绘
- UI 渲染
先说结论:上述 3 个概念,对于我们理解重排重绘到底是什么有着至关重要的作用。如上 3 个概念其实是 3 个独立的事情。
- js 执行是指的运行 js 代码,当然你还其实可以在其中调用 domapi(这种调用虽然会穿透到 c++层,但依然跟 js 执行占用一个线程);
- dom 重排和重绘是指的浏览器 DOM 模型对象内部的一种数据结构变动,可以理解为对
dom render tree
这个数据结构的某个子树的重新生成,但它并不意味着你在视觉上可以看到重排重绘后的样子 - UI 渲染才是真正的进行了视觉上的绘制。只有 UI 渲染后才能肉眼看到 dom 重排重绘后的结果。
下面,我们来分别解释这些概念。
js 执行
js 执行我们在网络上很多文章都有学习过了。js 的执行是通过 js 执行引擎线程来执行的。而且这个线程与 UI 渲染线程互斥,因此,当我们 js 代码运行期间,是不可能进行 UI 渲染的。
浏览器给我们 js 提供了很多 dom api,让我们可以去操作浏览器中的 dom 元素,例如改变 dom 元素的宽度属性,改变其颜色样式等等。
dom 对象是浏览器底层 C++ 实现的一个对界面上 UI 元素的抽象。那么,当我们 JavaScript 对所谓的 dom api 进行操作的时候,则实际上每次 api 调用实际上是在修改底层 C++维持的 dom 对象的属性。
例如:
1 | ele.style.width = "100px"; |
或者
1 | ele.style.backgroundColor = "red"; |
像上面这种操作,要比我们设置普通的属性如 window.a = 1
要耗时很多。因为修改 dom 属性会穿透到 c++层。
来源:https://www.zhihu.com/question/324992717
重排和重绘
我们从网络上文章都已经知道重排(回流)和重绘的概念。例如,如下行为会引发回流:
1、添加或者删除可见的 DOM 元素;
2、元素位置改变;
3、元素尺寸改变——边距、填充、边框、宽度和高度
4、内容改变——比如文本改变或者图片大小改变而引起的计算值宽度和高度改变;
5、页面渲染初始化;
6、浏览器窗口尺寸改变——resize 事件发生时;
如下行为会引发重绘:
如:只是影响元素的外观,风格,而不会影响布局的样式,比如 background-color。
来源:https://www.jianshu.com/p/b27cdab7f094
但回流和重绘到底是何时发生的,大家是否有思考过呢?比如这样一段代码:
1 | ele.style.flex = 1; // 假设ele的父元素是 display:flex |
那么,你将 ele 元素的 flex 样式设置为 1,即自适应宽度。那么你在何时可以拿到 ele 元素的真实渲染宽高呢?假如你同步来拿,如这样写法:
1 | ele.style.flex = 1; |
这样是否可以立刻拿到 ele 他真实渲染宽高呢?你在 console.log 这句代码执行的这一刻,浏览器中是否已经按照 flex:1 的预期画好了 UI 界面呢?关于重排重绘到底在线程的什么阶段发生,UI 渲染何时发生,我们在后文重点讲解。
UI 渲染
所谓 UI 渲染,就是通过 UI 线程来将此时 dom 的最新状态,渲染成浏览器中的可视的样子。
UI 渲染线程跟 JS 执行线程永远是互斥的,即当你 js 执行时,UI 必然不能渲染;当你 UI 渲染时,js 必然也无法
到底何时触发重排重绘
那么,当我们修改完一个元素的“宽度”或“颜色”,浏览器会立刻将修改渲染到页面上吗? 实际上不是的,为了解释 js 代码执行、重排重绘、以及浏览器到底啥时候往 UI 上去绘制我们的界面这些步骤,那么我们要搬出一张图了:
首先,浏览器的 js 引擎负责执行我们的 js 代码(按照宏任务和微任务的执行规则来执行);而当 js 引擎线程空闲时,浏览器渲染引擎线程才得以有机会得到执行(即上图中白色方块那个位置,表示 js 线程此时空闲了)。不过,白色方块右侧的黑色开关也不是一直打开的,他遵循 60 帧每秒的一个帧率来打开和关闭,即每(1000/60)毫秒打开一次。 因此,我们总结一下浏览器这几个概念的执行步骤:
1、首先,浏览器事件循环会不断的执行 js 代码和 js 宏任务回调,当然如果每次宏任务执行完毕后若微任务队列有任务,则清理微任务队列。—所以你会发现,其实之所以你 html 里写一堆初始化 script,执行完立刻就执行 promise 等微任务,并不是因为微任务比别的宏任务优先级高,而是微任务本来就是 script 这次宏任务“圈圈”结束之前的最后一步任务—即清掉本轮所有微任务。(而且你再本轮 script 代码中可能随时再往微任务队列里插入微任务,这也就是我们如果想让某个任务赶紧执行,就通过微任务实现,因为微任务会插到本轮循环的出口位置这个队列里,导致你必须得清空他才能 js 空闲。)
2、事件循环如此往复,必然有短暂空闲时刻(即图中白色方块位置)。每当 js 主线程空闲时刻,则有机会去按照帧率决定是否切到“UI 渲染线程”去绘制界面(规则就是 60 帧每秒的频率打开该开关)
3、假如,你的 js 代码在主线程中执行过程中,有对元素尺寸等的 api 操作(例如修改 width),那么,js 主线程中这个 dom 修改会将 c++层中的 dom 对象上 width 属性改掉,且会”触发”c++对这片渲染树的 reflow。
为什么是带引号的“触发”呢,那是因为浏览器会做优化:虽然你改变了 dom 元素的 width,但是你此时 js 还未执行完,那么 UI 渲染线程必然无法执行。既然 UI 渲染都无法执行,所以界面也无法绘制,因此浏览器认为,他也没必要对你的 width 进行 reflow 重排。因此,浏览器仅仅把你的 width 设置给缓存到队列—–等到真正要渲染 UI 的时候再 reflow 就好。
4、浏览器做的挺好了,他的思路没毛病。但有一种情况叫做“不可中断的回流” Uninterruptible reflow 。 这种情况回流会同步发生(即浏览器没办法缓存,必须要做完 reflow 的动作后再往下执行)。来源:https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Performance_best_practices_for_Firefox_fe_engineers
例如你读取了元素的宽:
1 | ele.style.flex = 1; |
那么,本来上面那一句 flex:1 的动作浏览器是可以缓存的,这样主线程可以立刻执行下方代码。但由于你直接调用了 offsetwidth,那么这一句会立刻触发浏览器 dom 清空回流操作,即把之前的 flex:1 实施。因此你的代码会阻塞在 ele.offsetWidth 这里等待回流完成,从而拿到正确的 offsetwidth 值(至于回流是发生在主线程还是 ui 线程都并不重要了,总之会阻塞你主线程代码)。这也就是回流性能低的原因了。
我猜测,这种代码应该是触发了 c++底层对 dom 渲染树这个数据结构的“重新计算”,并不是触发显示器上 UI 的重新显示,即“ui 渲染的开关并没有打开”—因此你 页面 UI 上不会发生变化—即没有触发真正的回流,而是触发底层 c++对 dom 进行计算准备而已。
5、不管你 js 执行过程中有没有触发上述同步的回流。当你 js 空闲,那么此时 ui 线程得到机会渲染时,都会看看回流重绘队列里有没有需要重排重绘的操作,有则执行他们并进行 UI 绘制。
实例
我们来看一个例子,这个例子会在 js 代码中对 dom 尺寸进行修改,且立刻读取其修改后的尺寸。这必然会立刻触发同步的回流操作,但实际上你在浏览器观察不到尺寸的变化(因为我们 js 线程未结束之前,UI 线程还无法进行渲染)。 通过这个例子,我们可以清晰的看出来:页面进行重排重绘是一个 dom 对象内部的过程和概念,而并不是指的 UI 渲染到浏览器视觉这个步骤。
代码如下:
1 |
|
到底什么样的代码性能会差?
在网络上,我们经常会听到说,不要频繁操作 dom 以免降低性能。其实这里有个歧义:即到底是因为什么影响到性能的呢?其实网络上会有一些不适应现代浏览器的错误说法。例如下面这个代码:
1 | const start = Date.now(); |
很多人认为:这样的写法是不妥的,因为他在循环内每次循环都要操作 DOM,进而造成每次都触发浏览器的重排。因此要改成如下的写法:
1 | var fragment = document.createDocumentFragment(); |
但实际上,上述 2 种写法,经过 chrome 浏览器测验,其总耗时是一致的。实际上,由于浏览器的优化机制,循环内直接操作 dom 实际上并不会让浏览器立刻就触发同步的重排(浏览器并没有那么傻)。而浏览器是会在你对内存 dom 全部操作完毕后,js 线程空闲后,需要 UI 绘制的时候,它才对你的重排命令进行合并触发—很显然这个优化是必要的。
那么,下面我们来看看,到底怎样的写法,才会导致页面性能问题呢。
只操作 dom 但不重排重绘
大部分的 dom 操作,其实并不会立刻触发重排重绘,而是被浏览器优化为在最后统一做一次重排重绘,因此这种 dom 操作对性能损耗并不是很大
试想以下两种代码,哪个对性能影响更大:
1 | // 第一行: |
答案是第二行影响更大,因为第一行仅仅是修改 v8 内 js 对象的一个属性,第二行你修改 v8 内 js 对象 document.title 的同时,他还要穿透到 c++层去修改真正 dom 模型上的 title(尽管不会触发 reflow 和 repaint)。类似的:我们使用 ele.style.width
进行修改,也一样会造成 c++调用的损耗。
但以上这种 dom 操作的性能损耗还是可以接受的,正是因为浏览器不会立刻触发重排重绘。就比如上文我们提到的那个例子:循环 n 次,每次都往 body append 一个元素。其性能还算可以的,因为这并没有触发重排,只是触发了一次 c++调用。
不过还是有很多 dom 操作是立刻就触发重排重绘的,这种行为我们还是要尽可能的避免(例如设置 scrollTop 属性),以免引起大量的性能损耗。而且有些浏览器可能不会做合并或者延迟重排的优化,那么我们确实也尽量应该避免操作真实的 dom 树。那么解决办法就是尽可能少操作,或者使用 fragment 创建好 dom 之后,再 append 到真实 dom 树里面。也可以像 vue virtual dom 似的,diff 找到最小化变更再一起更新。
会导致立刻重排的 dom 操作
有一些操作是会立刻造成 dom 重排的(即重新生成那块渲染树)。这个操作影响就比较大了。因为这种操作必然会引发立即重排重绘,那么浏览器的 js 执行线程就被卡住了。举例来说:
1 | console.log(ele.offsetWidth); |
这个宽度或距离窗口顶部距离等属性的读取,就会触发立刻重排。毕竟浏览器要把此时 dom 最新的状态下重排后的坐标等信息确认后,再返回给你结果。不然你拿到的结果就是错的。 更多的 api 请参考这里:https://gist.github.com/paulirish/5d52fb081b3570c81e3a
因此,这给我们的启示是,不要频繁读取这种触发重排的属性。例如:
1 | for (i = 0; i < 100; i++) { |
这时,最好把 ele.offsetWidth 挪到外面:
1 | const myWidth = ele.offsetWidth; |
另一个启示是:你操作 dom 也就罢了,但千万别操作完就读取它。例如上文的这个例子:
1 | const start = Date.now(); |
你如果在 appendChild 那一行下面加一句: a = (spanNode.clientWIdth),
,那性能就千差万别了。
虽然没有冗余的重排(被浏览器优化了),但是往页面里塞了 100000 个 dom,
上面我们说了,性能问题其实并不是出现在操作 dom 本身,而是出现在某些 dom 操作会触发重排(尤其是读取操作)。
因此,现代浏览器,我们单纯的 append dom 到文档中(例如 append10000 个),一般情况下浏览器也就在我们 js 空闲后来一次重排。此时用不用 fragment 都行。 那么,这种情况下,js 线程执行时其实并不会卡住。
而我们如果亲自操作以上实验,会发现 append 10000 个元素还是会导致浏览器卡顿。实际上这里的卡顿,出现在 js 执行完毕,浏览器的 UI 渲染线程开始渲染的时刻,因为此时浏览器要把你曾经 append 的元素进行渲染,则他必然要在此时进行重排,重排完了还要 UI 绘制到显示器,这个重排和绘制过程就比较耗时了,反过来他又阻塞了我们 js 的执行。
link 标签和 style 标签对页面重排的影响
在一些前端开发套件的开发模式下我们经常会通过 “动态 append 样式”的方式,将样式塞到 html 的 head 标签中。例如 vue 的本地开发热加载模式。
那么,假如我们初始化一个 vue 组件,该组件初始化时会立刻将 style 注入到 head(此时样式必然要影响到样式?)。那么请问:我们在 vue 组件的 mounted 中,能否拿到样式所影响的元素的最新的尺寸信息(如 offset)。
这个问题的答案要分情况:看你的样式是怎样 append 到 head 里的。这里有 2 种方式注入样式:
- style 标签注入
- link 标签注入
当使用 style 标签注入时,无论 chrome 还是 safari 浏览器,其 style 注入的那一刻,都把样式树模型进行了修改(毕竟这是同步塞到 dom 里,且同步更改了样式?),因此我们 vue 的 mounted(即 vue 组件的模板 dom 挂载完成)里面你读取元素的 offsetWidth 会立刻触发重排,故可以拿到最新结果。案例如下:
1 |
|
而如果使用 link 标签,则 Safari 浏览器会认为这是个外链样式,因此他会假装走一次异步修改样式树的行为。在这个异步空隙里,vue 得到初始化和 dom 挂载并触发 mounted。那么此时我们在 mounted 里面访问时,link 标签的 load 事件还没触发,因此你访问 offsetWidth 是拿不到最新样式的重排结果的。 不过,chrome 内核却解决了 link 的这个问题,他可能发现我们 link 的内容并不是网络 url,那么他就同步修改样式树了。
案例代码如下
1 |
|
同理,js 代码直接 append 带内容的 script 标签到页面,也是同步执行的。但凡同步执行的东西(无论 js 还是 css),只要你执行完想读取其 dom 尺寸等结果,那单但凡你有读取的代码,浏览器就必然要给你算好最新的渲染树结果给你读。
提问
fragment 上的元素(即悬空文档:DocumentFragment),我们能否调用 offsetWidth 等属性获取其渲染坐标尺寸呢?
答案:不可以。假如你设置了 style.width=’100px’,你也只能拿到 0。
因为 fragment(DocumentFragment) 是游离于渲染树的,因此他无法重排。只有你把 dom 放到真实 dom 里,才能拿到用于渲染的重排数据。
因此,如果你确实需要访问元素的渲染宽高坐标等,您则必然要耗费很多性能来拿到这个值。这也是本文最重要的启示:我们在这种场景要尽可能的避免那种循环写法(即循环里面还要每次读一下你设置的宽高尺寸之类的),因为“同步重排(设置一下就让他重排一下)”的代价太大了,我们更应该批量设置好,等到他“uiRender 开关”打开的时候让他批量一次进行最后统一的重绘重排,这样代价会小一点。