在前端性能优化策略中,耳熟能详的手段有,雅虎 35 条军规,使用 cache,减小请求数量,使用cookie-free domain,critical 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
要控制一帧的执行时间在 16.7ms 内,就只须要把上述 5 个步骤处理时间总和控制在 16.7ms 内。接下来,咱们一个步骤一个步骤来看,有哪些优化建议。node
JavaScript 是前端工程师的利器,有了它,咱们能够实现很是复杂的系统,或者很是流畅的游戏等。JavaScript 一般会处理用户输入或者点击等事件,而后作一系列的视觉效果的改变。当涉及 dom 元素改变时,老是把操做放在 requestAnimationFrame
中。css3
requestAnimationFrame
会有什么神奇之处呢?web
在不支持 raf 的浏览器中,一般使用setTimeout(fn, 16.7)
来实现。可是setTimeout
有一些很差的地方,chrome
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 了,
使用 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。
若是咱们在没有更改 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)
})
}
复制代码
一般,对某一个 dom 的改变,都会触发该 dom 所在层的整个从新绘制。为了不对整个层的从新绘制,咱们能够经过把该 dom 提高到一个新的 composite layer,减小对原始层的绘制。
未提高前,每一帧里都会有对整个 document 的绘制,对整个 document 的绘制会消耗 48us 左右
优化以后,咱们经过will-change: top
把小球提高到一个新的 composite layer 上,没有了对整个 document 的绘制,节省了 48us。
即便在同一层上,咱们对某一个 dom 使用 transform 或者 opacity 来实现动画,也不会每帧都触发对整个 layer 的绘制,它只会对当前改变的 dom 的绘制和适当减小对整个 layer 的绘制次数。这个特性实际上是浏览器的自身优化策略,transform 和 opacity 被视为合成器属性,它们在默认状况下都会生成新的 paint layer。相比上面的优化方式,使用合成器属性,实现了一样的优化效果,可是能够减小增长 composite layer,每建立一个 composite layer 也是会有性能开销的。
咱们没必要直接改变 dom 的 top 或者 width 等几何属性,而是使用 transform 的 translate,scale 等来实现一样的效果,既能够减小绘制区域,也能够避免 layout。
不一样的绘制效果,消耗的时间也是不一样的。一般越复杂的效果,消耗的资源和花费的时间越多,好比阴影,渐变,圆角等,就属于比较复杂的样式。这些样式效果,一般都是设计师设计出来的,咱们没有理由直接否认。可是,咱们能够根据不一样设备来作一些不一样的降级处理,好比较低设备上,就只用纯色来代替渐变,去掉阴影效果等。经过降级处理,保证了页面功能不受影响,也提升了页面的性能。
处理完了上面这些步骤,终于来到了 Composite 步骤。dom 经过适当条件触发,会提高为新的 composite layer,在 paint 阶段是发生在多个 layer 上的,最后浏览器经过合并多个 layer,根据它们的顺序来正确的显示在屏幕上。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,从而加速渲染。
如今,一帧的任务都处理完了,是否是就结束了呢?
若是一帧的处理时间少于 16.7ms,多余出来的时间,浏览器会执行requestIdleCallback
的任务。这个 API 目前还处在不稳定阶段,某些浏览器还未实现它。咱们在使用的时候,必定要加上兼容性检测。
if ("requestIdleCallback" in window) {
// Use requestIdleCallback to schedule work.
} else {
// Do what you’d do today.
}
复制代码
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 })
复制代码
经过完整的学习一帧的绘制过程,而后针对每一个过程,咱们都采起一些优化手段,那么整个动画都将表现的很是流畅。最好,用一张图来做为结尾吧。