减少重绘回流的优化策略以及什么是合成 Composite

87 阅读13分钟

公众号:小博的前端笔记

一:重绘和回流

重绘(Repaint)和回流(Reflow)是浏览器渲染引擎在更新网页显示时的两个核心过程,它们对网页性能有重大影响:

  1. 回流 (Reflow / Layout):

    • 定义: 当渲染树(Render Tree)中的一部分或全部因为元素的尺寸、结构、布局或某些属性(影响几何位置)发生改变,浏览器需要重新计算所有受影响元素的几何属性(位置、大小)的过程。

    • 触发条件: 任何改变元素几何信息的操作都会触发回流。常见触发点:

      • 添加、删除、移动可见的 DOM 元素。
      • 元素尺寸改变(宽度、高度、内边距、边框、外边距)。
      • 内容变化(文本数量改变、图片大小改变等)。
      • 浏览器窗口大小改变(resize 事件)。
      • 激活 CSS 伪类(如 :hover)。
      • 计算 offsetWidth, offsetHeight, clientWidth, clientHeight, scrollTop 等布局属性(因为浏览器需要提供最新值,会强制触发同步回流)。
      • 设置 style 属性值(尤其影响几何布局的属性)。
      • 改变元素的 display 属性(如 noneblock)。
      • 修改影响布局的 CSS 样式(如 width, height, position, float, font-size, line-height, text-align, overflow 等)。
  2. 重绘 (Repaint):

    • 定义: 当元素的外观、样式(如颜色、背景色、边框颜色、可见性等)发生改变,但不影响其布局(几何位置和大小不变)时,浏览器需要重新绘制受影响区域到屏幕上的过程。

    • 触发条件: 改变不影响元素在文档流中位置的样式属性。常见触发点:

      • 改变 colorbackground-colorborder-coloroutline-colorvisibility(非 display: none)等。
      • 改变 text-decoration
      • 改变 background-image
      • 改变 box-shadow

关键关系:

  • 回流必然导致重绘。 如果一个元素的位置或尺寸变了,浏览器肯定需要重新绘制它(以及可能受其影响的其他元素)。
  • 重绘不一定需要回流。 如果只是颜色、背景等不影响布局的属性改变,浏览器只需重绘,不需要重新计算布局。

为什么需要减少重绘和回流? 它们都是计算密集型操作,尤其是在复杂文档(DOM 树很大)或设备性能有限(如移动端)的情况下:

  • 性能开销大: 回流需要遍历渲染树重新计算几何信息,重绘需要重新填充像素。频繁触发会导致 CPU 和 GPU 负载升高。
  • 阻塞渲染: JavaScript 执行和页面渲染通常共享一个主线程。频繁的回流/重绘会占用主线程时间,导致页面卡顿、响应迟缓,用户体验变差(帧率下降)。

