深刻浅出动画帧

在前端性能优化策略中,耳熟能详的手段有,雅虎 35 条军规,使用 cache,减小请求数量,使用cookie-free domaincritical asset,使用 CDN,Lazy load,PreLoad 等,这些手段其实主要都是围绕怎么样更快的拿到所需关键资源。当咱们把这一步作到很好,没有可优化空间了,其实还能够从另一个方向去作优化,那就是浏览器渲染方面。浏览器渲染优化是一个很大的主题,今天,咱们只谈它一个小角度,动画帧。从动画帧,咱们能够怎么样来作一些优化工做呢?本文篇幅较长,图较多,耐心看完,必定会有不少收获的。javascript

基础概念

先仍是来熟悉基础概念。帧,能够理解为浏览器每一次绘制的快照。1s 内绘制的次数,叫作帧率,也就是咱们常说的 fps(frame per second)。帧率越大,浏览器在 1s 内绘制的次数就越多,动画就越流畅。人们视觉系统对帧率的最低要求通常是 24fps,当帧率低于 24 时,就会感受到明显的卡顿了。不一样的移动设备,有不一样的帧率,通常默认是 60fps。css

咱们要追求的理想帧率是 60fps,那么一帧绘制的时间最可能是 1 / 60 = 16.7ms,一旦超过这个数,就达不到 60fps 了。为了使得一帧花费的时间控制在 16.7ms 内,咱们必须先搞清楚浏览器在一帧会作些什么事情呢?html

帧任务

html,js,css 是浏览器处理最为常见的三种资源,也是咱们前端工程师天天都会打交道的文件。但是咱们真正知道浏览器是怎么绘制的吗?稍微思考一下,而后会说,html 解析为 dom tree,css 解析为 cssom tree,而后 dom tree 加上 cssom tree 就合并为 render tree,最后浏览器根据 render tree 绘制到屏幕上。这是咱们最为熟知的步骤,可是它只是描述了一个总体大体的过程,并无具体到一帧的绘制过程。下面看看一帧具体绘制的过程图。前端

上图就是浏览器在绘制一帧时,会通过的处理步骤。java

  • JavaScript,咱们会经过 js 来改动一些视觉效果,好比基于 js 的动画,或者响应用户事件等。
  • Style,若是经过 js 改变了某一个 dom 的样式,就会从新计算受影响的元素的样式。
  • Layout,若是样式改变中涉及了布局属性,例如 top,left,width 等几何位置,还会从新计算它的布局位置,以及受影响的其余元素的布局。固然,若是不涉及布局样式,也不会执行这一步。
  • Paint,计算出 Style 和 Layout 后,就能够把元素绘制到它所属的 paint layer 上。
  • Composite,最后会将多个 composite layer 输出到屏幕上显示出来。

要控制一帧的执行时间在 16.7ms 内,就只须要把上述 5 个步骤处理时间总和控制在 16.7ms 内。接下来,咱们一个步骤一个步骤来看,有哪些优化建议。node

JavaScript

requestAnimationFrame

JavaScript 是前端工程师的利器,有了它,咱们能够实现很是复杂的系统,或者很是流畅的游戏等。JavaScript 一般会处理用户输入或者点击等事件,而后作一系列的视觉效果的改变。当涉及 dom 元素改变时,老是把操做放在 requestAnimationFramecss3

requestAnimationFrame会有什么神奇之处呢?web

  • 它老是会确保 fn 的执行在浏览器一帧的开头
  • 浏览器会自动调节帧率,间接调节了 fn 的执行频率

在不支持 raf 的浏览器中,一般使用setTimeout(fn, 16.7)来实现。可是setTimeout有一些很差的地方,chrome

  • 它并非保证 fn 每隔 16.7ms 就执行一次,它只能每 16.7ms 将 fn 加入到MacroTask Queue中,具体何时执行,要根据当前执行队列决定
  • 它不能保证每次执行时机都是一帧的开头,可能某一次执行触发是在帧的中间或者结尾,致使延长当前帧在 16.7ms 内没法执行完成,就会出现丢失当前帧
  • 不一样设备帧率不同,并非固定的 60fps,给低帧率(好比 30fps)的设备上执行setTimeout(fn, 16.7)将会致使执行不少无心义的 fn

