理解 Web 的重排和重绘

发布于 大漠

Web 中 的重排和重绘是 Web 渲染中常见的问题,即 Relayout(重排)和 Repaint(重绘)。其中重排(Relayout)也常被称为回流(Reflow)。另外在 Web 中聊渲染相关的话题,除了 Repaint、Reflow、Relayout之外,还有 Restyle(重写样式)和 Rendering(渲染)。这五个带有 R 开头的单词简称 5R ,对于 Web 性能的优化有着决定性的影响。这五个 R 分别是:

  • Rendering :渲染
  • Repaint:重绘
  • Reflow: 回流(也称重排)
  • Relayout:重新布局 (和 Reflow 表达相同的意思)
  • Restyle :重新设计(重写样式)

如果要提高页面的渲染性能就需要深究造成 Repaint 和 Reflow 的相关原因。

浏览器渲染过程

用下图来阐述浏览器是如何工作的:

  • 浏览器引擎:将 HTML 文档和页面的其他资源转换为用户设备上的互动视觉表现(Interactive Visual Representation)
  • 布局引擎 和 渲染引擎:理论上,布局和渲染(或“绘制”)可以由独立的引擎处理;事实他们是紧密耦合在一起,很少单独考虑

当你在某个链接或 URL 上敲击回车键时,浏览器就会向该页面发起 HTTP 请求,相应的服务器则提供 (通常)HTML 文档作为回应(当然,这中间会发生很多事情)。

不同的浏览器的处理过程并不一致,但一定会有大家都会遵从统一的地方,下面这张图就把这个过程作了总结,各家的浏览器接收到代码之后,多多少少都会按照下面这个步骤将代码转化成显示器上的网页:

  • 首先解析 HTML 代码,将其所有转化成 DOM 树
    • 每一个 HTML 标签对应一个 节点(Node)
    • HTML 标签(元素)的内容则被转换成 文本节点(Text Node)
    • 树形的根部(Root Node)则是 documentElement<html>元素)
  • 碰到 <link rel="stylesheet"><style> 开始处理 CSS 样式表,会将 CSS 样式表解析成 CSSOM 树
    • 浏览器遇到自己不认识的前缀定义的样式规则,会直接被忽略
    • 样式信息层层递进,即层叠规则
      • 浏览器都有自己的默认样式 User Agent 样式
      • 用户设置样式
      • 开发者编写的样式
  • DOM 和 CSSOM 结合在一起,构建一个渲染树(Render Tree)
    • 渲染树有点像 DOM 树,但并不完全匹配
    • 渲染树了解样式
    • 可能有一些DOM元素在渲染树中有不止一个节点
    • 渲染树中的一个节点被称为一个帧(Frame),也称为盒子(Box,对应的就是 CSS 盒模型)
    • 每个节点都有 CSS 盒模型的属性
  • 渲染树构建完成,浏览器就可以在屏幕上绘制(画出)渲染树的节点

在这些树(DOM 树、CSSOM树,Render树)上都有相应的节点,而这些节点都是有对应的映射转换:

浏览器解析 Web 页面更详细的介绍,可以阅读《初探 CSS 渲染引擎》。

重排和重绘的概念

页面的生命周期中

网页生成的时候至少会有一次渲染。在用户的访问过程中,还会触发重排(Reflow)和 重绘(Repaint)

正如上图所示,在页面整个生命周期中,除了首次渲染之外,后面会随着一些操作,比如 JavaScript 脚本动态操作 DOM 或 CSSOM,用户的输入(比如在文本框中输入内容,点击按钮或鼠标悬浮在按钮上),异步加载,动效,用户滚动页面以及用户调整浏览器视窗大小等,都会在首次渲染的基础上进行更新(会有Reflow和Repaint)。

简单地说,在浏览器打开任何一个页面的时候,都会进行一次绘制。在此之后,对构建渲染树的信息进行任何改变都会造成以下一种或者两种结果:

  • 渲染树的部分(或者全部)内容需要重新验证,并重新计算节点的尺寸。这个过程被称为 回流(Reflow)布局(Layout,或 Layouting)重排(Relayout)! 重点是,页面的初始布局(Layout)至少会回流(Reflow)一次
  • 页面中的部分内容需要获得更新,比如一些节点(Node)的几何(Geometry)信息变化,或者一些类似背景颜色之类的CSS样式上的变化。这个过程被叫作 重绘(Repaint)或 重画(Redraw)

不管页面发生了重绘还是重排,都会影响性能,最可怕的是重排,因为:重绘不一定导致重排,但重排一定会导致重绘!

重绘

当元素(节点)的外观发生改变,但没有改变布局,重新把元素外观绘制出来的过程叫做 重绘(Repaint)

浏览器通过构造渲染树和回流阶段,可以知道哪些节点是可见的,以及可见节点的样式和具体的几何信息(位置、大小等),那么我们就可以将渲染树的每个节点都转换为屏幕上的实际像素,这个阶段就叫做重绘节点。简单地说,重绘是填充像素的过程。它涉及绘出文本、颜色、图像、边框 和阴影,基本上包括元素的每个可视部分。在重绘阶段,系统会遍历渲染树,并调用渲染对象的 paint 方法,将渲染对象的内容显示在屏幕上。

从上图可以看出,如果修改了元素的背景颜色,那么布局阶段将不会执行,因为并没有引起几何位置的变换,所以就直接进入绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作高一些。

浏览器将渲染对象的内容绘制到屏幕会按照一定的绘制顺序进行绘制,这个绘制顺序其实就是元素进入堆栈样式上下文的顺序。这些堆栈会从后往前绘制,因此这样的顺序会影响绘制。块渲染对象的堆栈顺序是:背景颜色 → 背景图片 → 边框 → 内容 → 轮廓

重排(Reflow/Relayout)

当 DOM 的变化影响了元素的几何信息(DOM 对象的位置和尺寸大小),浏览器需要重新计算元素的几何属性,将其安放在界面中正确位置,这个过程叫做 重排(Relayout) 也称作 回流(Reflow)

重排是浏览器中执行的一个流程,用于重新计算文档中各元素的位置和几何形状,以便重新渲染该文档的部分内容或全部内容。由于重排会阻止用户在浏览器中执行操作,因此开发者需要了解如何优化重排,以及各种文档属性(DOM 深度、CSS规则效率和不同类型的样式更改)对重排用时的影响。有时,对文档中的单个元素进行重排可能需要同时对其父元素及其后面的所有元素进行重排。

简单地说,更新了DOM元素的几何属性,就会发生重排:

从上图可以看出,如果你通过 JavaScript 脚本 或 CSS 修改元素的几何位置属性,浏览器就会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。重排需要更新完整渲染流水线,所以开销也是最大的。

重排是一个非常昂贵的操作,大部分重排都会导致页面被重新渲染(重绘)。它是导致 DOM 脚本执行缓慢的主要原因之一,特别是在移动手机端。在大部分情况下,重排等同于生新进入一次页面!

重排重绘与布局计算

  • 浏览器如何渲染文件
    • 用户点击一链接或输入URL按下Enter键时,会接收来自服务器的数据(字节)
    • 解析字节并转换为令牌(Token):
      • 标签起始符:<
      • 标签名称:TagName
      • 标签属性: Attribute
      • 标签属性值: AttributeValue
      • 标签结束符:>
    • 将令牌(Token)转换为节点(Node)
    • 将节点转换为 DOM 树
    • 从 CSS 规则中创建 CSSOM 树 (解析 HTML时碰到 <link rel="stylesheet"><style> 开始解析 CSS)
    • 将 CSSOM 树和 DOM 树合并为 渲染树(RenderTree)
      • 计算哪些元素是可见(visible)的以及它们的计算样式(Computed Styles)
      • 从 DOM 树的 根(<html>)开始
      • 不可见的元素(如,<meta><link><script> ) 和 display: none 不会挂到渲染树上(被渲染树忽略)
      • 对于每个可见的节点,找到与其匹配的 CSSOM规则,并运用到节点上
    • 布局(Layout 或 Reflow):计算每个可见元素的布局(位置和几何尺寸)
    • 绘制(Paint 或 Repaint):将像素渲染到屏幕上
  • 重绘(Repaint)
    • 可见性发生变化时会触发,比如 opacitycolorbackground-colorvisibility
  • 重排(Reflow,Relayout,Layout, LayoutFlush, LayoutThrashing)
    • 变化影响到布局,比如widthpositionfloat
    • 重新计算位置和尺寸
    • 有更大的影响:
      • 改变一个元素会影响到所有的子元素、祖先元素和同级元素或整个文档, 比如改变 DOM 或 CSS,滚动,用户行为(如获取焦点)
    • 只有当文档发生变化并使布局无效时,回流才会有代价
    • 无效的东西 + 触发的东西 = 昂贵的回流