二、如何减少重绘和回流(性能优化):

  1. 避免频繁操作样式:

    • 合并多次样式修改: 不要一条条地修改 element.style 属性。尽量一次性修改 style.cssText 或直接切换预定义的 CSS 类 (element.classNameelement.classList)。
    // 不好:触发多次回流/重绘
    el.style.marginLeft = '10px';
    el.style.marginTop = '20px';
    el.style.width = '100px';
    ​
    // 好:合并为一次修改 (使用 cssText 或 类)
    el.style.cssText += '; margin-left: 10px; margin-top: 20px; width: 100px;';
    // 或
    el.classList.add('new-styles'); // .new-styles 类里定义所有需要的样式
    
  2. 批量修改 DOM:

    • 使用 DocumentFragment 在内存中构建一个 DOM 片段,完成所有修改后一次性添加到真实 DOM 中。只触发一次回流。
    • 克隆修改: 克隆一个节点,在副本上修改,然后用副本替换原节点。
    • 隐藏元素再修改 (display: none): 将元素暂时移出文档流 (display: none),进行一系列修改(此时修改不会触发回流),然后再显示它 (display: block 等)。触发两次回流(隐藏和显示时)。
  3. 避免触发同步布局(强制同步回流):

    • 读写分离: 避免在修改样式后立即读取布局属性(如 offsetTop, scrollHeight, getComputedStyle())。浏览器为了给你最新的值,会强制刷新渲染队列,导致立即执行一次回流。
    • 先读后写: 如果需要读取布局属性,最好在修改样式之前一次性读取完所有需要的值,或者将读取操作和写入操作分开(用 requestAnimationFrame 延迟写入)。
    // 不好:强制同步回流 (布局抖动)
    for (let i = 0; i < items.length; i++) {
      items[i].style.width = (baseWidth + offset) + 'px'; // 写
      let newHeight = container.offsetHeight; // 读 (强制回流获取最新高度)
      // ... 可能用 newHeight 做其他事 ...
    }
    ​
    // 好:先读取所有值 (如果可能)
    let containerHeight = container.offsetHeight; // 读一次
    for (let i = 0; i < items.length; i++) {
      items[i].style.width = (baseWidth + offset) + 'px'; // 写
      // 使用之前读取的 containerHeight
    }
    ​
    // 更好:使用 requestAnimationFrame 分离读写 (现代最佳实践)
    function updateSizes() {
      // 在下一帧开始时一次性写入
      for (let i = 0; i < items.length; i++) {
        items[i].style.width = calculateWidth(i) + 'px';
      }
    }
    // 可能先读取一些需要的值...
    requestAnimationFrame(updateSizes);
    
  4. 优化 CSS:

    • 避免使用 CSS 表达式 (calc() 要谨慎): 虽然现代 calc() 优化较好,但过度复杂或频繁变化的计算仍可能影响性能。
    • 避免深层级或复杂的选择器: 选择器匹配效率会影响渲染树构建。
    • 谨慎使用 table 布局: 即使很小的改动也可能导致整个表格回流。尽量用 div 和现代布局技术 (Flexbox, Grid)。
    • 使用 transformopacity 实现动画: 这两个属性通常由合成器线程处理,不会触发主线程的回流和重绘(只触发合成 Composite),是实现高性能动画的首选。
    • 使用 will-change 属性 (谨慎): 提前告知浏览器哪些元素将要变化 (transform, opacity 等),让浏览器提前做好优化准备(如创建独立图层)。但不要滥用,过度使用会增加内存消耗。
  5. 元素位置优化:

    • 使用 position: absoluteposition: fixed 将动画元素或频繁变化的元素脱离普通文档流。这样它们的回流只会影响自身及其子元素(影响范围小),不会引起整个页面的大规模回流。但仍需谨慎使用 top/left 等属性,优先用 transform
  6. 利用浏览器优化:

    • 现代浏览器会将多次回流操作放入队列,然后在合适的时机(如一定时间间隔、代码执行间隙)批量执行一次回流。但如前所述,读取布局属性会强制刷新这个队列,导致立即执行回流。

总结关键策略:

  1. 合并操作: 无论是样式修改还是 DOM 操作,尽可能合并成单次操作。
  2. 脱离文档流: 对频繁变动的元素使用定位 (absolute/fixed) 或 display: none 策略。
  3. 读写分离: 绝对避免在循环或高频事件中交叉读写布局属性。使用 requestAnimationFrame 安排写入。
  4. 善用高性能属性: 动画优先用 transformopacity
  5. 优化 CSS 结构和选择器。

通过应用这些策略,可以显著减少不必要的重绘和回流,从而提升网页的渲染性能和用户体验。在 Chrome DevTools 的 Performance 面板中记录运行时性能,可以直观地看到 Layout (回流) 和 Paint (重绘) 事件及其耗时,是诊断和优化的必备工具。

三、什么是合成 Composite

1. 什么是合成(Composite)?

合成是浏览器渲染流水线(Pixel Pipeline)的最后一步。它负责将页面的不同部分(通常是存储在内存中的位图,称为图层 Layers合成层 Compositing Layers)按照正确的顺序(考虑 z-index、重叠等)和位置组合(拼合)起来,最终形成用户在屏幕上看到的完整一帧画面。