咱们先使用setTimeout来实现,每 16.7ms 就更新一次小球的位置,这样尽可能保证了 1s 内更新 60 次。typescript

// 使用setTimeout来实现小球下落的动画
function animtionWithSetTimeout() {
  setTimeout(() => {
    updateBoll()
    animtionWithSetTimeout()
  }, 16.7)
}
复制代码

而后,咱们经过 Chrome 的 Performance 来分析当前帧率状况以下,

帧率上会有一些锯齿,表示有部分状况是低于 60fps 的。而且在上图中,某一帧中执行了两次updateBoll,第二次的updateBoll是在帧快结束时触发的,因此拉长了当前帧执行的时间。

而后,咱们使用requestAnimationFrame来实现它。

// 使用requestAnimationFrame来实现小球下落
function animationWithRaf() {
  updateBoll()
  requestAnimationFrame(animationWithRaf)
}
复制代码

一样,使用 Chrome 的 Performance 来分析当前帧率状况以下,

帧率明显稳定在 60fps 左右,没有上面出现的锯齿了,确保了每一帧中都只会触发一次updateBoll

分块执行

JavaScript 的执行是在一帧的开头,JavaScript 执行的时间越长,那么当前帧花费的时间就越长。当浏览器要开始更新屏幕了,若是当前帧还未完成,那么当前帧就会被丢弃,延迟到一下次更新,表现出来的就是丢帧。咱们应该尽量缩短 JavaScript 的执行时间,一般不要超过 10ms,由于后面的 Style,Layout,Paint,Composite 至少要花费 6ms 的时间。

当有一个大任务,须要长时间的执行时,咱们能够把它分散到每一帧中去完成其中一小部分。这样,能够缩短在每帧中执行的时间。咱们来看一个例子,好比要在一帧里绘制 100 个小球,假设须要花费 3s 的时间。

function createBoll() {
  sleep(30) // 模拟30ms操做
  const boll = document.createElement("div")
  boll.classList.add("boll")
  boll.style.background = randomColor()
  document.body.append(boll)
}

const COUNT = 100

/* 直接执行大任务 */
function longTask() {
  requestAnimationFrame(() => {
    for (i = 0; i < COUNT; i++) {
      createBoll()
    }
  })
}
复制代码

能够明显的看到,这一帧里花费了 3022.7ms,期间页面一直是上一帧的内容(空白)。只有等到任务完成了,才会所有显示小球出来。下面,咱们把这个长任务分散到每一帧里执行,每一帧只建立一个小球。

/* 分块执行小任务 */
function chunkTask() {
  let i = 0
  requestAnimationFrame(function doOne() {
    // 每一帧,只建立一个小球
    createBoll()
    i++
    if (i < COUNT) {
      requestAnimationFrame(doOne)
    }
  })
}
复制代码

如今每一帧都只花费了 30ms 左右,而且页面不会空等 3s 才显示小球,而是小球逐个绘制出来了,体验明显好多了。

其余最佳实践

对于 JavaScript 步骤,还有其余一些很好的建议,下面简单列举出来,就不作 demo 了,

  • 将一些纯计算型,不涉及 dom 访问的任务,放到Web Workers
  • 对于可使用 css3 来实现的动画,就不须要用 JavaScript 来实现了
  • 对于用户事件的响应,同步执行时间不要太长,逻辑尽可能简单,复杂的逻辑能够延迟处理或分块处理

Style & Layout

先读取,后设置

使用 JavaScript 来绘制动画时,咱们每每须要根据当前 dom 状态(好比位置,大小)来更新它的状态。一般的最佳实践是,先读取,后设置。在 raf 中,页面 dom 元素样式都已经在上一帧中计算好了,在读取某一个 dom 样式时,不会触发浏览器从新计算样式和布局。可是,若是咱们是先设置,而后在读取,那么这个 dom 样式已经被改变了,为了保证获取到正确的样式值,浏览器必须从新对当前 dom 进行样式计算,若是还涉及布局改变,也会进行从新布局,这种状况就是咱们一般说的触发了 reflow