而布局计算就发生在 RenderObject 树的每个 RenderObject 对象上的,属于重排的其中一个环节。

DOM树解析完成的时机,调用了 UpdateStyleAndLayoutTree() 方法触发 LayoutTree 的更新。

网页加载之后,每当浏览器需要重新绘制新的一帧的时候,一般需要三个阶段:计算布局 、绘制 和 合成

这三个阶段越少,页面的性能就越好:

在页面初始化的整个渲染周期中,布局计算(Layout)绘制(Paint) 是最耗时的两个阶段,最后一个阶段 合成(Composite) 是较快的。而每次的布局计算后,一旦布局发生了改变,后续的绘制操作也会接着进行。因此,我们有必要了解在页面渲染周期中,哪些情况是需要重新计算布局:

  • 网页的可视区域(Viewport) 发生变化时都需要重新计算布局
  • 页面中的动画会触发布局计算。如果动画需要改变元素的一些大小或尺寸,那么就需要重新进行布局计算
  • JavaScript 脚本修改样式信息
  • 用户的交互也会触发布局计算,比如滚动页面,会触发新区域布局计算

简单地来说,只要样式发生了变化,都需要进行重新计算。

布局计算根据其范围大致分为两类:

  • 对整个 RenderObject 树进行计算
  • RenderObject 树中某个子树的计算

布局计算是一个递归的过程,这是因为一个节点的大小通常需要先计算它的子节点的位置、大小信息才能被确定。布局计算是以包含块和盒子模型为基础的,元素的布局计算都依赖于块,而它们通常是在垂直方向上展开的。

下图为布局计算过程描述:

重绘和重排是昂贵的

先来看实际案例的渲染,下面三个案例都是使用 JavaScript 脚本动态创建内容(20000次):

  • 案例1: 每次都访问,并修改 DOM + 重排 + 重绘

  • 案例2: 多次访问 DOM,一次修改 DOM + 重排 + 重绘

  • 案例3: 一次访问,并修改 DOM + 重排 + 重绘

    Case1
          <script>
              var times = 20000;
    
              // Case1: 每次都访问并修改DOM + 重排 + 重绘
              console.time(1);
              for(var i = 0; i < times; i++) {
                  document.getElementById('container').innerHTML += i + '<br/>'  
              }
              console.timeEnd(1);
    
          </script>
      </body>
    