你可以把合成想象成:

  • 舞台表演: 将页面想象成一个舞台。每个图层就像一张透明的醋酸纤维片(Celluloid Sheet),上面画着舞台的一部分(比如背景、一个动画角色、一个静态按钮)。合成就是把这些透明的“片”按照导演(渲染树)的指示,从上到下(或考虑深度)叠加在一起,最终形成观众(用户)看到的完整场景画面。
  • Photoshop 图层: 类似于在 Photoshop 中,你有多个图层(背景层、文字层、图片层)。最终显示的图片是所有可见图层叠加合成后的结果。浏览器合成就是做类似的事情。

2. 合成是如何工作的?(关键概念)

  • 分层(Layering): 浏览器并不是把整个页面当作一个巨大的位图来处理。它会根据 CSS 属性(如 transform, opacity, will-change, position: fixed 等)和内容(如 <video>, <canvas>)等因素,将页面的不同部分提升(Promote) 为独立的合成层(Compositing Layer) 。这些层被单独存储在 GPU 的内存中(作为纹理 Texture)。

  • 光栅化(Rasterization): 对于每个合成层,浏览器需要将其内容(HTML/CSS)转换成实际的像素点(位图)。这个过程可以在主线程进行,但现代浏览器更倾向于使用光栅线程(Raster Threads) (通常是合成线程的一部分)在 GPU 上进行快速光栅化,效率更高。

  • 合成线程(Compositor Thread): 浏览器有一个独立的合成线程(Compositor Thread) 。它的核心职责就是处理合成工作:

    1. 管理图层: 维护所有合成层的列表及其属性(位置、透明度、变换矩阵等)。
    2. 创建合成帧: 根据图层信息和它们的层次关系(层叠上下文),计算每个图层在最终屏幕上的确切像素位置。
    3. 发送指令给 GPU: 将计算好的图层信息和绘制指令(“在 (x, y) 位置绘制图层 A 的纹理,应用透明度 0.8,然后在其上绘制图层 B...”))提交给 GPU。
  • GPU 绘制: GPU 接收到合成线程的指令后,利用其强大的并行处理能力,非常高效地将所有图层的纹理绘制(合成)到屏幕的帧缓冲区(Frame Buffer)中,形成最终用户看到的画面。

3. 为什么合成性能高?(核心优势)

  • 避开主线程和回流/重绘: 这是合成的最大优势!当元素的改变只影响合成属性(主要是 transformopacity)时:

    • 这些属性的改变通常在 JavaScript 执行或样式计算阶段就处理完了。

    • 不需要触发布局(回流/Layout)或绘制(重绘/Paint)! 因为:

      • transform (如 translate, rotate, scale) 改变的是元素在合成层空间内的位置、旋转或缩放,它不影响元素自身及其周围元素在文档流中的几何位置(不会导致回流)。它改变的只是这个层在最终合成时如何被“摆放”。
      • opacity 改变的是整个图层的透明度,在合成阶段应用即可,不需要重新绘制图层内部内容(只要图层内容本身没变)。
    • 合成线程可以直接利用 GPU 中已有的图层纹理,只更新该图层的 transform 矩阵或 opacity 值,然后告诉 GPU 重新合成。这个过程完全绕过了耗时的回流和重绘步骤,并且发生在独立的合成线程和 GPU 上,不会阻塞主线程执行 JavaScript、处理事件或进行其他渲染工作。

  • GPU 加速: GPU 专为并行处理图形计算(如纹理映射、变换、混合/合成)而设计,执行合成操作的速度极快。

  • 增量更新: 如果页面的某个部分(一个合成层)发生了变化,浏览器通常只需要重新光栅化并更新那个特定的层,然后在合成时将其与其他未变化的层重新组合即可,无需重绘整个页面。

4. 哪些样式属性可以触发合成(避免重绘/回流)?