下面咱们来验证一下,在 raf 中,每次都是先设置了小球的 top,而后再读取它的 offsetTop 和屏幕的 offsetHeight。

function reflow() {
  requestAnimationFrame(() => {
    boll.style.top = boll.offsetTop + 2 + "px"

    if (boll.offsetTop < document.documentElement.offsetHeight) {
      reflow()
    }
  })
}
复制代码

经过分析,能够看到在 JavaScript 中就触发了 Recalculation Forced 和 Layout Forced。如今咱们优化一下,先读取它的 offsetTop 和屏幕的 offsetHeight 值,而后在去设置它的 top

function optimizeReflow() {
  requestAnimationFrame(() => {
    const top = boll.offsetTop
    const bodyHeight = document.documentElement.offsetHeight
    if (top >= bodyHeight) {
      return
    }

    boll.style.top = top + 2 + "px"
    optimizeReflow()
  })
}
复制代码

经过分析能够看到,在 JavaScript 步骤没有出现上面的 Recalculation Forced 和 Layout Forced。

减小 layout

若是咱们在没有更改 dom 的几何属性(width,height,top,left,bottom,right 等),就不会触发 layout 步骤。当一个 dom 的 layout 被改变时,一般都会影响到其余关联的 dom 的 layout 也被改变。在设置动画时,能避免直接改变 dom 的 layout,就应该尽力避免。好比咱们可使用 transform 来位移元素,而没必要直接改变它的 layout。仍是上面的例子,一样的动画,咱们改动 transform 来实现。

function transform() {
  let distance = document.documentElement.offsetHeight - boll.offsetTop
  let dropDistance = 0

  requestAnimationFrame(function drop() {
    if (dropDistance > distance) {
      return
    }

    boll.style.transform = "translateY(" + dropDistance + "px)"
    dropDistance = dropDistance + 2
    requestAnimationFrame(drop)
  })
}
复制代码

Paint

减小绘制区域

一般,对某一个 dom 的改变,都会触发该 dom 所在层的整个从新绘制。为了不对整个层的从新绘制,咱们能够经过把该 dom 提高到一个新的 composite layer,减小对原始层的绘制。

未提高前,每一帧里都会有对整个 document 的绘制,对整个 document 的绘制会消耗 48us 左右

优化以后,咱们经过will-change: top把小球提高到一个新的 composite layer 上,没有了对整个 document 的绘制,节省了 48us。

使用 transform 和 opacity

即便在同一层上,咱们对某一个 dom 使用 transform 或者 opacity 来实现动画,也不会每帧都触发对整个 layer 的绘制,它只会对当前改变的 dom 的绘制和适当减小对整个 layer 的绘制次数。这个特性实际上是浏览器的自身优化策略,transform 和 opacity 被视为合成器属性,它们在默认状况下都会生成新的 paint layer。相比上面的优化方式,使用合成器属性,实现了一样的优化效果,可是能够减小增长 composite layer,每建立一个 composite layer 也是会有性能开销的。

咱们没必要直接改变 dom 的 top 或者 width 等几何属性,而是使用 transform 的 translate,scale 等来实现一样的效果,既能够减小绘制区域,也能够避免 layout。

减小绘制的复杂度

不一样的绘制效果,消耗的时间也是不一样的。一般越复杂的效果,消耗的资源和花费的时间越多,好比阴影,渐变,圆角等,就属于比较复杂的样式。这些样式效果,一般都是设计师设计出来的,咱们没有理由直接否认。可是,咱们能够根据不一样设备来作一些不一样的降级处理,好比较低设备上,就只用纯色来代替渐变,去掉阴影效果等。经过降级处理,保证了页面功能不受影响,也提升了页面的性能。

Composite

处理完了上面这些步骤,终于来到了 Composite 步骤。dom 经过适当条件触发,会提高为新的 composite layer,在 paint 阶段是发生在多个 layer 上的,最后浏览器经过合并多个 layer,根据它们的顺序来正确的显示在屏幕上。composite layer 提高触发条件很是多,这里我只列举几个常见的条件:

  1. 3D 相关的 css 样式
  2. will-change 设置为 opacity,transform,top,left,bottom,right
  3. fixed(在高的 DPI 上会默认提高,在低 DPI 上不会)
  4. Hardware-accelerated video element
  5. Hardware-accelerated 2D canvas
  6. 3D WebGL
  7. Overlay(好比 A 覆盖在 B 上,而 B 是提高的 composite layer,则 A 也会提高到新的 composite layer)