Chrome浏览器耗时: 1: 458966.73193359375 ms。(无痕耗时: 388878.5646972656 ms

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Case2</title>
    </head>
    <body>
        <div id="container"></div>
        <script>
            var times = 20000;
            
            // Case2: 多次访问 DOM, 一次修改 DOM + 重排 + 重绘
            console.time(1);
            var str = ''
            for(var i = 0; i < times; i++ ) {
                var tmp = document.getElementById('container').innerHTML
                str += i + '<br/>'  
            }
            document.getElementById('container').innerHTML = str
            console.timeEnd(1)
        </script>
    </body>
</html>

Chrome 浏览器耗时: 34.890869140625 ms (无痕耗时:36.68994140625 ms

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Case3</title>
    </head>
    <body>
        <div id="container"></div>
        <script>
            var times = 20000;

            // Case3: 一次访问并修改 DOM + 重绘 + 重排
            console.time(1)
            var str = '';
            for (var i = 0; i < times; i++) {
                str += i + '<br/>'  
            }
            var container = document.getElementById('container') 
            container.innerHTML = str
            console.timeEnd(1)
        </script>
    </body>
</html>

Chrome耗时:30.361083984375 ms (无痕:33.60205078125 ms

我们一直知道一个性能规则: 避免频繁进行 DOM 操作。DOM 操作性能代价是很大的,而从数据也更可以看出,重排重绘的代价更巨大。因此了解如何减少不必要的重排重绘,这对页面性能的提升也是巨大的。

何时会触发重排重绘

重排计算了页面的布局。对一个元素的重排会重新计算该元素的尺寸和位置,也时也会触发对该元素的后代、祖先或同级中的元素进一步重排。然后,它调用最后的重绘。但重排又是很容易被触发的:

  • 在 DOM 中插入、删除或更新一个元素 (即 可见DOM 的增删改)
  • 移动 DOM 在 HTML 文档的位置
  • 修改页面上的内容(比如文本框输入内容)
  • 对一个 DOM 元素的尺寸发生变化(包括 marginpaddingborder-widthwidthheight 等)
  • 对一个 DOM 元素的位置发生了变化 (比如 positionfloattoprgihtbottomleft 等)
  • 对一个 DOM 元素进行动画处理
  • 对一个 DOM 元素进行测量(计算),比如 offsetHeightgetComputedStyle
  • 改变一个 CSS 样式
  • 改变一个元素的类名
  • 添加或删除一个样式表
  • 调整窗口的大小,经如用户调整浏览器视窗大小,resize
  • 滚动
  • 改变字体
  • 激活CSS伪类,比如 :hover
  • 设置元素的 style 属性
  • 改变节点内部文字节结构,比如 text-alignoverflowfont-sizeline-height

触发重排必触发重绘!

在 CSS 中不同的属性分别触发重排(Reflow)、重绘(Repaint)和合层:

记住下面这几条规则:

  • 只要是会改变元素位置和尺寸的CSS 属性都会触发重排,比如
    • 盒模型中的 width 、heightmin-width/heightmax-width/heightmarginpaddingborder 以及它们对应的逻辑属性
    • 定位相关的属性,比如 position (除 static 之外),toprightbottomleftposition 在非static )以及它们对应的逻辑属性
    • 浮动和清除浮动:floatclear
    • 改变节点内部文本结构的相关属性,比如 text-alignoverflowfont-familyfont-sizefont-weightvertical-alignline-heightwhite-spacetext-overflow
    • 布局类属性,Flexbox 和 Grid
    • display: none
  • 只改变颜色和外形样式,并不涉及位置和大小的属性只会触发重绘,比如:
    • 颜色属性,colorborder-colorbackground-coloroutline-color
    • 边框样式 border-styleborder-image
    • 背景相关的属性,比如backgroundbackground-imagebackground-repeatbackground-sizebackgrounnd-position
    • 阴影:box-shadowtext-shadow
    • 圆角: border-radius
    • 轮廓:outlineoutline-styleoutline-offsetoutline-widthoutline-color
    • 下划线:text-decoration
    • visibility
  • 触发重排的属性必触发绘

具体的可以查询 CSSTriggers 网站。(注意该网站很久没有更新了):

在 JavaScript 中有些 API (DOM API 和 CSSOM API)对 DOM 和 CSSOM 操作的时候,也将触发重绘(或强制布局 Layout Thrashing)。

  • 元素相关的 API (Element API)
    • 度量盒子相关的API:
      • elem.offsetLeftelem.offsetTopelem.offsetWidthelem.offsetHeightelem.offsetParent
      • elem.clientLeftelem.clientTopelem.clientWidthelem.clientHeight
      • elem.getClientRects()elem.getBoundingClientRect()
    • 滚动相关的 API
      • elem.scrollBy()elem.scrollTo()
      • elem.scrollIntoView()elem.scrollIntoViewIfNeeded()
      • elem.scrollWidthelem.scrollHeight
      • elem.scrollLeftelem.scrollTop
    • 设置焦点相关的 API
      • elem.focus()
      • elem.computedRoleelem.computedName
      • elem.innerText
  • 窗口坐标相关的 API
    • window.scrollXwindow.scrollY
    • window.innerHeightwindow.innerWidth
    • window.visualViewport.heightwindow.visualViewport.widthwindow.visualViewport.offsetLeftwindow.visualViewport.offsetTop
  • 文档相关的 API (Document API)
    • documnet.scrollingElemnt
    • document.elementFromPoint
  • 表单相关的 API(设置选择+焦点)
    • inputElem.focus()
    • inputElem.select()textareaElem.select()
  • 鼠标事件,读取鼠标移动坐标相关的API
    • mouseEvt.layerXmouseEvt.layerYmouseEvt.offsetXmouseEvt.offsetY
  • 调用 getComputedStyle()函数
    • 通常会强制重新计算样式:window.getComputedStyle()
    • 通常也会强制布局:window.getComputedStyle()
      • 元素在一个 Shadow Tree 中
      • 有媒体查询(与视窗相关)的属性:
        • min-widthmin-heihgtmax-widthmax-heightwidthheight
        • aspect-ratiomin-aspect-ratiomax-aspect-ratio
        • device-pixel-ratioresolutionorientationmin-device-pixel-ratiomax-devicce-pixel-ratio
      • 请求的属性是下列之一
        • widthheight
        • toprightbottomleft
        • marginmargin-topmargin-rightmargin-bottommargin-left (只有margin固定的情况下才会发生)
        • paddingpadding-toppadding-rightpadding-bottompadding-left(只有padding固定的情况下才会发生)
        • transformtransform-originperspective-origin
        • translaterotatescale
        • gridgrid-templategrid-template-columnsgrid-template-rows
        • perspective-origin
  • 获取尺寸范围 API
    • range.getClientRects()range.getBoundingClientRect()

来看一些简单的例子:

var bstyle = document.body.style; // 保存 body 的 style 对象

bstyle.padding = "20px";     //     触发重排 + 重绘
bstyle.border = "10px solid red"; //  触发重排 + 重绘

bstyle.color = "blue";     // 触发重绘
bstyle.backgroundColor = "#fad";     // 触发重绘

bstyle.fontSize = "2em";     // 触发重排 + 重绘

// 创建新的DOM元素,触发重排 + 重绘
document.body.appendChild(document.createTextNode('dude!'));

浏览器的渲染队列

正因为渲染树的重绘和重排会造成比较大的性能损失,所以浏览器本身也一直在努图将其损失减少到最少。一个减少损失的策略就是,当遇到会造成重排或者重绘的操作时,选择不执行或者至少不马上去执行。不马上执行的意思是,“浏览器会在其内部生成一个队列对这些会造成损失的操作进行缓存,到了合适的时机再分批执行。通过这种方式,许多次会造成重排的操作就会被盒并成一次,结果只会造成一次重排”。这里合适的时机往往是缓存操作的队列达到了一定的数量,或者过了间隔时间。这样的机制就是 浏览器渲染队列机制!

思考下面这段代码:

var elem = document.getElementById('container1');
console.time(1);
elem.style.borderLeft = '1px';  // 重排 + 重绘
elem.style.borderRight = '2px'; // 重排 + 重绘
elem.style.color = 'blue';      // 重绘
elem.style.padding = '5px';     // 重排 + 重绘
console.timeEnd(1);             // 1: 0.303ms

使用 JavaScript 脚本动态改变了 #container 元素的样式四次(分别是border-leftborder-rightcolorpadding),每次改变都会引起重排重绘,所以上面的代码总共有三次重排和一次重会过程。但我们肯定会想,这四次改变其实只触发一次“重排重绘”会更好。实际上,现在浏览器早已经对此进行了优化,浏览器采用其渲染队列机制,会先把这四次修改保存起来,再批量进行一次重排重绘,并不需要进行四次重排重绘

浏览器内部有一个修改队列会用来存储修改操作,在合适的时候才会统一进行刷新!

那何时是 “合适的时候”呢?

如果用户的操作需要强制刷新队列并要求计划任务立即执行,则会立刻进行刷新,否则将等到本次事件循环结束时才统一进行队列刷新

JavaScript中有些执行操作(API)则会打破浏览器针对性能损失的优化操作,比如获取布局信息的相关 API 操作会导致队列立即刷新。因为,如果我们的程序需要这些值,那么浏览器需要返回最新的值就必须执行一次重排重绘:

  • elem.offsetTopelem.offsetLeftelem.offsetWidthelem.offsetHeight
  • elem.scrollTopelem.scrollLeftelem.scrollWidthelem.scrollHeight
  • elem.clientTopelem.clientLeftelem.clientWidthelem.clientHeight
  • elem.getComputedStyle()

也就是说,上面示例中的代码,浏览器自身已经做了优化,只需要一次重排重绘。如果上面的代码换成下面这样,重排重绘次数就增加了(强制刷新渲染队列):

var elem = document.getElementById('container1');
console.time(2);
elem.style.borderLeft = '1px';
elem.offsetWidth; // 访问元素宽度,触发重排重绘
elem.style.borderRight = '2px';
elem.offsetWidth; // 访问元素宽度,触发重排重绘
elem.style.color = 'blue'; 
elem.style.padding = '5px'; 
elem.offsetWidth; // 访问元素宽度,触发重排重绘
console.timeEnd(2); //(2: 11.944ms)

时间从 0.303ms 增加到 11.944ms ,虽然两段代码没有遵循单一变量的测试原则(多了三个语句),但两次测试的结果差距在的几乎可以把这个因素的影响比例消除。

上面的代码中,我们在布局信息改变时穿插着查询布局信息,这个操作打断了刷新队列的缓存操作,浏览器必须立即进行重排重绘,这个操作就非常耗时了。所以我们应该 尽量不要在布局信息改变时做查询

最小化重排重绘

重绘重排是性能提升的严重瓶颈,而页面渲染的重排重绘是避无可避的,我们只能尽可能的采用一些方法来减少重排重绘,尤其是重排。接下来的一些方法可以让我们尽可能的最小化重排重绘。

批量编辑 HTML 元素

如果你要在 JavaScript 中的某个地方多次改变一个 DOM 元素,请在你把它从 DOM 中删除之后再做。

var element = document.getElementById('example-element');
var parentElement = element.parentElement;
var removedElement = parentElement.removeChild(element); // 触发重排

批量编辑被移除的元素,并将其重新添加到 DOM 中。

removedElement.style.opacity = '0.5';
removedElement.style.padding = '20px 10px';
removedElement.style.width = '200px';
parentElement.appendChild(removedElement); // 触发重排

或者,你可以隐藏该元素,编辑它,然后再显示它。

var element = document.getElementById('example-element');
element.style.display = 'none'; // 隐藏该元素, 触发重排
element.style.opacity = '0.5';
element.style.padding = '20px 10px';
element.style.width = '200px';
element.style.display = 'block'; // 显示该元素, 触发重排

请注意,在这两种情况下,一个重排被触发两次,所以确保你的代码会触发很多次重排,才使用这种技术。

尽可能在 DOM 树末端处编辑元素

由于重排的螺旋效应,建议在 DOM 树中尽可能的末端位置触发重排,以尽量减少可能在子元素上触发的后续重排。比如你想切换一个类,以便应用一组 CSS 样式,那么就始终在你想改变的元素上这样做,而不是他的父元素上。如果你在整个 HTML 中使用较少的容器元素,你可以获得一些性能上的改善。

文档树的修改

文档树的修改将触发重排。向 DOM 添加新的元素、改变文本节点的值 或改变各种属性,都会触重排。一个接一个地做几个改动,可能会触发不止一次的重排。因此,最好在一个不显示的 DOM 树片段中做多个改动。然后可以在一个单一的操作中对实时文档的 DOM 进行改变。

var docFragm = document.createDocumentFragment();
var elem, contents;
for(var i = 0; i < textlist.length; i++) {
    elem = document.createElement('p');
    contents = document.createTextNode(textlist[i]);
    elem.appendChild(contents);
    docFragm.appendChild(elem);
}
document.body.appendChild(docFragm);

文档树的修改也可以在元素的克隆上进行,在修改完成后与真正的元素进行交互,从而实现多次调整只触发一次重排。

var original = document.getElementById('container');
var cloned = original.cloneNode(true);
cloned.setAttribute('width', '50%');
var elem, contents;
for(var i = 0; i < textlist.length; i++) {
    elem = document.createElement('p');
    contents = document.createTextNode(textlist[i]);
    elem.appendChild(contents);
    cloned.appendChild(elem);
}
original.parentNode.replaceChild(cloned, original);

请注意,如果元素包含任何表单控件,就不应该使用这种方法,因为用户对其值的任何改变都不会反映在主 DOM 树中。如果你需要依赖事件处理程序附加到元素或其子元素,也不应该这么做,因为理论上它们不应该被克隆。

避免检查大量的节点

当试图定位一个特定的节点,或特定的节点子集时,使用 DOM 内置的方法和集合来缩小搜索范围,尽可能减少节点的数量。例如,如果你想在文档中找到一个未知的元素,它有一个特定的属性,你可以使用这个。

var allElements = document.getElementsByTagName('*');
for(var i = 0; i < allElements.length; i++) {
    if(allElements[i].hasAttribute('someattr')) {
        // …
    }
}

即使我们忽略了更高有的技术,如 XPath,这个例子仍然有两个问题使它很慢。首先,它搜索每一个元素,根本没有尝试缩小搜索范围;其二,它仍然在继续搜索,甚至在找到它想要的元素之后。比如说,已知未知的元素在一个 idinherediv 里面,这个代码可以表现得更好。

var allElements = document.getElementById('inhere').getElementsByTagName('*');
for(var i = 0; i < allElements.length; i++) {
    if(allElements[i].hasAttribute('someattr')) {
        // …
        break;
    }
}

如果你已知未知元素是 div 的子元素(直接后代),那么这种方法可能会更快,这取决于 div 的子代元素的数量和其childNodes 集合的长度。

var allChildren = document.getElementById('inhere') // h3 id=h3 id=.childNodes; for(var i = 0; i < allChildren.length; i++) { if(allChildren[i].nodeType == 1 && allChildren[i].hasAttribute('someattr')) { // … break; } }

一次测量

浏览器可能会为你缓存几个变化,当这些变化全部完成后才会重排一次。但是,请注意,对元素进行测量会迫使它重排,只有这样测量的结果才会准确。这些变化可能会或可能不会被明显的重排,但重排本身仍然要在幕后发生。

当使用 elem.offsetWidth 等属性进行测量,或使用 elem.getComputedStyle 等方法时,就会产生这种效果。即使不使用这些数字,只要在浏览器仍在缓存变化时使用这两种方法,就足以触隐藏式重排。如果这些测量是重复进行的,应该考虑只测量一次,并将结果存储起来,以后可以使用。

// 未经优化的代码:在循环中计算一个元素的高度
var list = document.getElementById('list');
var listItems = Array.from(list.children);

for (var i = 0; i < listItems.length; i++) {
    var listParentHeight = list.parentElement.offsetHeight; // 每个循环都会计算一次父元素的高度
    listItems[i].style.marginTop = Math.floor( listParentHeight / listItems.length - 10) + 'px'; 
}

// 优化后的代码:用变量存储了元素的高度值,并在循环中使用该值来代替,在再次将其添加到 DOM 之前,还删除了子元素进行批量编辑
var list = document.getElementById('list');
var listParent = list.parentElement;
var listParentHeight = listParent.offsetHeight; // 将父元素的高度存储在一个变量中
var removedList = listParent.removeChild(list); // 为批量编辑删除列表
var listItems = Array.from(removedList.children);

for (var i = 0; i < listItems.length; i++) {
    listItems[i].style.marginTop = Math.floor( listParentHeight / listItems.length - 10) + 'px'; 
}

listParent.appendChild(removedList); // 在编辑后将列表添加回来

注意,这样做可能会让代码变得更冗余,而且最后会变得不可读,不好维护。所以一定要用一个语义化名称的函数来封装这个过程,并且做好注释,说明这个函数是用来做性能优化的,即减少重排次数

你可能已经注意到了,优化后的代码也会在编辑其子项之前删除列表,因为相反,每次添加一些外边距(margin-top)都会导致重排。如果你要处理很多类似上述的情况,可以考虑使用 fastdom脚本库。该脚本库允许你对所有的测量或产生变化的过程进行批量处理。

分离读写操作

DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。

// bad 强制刷新 触发四次重排+重绘
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';
div.style.right = div.offsetRight + 1 + 'px';
div.style.bottom = div.offsetBottom + 1 + 'px';


// good 缓存布局信息 相当于读写分离 触发一次重排+重绘
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
var curRight = div.offsetRight;
var curBottom = div.offsetBottom;

div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';
div.style.right = curRight + 1 + 'px';
div.style.bottom = curBottom + 1 + 'px';

原来的操作会导致四次重排,读写分离之后实际上只触发一次重排,这都得益于浏览器的渲染队列机制。

当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。

在变化过于频繁的元素上使用固定或绝对定位

如果你的网站上有任何元素过于频繁地改变它们的布局,它们可能也会影响到其他元素的布局,这将引发层叠重排效应(文档中被重排的部分越多,重排的时间就越长)。被绝对定位或固定定位的元素,不会影响主文档的布局。也就是说,被绝对定位或固定定位的元素,如果造成重排,也只有它们自己是重排。因此,如果一个动画不需要应用于整个文档,那么它最好只应用于一个定位元素。

例如,当对一个元素的尺寸(widthheight)进行动画处理时,最好使用position:fixedposition: absolute 来定位该元素。这样,在改变宽度和高度时,动画元素不会影响它周围元素,从而减少不需要的重排次数。

使用最佳实践的布局技术

不要使用内联样式或表格进行布局:

  • 内联样式会在下载 HTML 时影响布局,并引发额外的重排
  • 表格布局是最昂贵的,因为解析器需要不止一次地计算单元格的尺寸

一些现代 CSS 布局模块,比如 CSS Flebox ,CSS Grid 等布局具有更好的性能(使重排对页面性能的影响相对会减少)。Google 开发者对 Flexbox 、浮动(float)以及 inline-block 几种布局方式做过简单的测试,从测试结果来看,Flexbox布局要比 inline-blockfloat可以使重排更快。对于 1000 个子元素,重排过程来比,Flexbox 比浮动快 1.3倍,比inline-block2.3倍。

即使如此,也要确保尝试不同的布局技术,看哪种技术在你的环境中更有效。布局有一个最基本的原则:尽可能避免元素尺寸和位置的变化

使用 Flexbox 布局也会对性能产生影响,因为Flex项目的位置和尺寸会随着 HTML 的下载而改变。

使用visibility 替代 display: none

在可能的情况下,使用 visibility: hiddenvisibility: visible 来隐藏和显式元素,而不是使用 display: nonedisplay: block

这其中的关键是,当设置visibilityhidden ,该元素仍会在 DOM 布局中占据空间,元素的宽度和高度不会改变,浏览器不需要重新计算布局,不会触发重排(但重绘还是避免不了);而displaynone 会直接把 DOM 元素从渲染树上移除,在整个布局中不占任何空间,将none 变为其他值,比如 block 时,元素的宽度和高度就发生了变化,因些会触发重排。

因此,如果没有必要将该元素从布局中移除,只需要使用 visibility: hidden ,这样可以减少重排的次数。

修改一个不见的元素

当一个元素的 display 属性设置为 none 时,它将不需要重新绘制,即使它的内容被改变,因为它没有被显示。这可以作为一个优势来使用。如果需要对一个元素或其内容做一些改变,而且不可能将这些改变合并到一次重绘中,可以将该元素设置为 display: none,进行改变,然后将该元素设置为正常显示。

这将触发两次额外的重绘,一次是当元素被隐藏时,另一个是当它再次出现时,但整体效果会更快(注意,display: none 为触发重排)。如果元素本身影响了滚动的偏移量,它也可能导致不必要的滚动条跳动。然而,它可以很容易地应用于一个定位的元素,而不会造成难看的效果。

var posElem = document.getElementById('animation');
posElem.style.display = 'none';
posElem.appendChild(newNodes);
posElem.style.width = '10em';
// Other changes…
posElem.style.display = 'block';

同时进行多个样式的修改

就像 DOM 树的修改一样,为了尽量减少重绘或重排的次数,有可能同时行时进行几个与样式个关的修改。常见的方法是一次设置一个样式:

var toChange = document.getElementById('mainelement');
toChange.style.background = '#333';        // 触发重绘
toChange.style.color = '#fff';             // 触发重绘
toChange.style.border = '1px solid #00f';  // 触发重排 + 重绘

上面代码是一种不好的使用方式,可能会触发多次重排和重绘。有两个主要的方法可以更好的做到这一点。

如果元素本身需要采用几种样式,而这些样式的值都是预先知道,那么可以改变元素的类,然后它将采用为该类定义的所有新样式:

div {
    background: #ddd;
    color: #000;
    border: 1px solid #000;
}

.highlight {
    background: #333;
    color: #fff;
    border: 1px solid #00f;
}

document.getElementById('mainelement').className = 'highlight';

第二种方法是为元素定义一个新的样式属性,而不是一个接一个地分配样式。这通常适用于动态变化,如动画,新的样式规则无法预先知道。这可以通过使用样式对象 cssText 属性,或者 setAttribute 来完成:

var posElem = document.getElementById('animation');
var newStyle = 'background: ' + newBack + ';' + 'color: ' + newColor + ';' +  'border: ' + newBorder + ';';
if(typeof(posElem.style.cssText) != 'undefined') {
    posElem.style.cssText = newStyle;
} else {
    posElem.setAttribute('style', newStyle);
}

使用 cssText 来一次性给元素添加多个样式,只会触发一次重排,而不是多次。

使用 textContent 替代 innerText

当请求一个 HTML 元素的文本时,可以使用 elem.textContextelem.innerText ,但它们的区别在于:

  • elem.textContent 返回元素内部的所有文本为隐藏或可见
  • elem.innerText 在返回可见文本之前需要计算布局

相比而言,elem.innerText 对性影响的影响要大于 elem.textContent 。如果这两个方法都有同时实现所需要的功能时,应该尽可能的使用 elem.textContent ,减少elem.innerText 的使用。这样可以减少重排次数,提高性能。

尽量减少 CSS 规则的数量

CSSTriggers 网站可以查出哪些 CSS 的属性会触发 重排、重绘 和 合成。如果你的 CSS 规则越多,被触发的 重排和重绘概率就越多,换句话说,使用的 CSS 规则越少,越可以避免触发重绘,重排。另外,你也应该尽可能的避开复杂的 CSS 选择器的使用。

  • 在减少 CSS 规则(删除未使用的 CSS,避免冗余和重写等),浏览器不必在样式计算过程中反复查看相同的样式
  • 减少 CSS复杂和深嵌套的选择器,浏览器就能减少计算时间

浏览器在“重新计算样式”阶段,会计算要应用于元素的样式。要做到这一点,浏览器道先要找出 CSS 中指向 DOM 树中某一特定元素节点的所有选择器,然后它浏览这些选择器中的所有样式规则,并决定哪些规则将被实际应用于该元素。在 CSS 中尽可能做到上面两点,可以减少样式计算时间,而且还可避免触发更多的重排和重绘。

某些 DOM 事件回调函数,使用限流策略

我们知道,改变浏览器视窗大小会频繁触发重排重绘,导致 UI 迟钝。这是因为我们监听 resizescroll 事件的时候,一旦用户拖动窗口,或者滚动滚动条时,将会频繁的触发回调函数。为了避免这种频繁触发回调函数的调用,需要在这些函数上使用 setTimeout 限制操作频率。大概的意思就是,设置一个操作时间阀值,例如 300ms ,如果本次触发回调时,上一次设定的定时器还存在,那么就不执行回调操作,并重设 300ms 的定时器。这样一来,就可以将回调函数调用频率限制在最快 300ms 一次。

渲染分层与硬件加速

在实际开发中可以利用渲染分层的机制进行减少重排重绘的范围,以此加速渲染。前面提到过,渲染新帧主要有三个步骤:布局(Layout)渲染(Paint)合成(Composite),其中布局和渲染对于性能来说是昂贵的,但合成就好很多。并且在 CSS 中有些属性会让渲染不需要经过 “布局”和“渲染”阶段,直接进入“合成”阶段,比如 opacitytransform 等属性。而这个合层指的就是“渲染层合并”。

在浏览器中,页面内容是存储为由 Node 对象组成的树状结构,也就是 DOM 树。每一个 HTML element 元素都有一个 Node 对象与之对应,DOM 树的根节点永远都是 Document Node。这一点相信大家都很熟悉了,但其实,从 DOM 树到最后的渲染,需要进行一些转换映射。

一般来说,拥有相同的坐标空间的 LayoutOjbects 属于同一个渲染层(PaintLayer)。PaintLayer 最初是用来实现层叠上下文(Stacking Context),以此来保证页面元素以正确的顺序合成(Composite)。因此,满足形成层叠上下文条件的 LayoutObject g 一定会为其创建新的渲染层,当然也有一些特殊情况,会为特殊的 LayoutObject 创建一个新的渲染层,比如 overflow 属性不为 visible 的元素。根据创建 PaintLayer 的原因不同,可以将其为分常见的三类:

  • NormalPaintLayer
    • 根元素(<html>
    • 有明确的 position 属性,且值为非 staticrelativeabsolutefixedsticky
    • 透明的,即 opacity 值小于 1
    • CSS 滤镜 filter
    • CSS 蒙层 mask
    • 混合模式,mix-blend-mode 属性,且值为非 normal
    • 变换,transform 属性,且值为非 none
    • backface-visibility 值为 hidden
    • CSS reflection
    • CSS 的 column-count 不为 autocolumn-width 不为 auto
    • 动画中运用了opacitytransfromfliterbackdrop-filter
  • OverflowClipPaintLayer
    • overflow 值不为 visible
  • NoPaintLayer
    • 不需要 Paint 的 PaintLayer,比如一个没有视觉属性(如 backgroundcolorbox-shadow 等)的空 div

满足以上条件的 LayoutObject 会拥有独立的渲染层,而其他的 LayoutObject 则和其第一个拥有渲染层的父元素共用一个!

某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。

每个 GraphicsLayer 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后 draw 到屏幕上,此时,我们的页面也就展现到了屏幕上。

渲染层提升为合成层的原因有一下几种:

  • 直接原因
    • 硬件加速的 <iframe> 元素(比如 iframe 嵌入的页面中有合成层)
    • 视频元素 <video>
    • 3D <canvas> 元素或 硬件加速的 2D <canvas> 元素
    • 3D 的 transform
    • backface-visibilityhidden
    • animationtransition 中使用了opacitytransformfilterbackdrop-filter (需要在动效当前状态中才有效,当动效未开始或结束,提升全成层也会失效)
    • will-change 设置为 opacitytransform
  • 后代元素原因
    • 有合成层后代同时本身有 transformopacity (小于1)、maskfilterreflectionmix-blend-mode 不为normalbackdrop-filter
    • 有合成层后代同时本身 overflow 不为 visible
    • 有合成层后代同时本身 position 为非 static
    • 有 3D Transform 的合成层后代同时本身有 transform-stylepreserve-3d
    • 有 3D Transform 的合成层后代同时本身有 preservetive
  • Overlap 重叠原因
    • 重叠或者说部分重叠在一个合成层之上
    • filter 效果同合成层重叠
    • transform 变换后同合成层重叠
    • overflowscroll 情况下同合成层重叠

注:渲染层提升为合成层有一个先决条件,该渲染层必须是 SelfPaintingLayer(基本可认为是上文介绍的 NormalPaintLayer)。

一旦 renderLayer 提升为了合成层就会有自己的绘图上下文,并且会开启硬件加速,有利于性能提升

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快,也就是说,提升到合成层后合成层的位图会交 GPU 处理,但请注意,仅仅只是合成的处理(把绘图上下文的位图输出进行组合)需要用到 GPU,生成合成层的位图处理(绘图上下文的工作)是需要 CPU
  • 当需要 Repaint 时,只需要 Repaint 本身,不会影响到其他的层,当需要 Repaint 的时候可以只 Repaint 本身,不影响其他层,但是 Paint 之前还有 Style,Layout 那就意味着即使合成层只是 Repaint 了自己,但 Style 和 Layout 本身就很占用时间
  • 对于 transformopacity 效果,不会触发 layout 和 paint,仅仅是 transformopacity 不会引发 Layout 和 Paint,其他的属性不确定

一般一个元素开启硬件加速后会变成合成层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。

通常来说,提升合成层的最好方式是使用 CSS 的 will-change 属性 或 使用 3D Transform 的 translateZ()开启硬件加速!

虽然合成(Composite)能减少重绘重排,但也并不意味着,页面在渲染过程中,提升合成数越多,性能就越好。即,我们需要 避免过渡使用提升规则;各层都需要内存和管理开销

使用 transformopacity 属性更改来实现动画

性能最佳的像素管道版本会避免重排和重绘,只需要合成更改:

为了实现此目标,需要坚持更改可以由合成器单独处理的属性。具前只有两个属性符合条件:transformopacity

使用 transformopacity 时要注意的是,你更改这些属性所在元素应处于其自身的合成器层。要做一个层,你必须提升元素。可以使用 will-change 将该元素(打算设置动画的元素)提升到其自己的层,比如:

.moving-element {
    will-change: transform;
}

对于不支持 will-change 的浏览器,可以通过translateZ() 或 3D Transform 开启3D加速器来将其提升到自己的层:

.moving-element {
    transform: translateZ(0);
}

这可以提前警示浏览器将出现更改,根据你打算更改的元素,浏览器可能可以预先安排,如创建合成器层。

管理层并避免层数激增

层往往有助于性能,知道这一点可能会诱使开发者通过以下代码来提升页面上的所有元素:

* {
    will-change: transform;
    transform: translateZ(0);
}

这是以迂回方式说你想要提升页面上的每个元素。此处的问题是你创建的每一层都需要内存和管理,而这些并不是免费的。事实上,在内存有限的设备上,对性能的影响可能远远超过创建层带来的任何好处。每一层的纹理都需要上传到 GPU,使GPU与GPU之间的带宽、GPU上可用纹理处理的内存都受到进一步限制。

如无必要,请勿提升元素

合理使用 will-change

虽我说 will-change 可以提升元素到层,能提高性能,但这个属性应该被认为是最后的手段,它不是为了过早的优化。只有消退你必须处理性能问题时,你才应该使用它。如果你滥用的话,它不但不能起到性能优化的作用,反正会降低 Web 性能。

**使用 will-change 表示该元素在未来会发生变化 **

因此,如果你试图将 will-change 和动画同时使用,它将不会给你带来优化。因此,建议在父元素上使用 will-change ,在子元素上使用动画。

.animate-element-parent {
    will-change: opacity;
}

.animate-element {
    transition: opacity .2s linear
}

不要使用非动画元素

当你在一个元素上使用 will-change 时,浏览器会尝试着通过将元素移动到一个新的图层并将转换工作给 GPU来优化它。如果你没有任何要转换的内容,则会导致资源浪费。除此之外,使用 will-change 需要做到:

  • 不要将will-change应用到太多元素上:浏览器已经尽力尝试去优化一切可优化的东西了。有一些更强力的优化,如果与 will-change 结合在一起的话,有可能会消耗很多机器资源,如果过度使用的话,可能会导致页面响应缓慢。比如 *{will-change: transform, opacity}
  • 有节制地使用:通常,当元素恢复到初始状态时,浏览器会丢弃掉之前做的优化工作。但是如果直接在样式表中显式声明 will-change 属性,则表示目标元素可能会经常变化,浏览器会将优化工作保存得比之前更久。所以最佳实践是 当元素变化之前和之后通过JavaScript脚本来切换 will-change 的值
  • 不要过早应用 will-change 优化 :如果你的页面在性能方面没什么问题,则不要添加 will-change 属性来榨取一顶点的速度。 will-change 的设计初衷是作为最后的优化手段,用来尝试解决现有的性能问题。它不应该被用来预防性能问题。过渡使用 will-change 会导致大量的内存占用,并会导致更复杂的渲染过程,因为浏览器会试图准备可能存在的变化过程。这会导致更严重的性能问题
  • 给它足够的工作时间:这个属性是用来让开发者告诉浏览器哪些属性可能会存在变化的。然后浏览器可以选择在变化发生前提前去做一些优化工作。所以给浏览器一点时间去真正做这些优化工作是非常重要的。使用时需要尝试去找到一些方法提前一定时间获知元素可能发生的变化,然后为它加上 will-change 属性

最后需要注意的是,建议在完成所有动画后,将元素的 will-change 属性删除。下面这个示例展示了如何使用JavaScript脚本正确地使用 will-change ,在大部分的场景中,我们都应该这样使用:

var el = document.getElementById('element');

// 当鼠标移动到该元素上时给该元素设置 will-change 属性
el.addEventListener('mouseenter', hintBrowser);
// 当 CSS 动画结束后清除 will-change 属性
el.addEventListener('animationEnd', removeHint);

function hintBrowser() {
    // 填写上那些你知道的,会在 CSS 动画中发生改变的 CSS 属性名们
    this.style.willChange = 'transform, opacity';
}

function removeHint() {
    this.style.willChange = 'auto';
}

在实际使用 will-change 需要 五可做,三不可做

  • 在样式表中少用 will-change
  • will-change 足够的时间令其发挥该有的作用
  • 使用 <custom-ident> 来针对超特定的变化(如,left, opacity等)
  • 如果需要的话,可以JavaScript中使用它(添加和删除)
  • 修改完成后,删除 will-change
  • 不要同时声明太多的属性
  • 不要应用在太多元素上
  • 不要把资源浪费在已停止变化的元素上

避免大型、复杂的布局和布局抖动

布局是浏览器计算各元素几何信息的过程:元素的大小以入在页面中的位置 。根据所用的 CSS、元素的内容或父级元素,每个元素都将有显式或隐含的大小信息。这些过程与样式计算相似,需要布局的元素数量越多,布局复杂性程度就越大,造成重排重绘机率也越大,渲染耗时就越大。这是因为:

  • 布局的作用范围一般为整个文档
  • DOM 元素的数量将影响性能,应尽可能避免触发重排
  • 避免强制同步布局和布局抖动:先读取样式值,然后进行样式更改

尽可能避免布局操作

当你更改样式时,浏览器会检查任何更改是否需要计算布局,以及是否需要更新渲染树。对“几何属性”的更改都需要布局计算。

.box {
    width: 20px;
    height: 20px;
}

/**
* 改变宽高,触发重排
*/
.box--expanded {
    width: 200px;
    height: 350px;
}

布局(重排)几乎总是作用到整个文档。如果有大量元素,将需要很长时间来算出所有元素的位置和尺寸。我们都应当在应用的高压力点期间尝试完全避免触发重排

避免强制同步布局

将一帧送到屏幕会采用如下顺序:

首先 JavaScript 运行,然后计算样式,然后布局。但是,可以使用JavaScript强制浏览器提前执行布局。这被称为强制同步布局

要记住的第一件事是,在JavaScript运行时,来自上一帧的所有旧布局值是已知的,并且可供你查询。因此,如果(例如)你要在帧的开头写出一个元素(让我们称其为“框”)的高度,可能编写一些如下代码:

// 安排我们的函数在帧的开始处运行
requestAnimationFrame(logBoxHeight);

function logBoxHeight() {
    // 获取盒子的高度(像素)并记录下来
    console.log(box.offsetHeight);
}

如果在请求此框的高度之前,已更改其样式,就会出现问题:

function logBoxHeight() {

    box.classList.add('super-big');

    // 获取盒子的高度(像素)并记录下来。
    console.log(box.offsetHeight);
}

现在,为了回答高度问题,浏览器必须先应用样式更改(由于增加了 super-big 类),然后运行布局。这时它才能返回正确的高度。这是不必要的,并且可能是开销很大的工作。

因此,始终应先批量读取样式并执行(浏览器可以使用上一帧的布局值),然后执行任何写操作。正确完成时,以上函数应为:

function logBoxHeight() {
    // 获取盒子的高度(像素)并记录下来
    console.log(box.offsetHeight);

    box.classList.add('super-big');
}

大部分情况下,并不需要应用样式然后查询值;使用上一帧的值就足够了。与浏览器同步(或比其提前)运行样式计算和布局可能成为瓶颈,并且您一般不想做这种设计。

避免布局抖动

有一种方式会使强制同步布局甚至更糟: 接二连三地执行大量这种布局。看看这个代码:

function resizeAllParagraphsToMatchBlockWidth() {

    // 使浏览器进入读-写-读-写的循环
    for (var i = 0; i < paragraphs.length; i++) {
        paragraphs[i].style.width = box.offsetWidth + 'px';
    }
}

此代码循环处理一组段落,并设置每个段落的宽度以匹配一个称为 box 的元素的宽度。这看起来没有害处,但问题是循环的每次迭代读取一个样式值 (box.offsetWidth),然后立即使用此值来更新段落的宽度 (paragraphs[i].style.width)。在循环的下次迭代时,浏览器必须考虑样式已更改这一事实,因为 offsetWidth 是上次请求的(在上一次迭代中),因此它必须应用样式更改,然后运行布局。每次迭代都将出现此问题!

此示例的修正方法还是先读取值,然后写入值:

// 读.
var width = box.offsetWidth;

function resizeAllParagraphsToMatchBlockWidth() {
    for (var i = 0; i < paragraphs.length; i++) {
        // 写
        paragraphs[i].style.width = width + 'px';
    }
}

在“分离读写操作”也提到这个点。如果要保证安全,应当使用类似 FastDOM 这样的脚本库,它会自动为你批处理读取和写入,应该能防止你意外触发强制同步布局和布局抖动。

降低绘制的复杂性

在谈到绘制时,一些绘制比其他绘制的开销更大。例如,绘制任何涉及模糊(例如阴影)的元素所花的时间将比(例如)绘制一个红框的时间要长。但是,对于 CSS 而言,这点并不总是很明显: background: red;box-shadow: 0, 4px, 4px, rgba(0,0,0,0.5); 看起来不一定有截然不同的性能特性,但确实很不相同。

您要尽可能的避免绘制的发生,特别是在动画效果中。因为每帧 10 毫秒的时间预算一般来说是不足以完成绘制工作的,尤其是在移动设备上。

使输入处理程序去除抖动

输入处理程序可能是应用出现性能问题的原因,因为它们可能阻止帧完成,并且可能导致额外(且不必要)的布局(重排)工作。

避免长时间运行输入处理程序

在最快的情况下,当用户与页面交互时,页面的合成器线程可以获取用户的触摸输入并直接使内容移动。这不需要主线程执行任务,主线程执行的是 JavaScript、Layout、Style 和Paint。

但是,如果你附加一个输入处理程序,例如 touchstarttouchmovetouchend ,则合成器线程和须等待处理程序执行完成,因为你可能选择调用 preventDefault() 并且会阻止触摸滚动发生。即使没有调用 preventDefault() ,合成器也必须等待,这样用户滚动会被阻止,这就可能导致卡顿和漏掉帧。

总之,要确保你运行的任何输入处理程序应快速执行,并且允许合成器执行其工作。

避免在输入处理程序中更改样式

与滚动和触摸的处理程序相似,输入处理程序被安排在紧接任何 requestAnimationFrame 回调之前运行。

如果在这些处理程序之一内进行视觉更改,则在 requestAnimationFrame 开始时,将有样式更改待待处理。如果按照“避免大型、复杂的布局和布局抖动”的建议,在 requestAnimationFrame 回调开始时就读取视觉属性,你将触发强制同步布局。

当屏幕正在发生视觉变化时(有可能会发生重排或重绘),你希望在适合浏览器的时间执行你的工作,也就是正好在帧的开头。保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame

/**
* 如果作为requestAnimationFrame的回调运行,这将在帧的开始运行
*/
function updateScreen(time) {
    // 视觉发生变化的代码放在这...
}

requestAnimationFrame(updateScreen);

框架或示例可能使用 setTimeoutsetInterval 来执行动画之类的视觉变化,但这种做法的问题是,回调将在帧中的某个时点运行,可能刚好的末尾,而这可能经常会使我们丢失帧,导致卡顿。

使滚动处理程序去除抖动

上面两个问题的解决方法相同: 始终应使下一个 requestAnimationFrame 回调的视觉更改去除抖动:

function onScroll (evt) {

// 存储滚动值,以备后用
lastScrollY = window.scrollY;

// 防止多个 rAF 回调
if (scheduledAnimationFrame)
    return;

scheduledAnimationFrame = true;
requestAnimationFrame(readAndUpdatePage);
}

window.addEventListener('scroll', onScroll);

这样做还有一个好处是使输入处理程序轻量化,效果非常好,因为现在你不用去阻止计算开销很大的代码的操作,例如滚动或触摸!

优化CLS

CLS(Cumulative Layout Shift)指的是 累积布局偏移 。它是一个以用户为中心,用来衡量可视区域元素移定性的重要标准,可以帮助我们定量计算出用户遇到意料之外的布局偏移的频率,CSL小可以确保我们的页面有一个良好的用户体验。

CLS会计算出页面整个生命周期中所有发生的预料之外的布局稨移的得分的总和。每当一个可视元素位置发生改变,就是发生了布局偏移。布局偏移就会造成布局信息的改变(大小或位置),也就触发页面的重排和重绘。

最常见的影响 CLS 的分数的有:

  • 未指定尺寸的图片
  • 未指定的广告,嵌入元素、iframe
  • 动态插入内容
  • 自定义字体(引发 FOIT或FOUT)
  • 在更新DOM之前等待网络响应的操作

我们应该针对这些点做优化,这也可以尽可能的避免页面重排和重绘的最小化。

未指定尺寸的图片

在 Web 早期,开发者会给 <img> 标签显式设置 widthheight 属性,以确保浏览器开始获取图片之前可以分配好空间,这样可以减少页面重排和重绘。

如果没有 widthheight 属性,图片在下载后会导致后续内容的移动。

<img src="hero_image.jpg" alt="..."  width="400" height="400">

你也许会注意到这两个属性没有带单位,这些像素尺寸会确保保留 width x height (比如上面示例 400 x 400)的区域。图片最终会平铺在这个区域,不管原始尺寸是否一致。你也可以通过开发者调试工具,将鼠标悬停在(或点选)该元素上找到图片的尺寸。

我建议使用内在尺寸(图像的原始尺寸)然后当你使用 CSS 改变这些尺寸时,浏览器会将这些尺寸缩小到渲染的尺寸。

但随着响应式Web设计到来,开发者开始忽略在 <img> 上显式设置 widthheight ,开始使用 CSS 来调整图片大小:

img {
    max-width: 100%;
    height: auto;
}

这种方法的缺点是,只有图片下载的时候,浏览器才知道图片的宽高并且分配好空间。图片下载完了,每张图片出现在屏幕上的时候,页面都会重排一次,会导致页面频繁的往下弹。这对于用户体验来说非常不友好,对于页面渲染性能也是致命的。

因此而诞生了 aspect-ratio ,图片的宽高比。比如,x:y 的宽高比,指的是宽度 x 单位,高度 y 单位。这也意味着,我们只要知道宽高之一,就能计算出另一个属性。比如对于一个 16:9 的宽高比而言:

  • 如果图片的宽度是 640px ,根据宽高比可以计算出图片高度为 640 x (9 / 16) = 360px
  • 如果图片的高度是 360px ,根据宽高比可以计算出图片的宽度为 360 x (16 / 9) = 640px

在现代浏览器中,可以基于 widthheight 属性设置默认宽高比,这样就可以避免布局偏移。开发者只需要像下面这样做即可:

<!-- set a 640:360 i.e a 16:9 - aspect ratio -->
<img src="puppy.jpg" width="640" height="360" alt="Puppy with balloons">

img {
    aspect-ratio: attr(width) / attr(height);
}

这样一来,图片加载之前,浏览器就可以根据宽高属性分配好空间。图片加载之后,就可以根据宽度或高度属性,按照宽高比来分配实际空间。

有关于这方面更详细的讨论可以阅读 Github 的 《A more elegant and easier to use solution

注意,只要是媒体对象相关的元素都可以这样使用,比如:

img, video, iframe {
    aspect-ratio: attr(width) / attr(height);
} 

这个也适用于 <picture> 元素和srcset图像(在回退的 <img> 元素上设置宽度和高度)。另外我们还可以使用 <picture><img> 的一些新特性,可以根据用户所在的环境或所持设备加载不同的图片资源:

未指定尺寸的广告,嵌入元素,iframe

广告

广告是造成布局偏移的罪魁祸首之一。经常性,这些广告会有动态尺寸,这样会导致糟糕的用户体验,当你在往下浏览页面的时候,广告突然插入一些可见内容。

在广告的生命周期里,很多点可以导致布局偏移:

  • 广告容器插入到 DOM 的时候
  • 本站代码调整广告容器尺寸的时候
  • 广告代码库加载的时候(导致容器尺寸改变)
  • 广告内容填充容器的时候(如果最终广告的尺寸不一样,导致容器尺寸变化)

好消息是网站可以采用最佳体验,来减少布局偏移。

  • 为广告位静态保留空间。
    • 换句话说,在广告代码库加载之前,就给容器加好样式。
    • 如果要在内容流中插入广告,在插入之前确保通过保留尺寸来消除布局偏移。如果这些广告在屏幕外加载,则没有这个问题。
  • 在视图顶部插入非粘性广告的时候要特别注意。
  • 避免折叠预留的空间,如果广告没有返回,可以在该空间展示占位符。
  • 通过预留广告所需最大尺寸,来避免布局偏移。
    • 这很有效,不过如果广告很小,可能会有大片空白。
  • 根据历史数据,给广告加上合适的尺寸。

如果广告不太可能填满,一些网站会发现在初始的时候折叠广告位可以减少布局偏移。很难做到每一次都能给广告位精准的尺寸,除非这个广告是你自己提供的。

  • 为广告位静态保留空间
    • 给广告容器设置固定的样式,避免代码库加载的时候,重新调整广告的尺寸。
    • 要额外注意一下小尺寸的广告,如果预留很大的空间,会导致大片空白。
  • 避免在视图顶部插入广告
    • 根据CLS的计算规则,在顶部插入广告比在中间插入,造成的影响更大。

嵌入元素和iframe

可嵌入的挂件可以允许你在页面上嵌入Web内容(例如,youtube视频、谷歌地图、社交媒体的帖子等)。这些嵌入元素可以采用多种形式。

  • HTML Fallback,然后JavaScript将该Fallback转换成嵌入元素
  • 内联HTML代码块
  • iframe嵌入

这些嵌入通常不会事先知道嵌入的大小(例如,社交媒体帖子,是否包含图片?视频?或者多行文本?)。结果就是提供嵌入元素的平台经常无法保证预留足够的空间,导致布局偏移。

为了应对这种情况,你可以通过提前计算嵌入元素的足够空间,以最小化CLS。以下工作流可以参考:

  • 使用开发者工具检查最终嵌入的高度
  • 一旦嵌入元素加载,iframe容器根据内容重新调整尺寸。

记下尺寸,并相应设置嵌入元素占位符的样式。你可能还会用到媒体查询来考虑不同的因素。

动态内容

总而言之,避免在已存在的内容上方插入新内容,除非为了响应用户交互。这样可以保证任何布局偏移都是可预期的。你可能经常会遇到从顶部或者底部弹出的一些内容。这经常发生在Banner或者表单的地方,让页面的剩余内容产生偏移。如果你需要展示一些动态内容,请提前预留好空间,避免产生布局偏移。

自定义字体(引发FOIT/FOUT)

下载并渲染自定义字体会引发布局偏移,通过以下两种方式:

  • Fallback字体切换到新字体(FOUT - flash of unstyled text)
  • 从不可见变成可见,因为新字体的渲染缘故(FOIT - flash of invisible text)

以下工具可以帮你最小化影响:

  • font-display 属性可以让你修改自定义字体的渲染表现,通过使用可选值:auto, swap, block, fallbackoptional。不幸的是,除了 optional 之外的属性都会引发 重排,通过以上的其中一种方式。
  • Font Loading API可以减少获取必要字体的时间。

Chrome 83版本之后,可以采取以下方案:

  • 针对关键字体使用 <link rel=preload> ,提高优先级,让字体下载有更高概率赶在FCP之前,这样就能避免布局偏移。
  • <link rel=preload>font-display: optional 结合使用。

简单小结一下:

  • 图片的尺寸,以及其他嵌入元素的尺寸,最开始就设定好,或者预留足够空间,这样可以有效避免布局偏移
  • 利用图片宽高比的属性,可以在优化CLS的同时,做响应式布局
  • 尽可能不要往已存在内容上方添加新内容
  • Web字体尽可能早的加载,避免产生FOIT和FOUT

让元素及其内容尽可能独立于文档树的其余部分(contain)

W3C的CSS Containment Module Level 2提供的 contain。该属性允许我们指定特定的DOM元素和它的子元素,让它们能够独立于整个DOM树结构之外。目的是能够让浏览器有能力只对部分元素进行重绘、重排,而不必每次针对整个页面。即,允许浏览器针对DOM的有限区域而不是整个页面重新计算布局,样式,绘画,大小或它们的任意组合

在实际使用的时候,我们可以通过 contain 设置下面五个值中的某一个来规定元素以何种方式独立于文档树:

  • layout :该值表示元素的内部布局不受外部的任何影响,同时该元素以及其内容也不会影响以上级
  • paint :该值表示元素的子级不能在该元素的范围外显示,该元素不会有任何内容溢出(或者即使溢出了,也不会被显示)
  • size :该值表示元素盒子的大小是独立于其内容,也就是说在计算该元素盒子大小的时候是会忽略其子元素
  • content :该值是 contain: layout paint 的简写
  • strict :该值是 contain: layout paint size 的简写

在上述这几个值中,sizelayoutpaint 可以单独使用,也可以相互组合使用;另外contentstrict是组合值,即contentlayout paint的组合,strictlayout paint size的组合。

containsizelayoutpaint 提供了不同的方式来影响浏览器渲染计算:

  • size: 告诉浏览器,当其内容发生变化时,该容器不应导致页面上的位置移动
  • layout:告诉浏览器,容器的后代不应该导致其容器外元素的布局改变,反之亦然
  • paint:告诉浏览器,容器的内容将永远不会绘制超出容器的尺寸,如果容器是模糊的,那么就根本不会绘制内容

@Manuel Rego Casasnovas提供了一个示例,向大家阐述和演示了contain是如何提高Web页面渲染性能。这个示例中,有10000个像下面这样的DOM元素:

<div class="item">
    <div>Lorem ipsum...</div>
</div>

使用JavaScript的 textContent 这个API来动态更改 div.item > div 的内容:

const NUM_ITEMS = 10000;
const NUM_REPETITIONS = 10;

function log(text) {
    let log = document.getElementById("log");
    log.textContent += text;
}

function changeTargetContent() {
    log("Change \"targetInner\" content...");

    // Force layout.
    document.body.offsetLeft;

    let start = window.performance.now();

    let targetInner = document.getElementById("targetInner");
    targetInner.textContent = targetInner.textContent == "Hello World!" ? "BYE" : "Hello World!";

    // Force layout.
    document.body.offsetLeft;

    let end = window.performance.now();
    let time = window.performance.now() - start;
    log(" Time (ms): " + time + "\n");
    return time;
}

function setup() {
    for (let i = 0; i < NUM_ITEMS; i++) {
        let item = document.createElement("div");
        item.classList.add("item");

        let inner = document.createElement("div");
        inner.style.backgroundColor = "#" + Math.random().toString(16).slice(-6);
        inner.textContent = "Lorem ipsum...";
        item.appendChild(inner);

        wrapper.appendChild(item);
    }
}

如果不使用 contain,即使更改是在单个元素上,浏览器在布局上的渲染也会花费大量的时间,因为它会遍历整个DOM树(在本例中,DOM树很大,因为它有10000个DOM元素):

在本例中,div 的大小是固定的,我们在内部div中更改的内容不会溢出它。因此,我们可以将 contain: strict 应用到项目上,这样当项目内部发生变化时,浏览器就不需要访问其他节点,它可以停止检查该元素上的内容,并避免到外部去。

尽管这个例子中的每一项都很简单,但通过使用 contain,Web性能得到很大的改变,从~4ms降到了~0.04ms,这是一个巨大的差异。想象一下,如果DOM树具有非常复杂的结构和内容,但只修改了页面的一小部分,如果可以将其与页面的其他部分隔离开来,那么将会发生什么情况呢?