核心属性是:

  • transform 这是实现高性能动画的利器。常用值:

    • translate(x, y) / translate3d(x, y, z) (移动)
    • scale(x, y) (缩放)
    • rotate(angle) (旋转)
    • skew(x-angle, y-angle) (倾斜)
    • matrix() / matrix3d() (矩阵变换 - 最强大但也最复杂)
    • 关键: 使用 translate3d(0, 0, 0)translateZ(0) 有时可以“欺骗”浏览器提前将元素提升为合成层(利用 3D 变换特性),即使没有实际 3D 变换。
  • opacity 改变元素的透明度。

  • will-change 谨慎使用! 这是一个提示(Hint)属性,告诉浏览器你预期某个元素将要如何改变(如 will-change: transform, opacity;)。浏览器可以据此提前将该元素提升为独立的合成层,并分配 GPU 资源,为即将到来的变化(特别是 transform/opacity 变化)做准备,使后续的变化更加流畅。但滥用 will-change(如设置大量元素或设置不需要的属性)会导致不必要的内存开销(每个合成层都需要 GPU 内存)和层管理开销(“层爆炸” Layer Explosion),反而可能损害性能。只在需要时对真正会频繁动画的元素使用。

  • 其他可能触发分层的属性: filter (某些滤镜如 blur 在现代浏览器也可能高效合成), backdrop-filter, position: fixed / sticky (通常需要独立层处理滚动), <video>, <canvas>, WebGL, 有 3D 变换 (perspective, transform-style: preserve-3d) 的子孙元素等。但这些属性本身的变化不一定能像 transform/opacity 那样完全避免重绘。

5. 如何利用合成进行优化?(最佳实践)

  1. 动画首选 transformopacity 对于移动、缩放、旋转、淡入淡出效果,绝对优先使用 CSS transformopacity 属性,结合 CSS Transitions 或 CSS Animations / Web Animations API。这能确保动画运行在合成线程和 GPU 上,极其流畅。

    • 避免使用: top/left/bottom/right, margin/padding, width/height 等会触发回流的属性做动画。使用 transform: translate() 代替 top/left 移动元素。
  2. 谨慎使用 will-change

    • 只对你明确知道会频繁进行 transformopacity 动画的元素使用。
    • 在动画开始前稍早设置(例如在触发事件处理程序中),并在动画结束后移除(elem.style.willChange = 'auto' 或覆盖掉)以释放资源。
    • 避免应用于过多元素或大面积元素。
    • 指定具体的属性(如 will-change: transform, opacity;),避免使用 will-change: all;
  3. 注意层创建的成本: 虽然合成性能高,但创建和管理合成层本身(分配 GPU 内存、通信开销)也有成本。过多的、不必要的合成层(“层爆炸”)会消耗大量内存,尤其在低端设备上,反而可能导致卡顿甚至崩溃。使用浏览器开发者工具(如 Chrome DevTools 的 Layers 面板)检查页面分层情况,识别并优化过度分层。

  4. 注意 filterbackdrop-filter 虽然它们可能被合成,但一些复杂滤镜(特别是影响大面积区域的模糊)仍然可能很耗性能。测试其在实际设备上的表现。

总结:

  • 合成(Composite) 是将预先光栅化好的图层(合成层)拼合成最终屏幕画面的过程,发生在独立的合成线程GPU上。

  • 核心优势: 当改变仅限于 transformopacity 属性时,可以完全跳过回流(Layout)和重绘(Paint) 这两个最耗性能的步骤,实现极其流畅的动画和交互。

  • 优化关键:

    • 动画/高频交互使用 transform (尤其是 translate, scale, rotate) 和 opacity
    • 谨慎使用 will-change 提示浏览器提前优化。
    • 监控图层数量,避免“层爆炸”。
  • 工具: 利用 Chrome DevTools 的 Performance 面板分析渲染性能,Rendering 面板中的 Paint flashing 查看重绘区域,Layers 面板查看分层情况和内存占用。