composite layer 有本身的 graphic context,因此在渲染的时候,速度很是快,它是直接在 GPU 上完成的,不须要经过 CPU 处理。可是每新增一个 composite layer 都会消耗额外的内存,也不能盲目的将元素提高为新的 composite layer。

除了有 Composite layer,还有一种 paint layer。paint layer 是在stacking context上的,例如咱们在使用z-index时就会生成新的 paint layer,在帧处理步骤中,其实还有一步 Update Layer Tree 就是用来更新 paint player 的。

每一个 dom node 都会有对应的 layout object。若是 layout object 在同一个坐标系空间中,就会在同一个 paint layer 上。在某些条件下,好比 z-index,absolute 等会生成新的 paint layer。默认状况下,paint layer 都共享同一个 composite layer,可是在某些条件下,好比 animation,will-change 等会把当前 paint layer 提高为新的 composite layer,从而加速渲染。

如今,一帧的任务都处理完了,是否是就结束了呢?

requestIdleCallback

若是一帧的处理时间少于 16.7ms,多余出来的时间,浏览器会执行requestIdleCallback的任务。这个 API 目前还处在不稳定阶段,某些浏览器还未实现它。咱们在使用的时候,必定要加上兼容性检测。

if ("requestIdleCallback" in window) {
  // Use requestIdleCallback to schedule work.
} else {
  // Do what you’d do today.
}
复制代码

必定不能在 requestIdleCallback 里更改 dom 样式

rIC 阶段,frame 的样式布局等都己经 commit 了,因此不能在 rIC 里直接改变 dom 的布局或者样式。若是改变了,会致使样式布局计算失效,在下一帧就会触发 forced layout 等。例以下面这个例子,咱们直接在 rIC 里改了小球的位置。

if ("requestIdleCallback" in window) {
  requestIdleCallback(() => {
    const left = boll.offsetLeft
    // 这里,咱们直接改了boll的位置
    boll.style.left = left + 2 + "px"
    moveLeftWithBadRic()
  })
}
复制代码

而后,打开 chrome 调试一下,就会发现问题,在下一帧的开头就会触发了 layout,这是咱们不但愿的。

更好的方式就是,咱们能够在 rIC 里计算好样式,而后在 rAF 里去更新样式。

if ("requestIdleCallback" in window) {
  requestIdleCallback(() => {
    const left = boll.offsetLeft + 2
    const top = boll.offsetTop + 2
    // 在rAF里更新位置
    requestAnimationFrame(() => {
      boll.style.left = left + "px"
      boll.style.top = top + "px"
    })
    moveLeftWithGoodRic()
  })
}
复制代码

优化以后,咱们再调试一下,发现,帧开头没有了 layout,这是咱们想要的结果。

其余最佳实践

  • rIC 里,可用时间是很是有限的,不能一次执行长时间任务。可根据参数 deadline.timeRemaing()来判断当前可用时间,若是时间到了,必需要结束,或者放在下一个 rIC 里执行

    function myNonEssentialWork(deadline) {
      // Use any remaining time, or, if timed out, just run through the tasks.
      while (
        (deadline.timeRemaining() > 0 || deadline.didTimeout) &&
        tasks.length > 0
      )
        doWorkIfNeeded()
    
      if (tasks.length > 0) requestIdleCallback(myNonEssentialWork)
    }
    复制代码
  • rIC 不能保证必定会执行。因此通常放在 rIC 里的任务是无关核心逻辑或用户体验的,通常好比数据上报或者预处理数据。可用传入 timeout 参数,保证任务必定会执行。

    // Wait at most two seconds before processing events.
    requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 })
    复制代码

小结

经过完整的学习一帧的绘制过程,而后针对每一个过程,咱们都采起一些优化手段,那么整个动画都将表现的很是流畅。最好,用一张图来做为结尾吧。

参考

相关文章
相关标签/搜索