重排-重绘-函数节流-动画掉帧

上次豹哥面试里面提到了重排(回流),重绘,函数节流,函数去抖,使用setInterval做动画的掉帧问题。今天找了写资料,总结一下。

前几天就想弄了,但是开始了啃犀牛书系列,但是这个问题估计再拖几天就忘记去查看了,索性今天事情不多,就整理一下。

重绘(repaint)—重排/回流(reflow)

这两个概念应该是放在一起的。在理解之前,应该先清除浏览器渲染页面的机制。在下面的参考链接已经介绍的很详细了,我就简单进行概括:

1. 解析html,生成dom
2. 解析css,生成cssdom
3. html+css整合形成渲染树
4. 生成布局(layout)
5. 绘制到屏幕(paint)

4+5=渲染。
里面有两个地方需要注意:

  1. 隐藏的元素(display:none),他们是不在渲染树上的
  2. visibility:hidden的元素是在渲染书上的,这个属性只是让元素不显示,但是它还是会占据空间,影响布局。

当dom元素的几何属性发生改变,浏览器需要重新计算元素的几何属性,然后重新构造渲染树,这就是重排的意思。

构建完新的渲染树,浏览器又将此时的layout绘制到屏幕,这就是重绘。如果更改了除了几何属性之外的其他属性,浏览器会再次进行绘制,这个也是重绘。
如改变字体颜色,背景色等。有此处可以看出来,重排不定重绘,重绘未必重排。更正:重排必定重绘,重绘未必重排

当重排影响到了与它相关的元素,导致整片区域发生了重排,这是回流。(个人理解,感觉这样解释更容易明白)。
更正:重排,回流其本质是一个意思,只是在浏览器的内核中,叫法存在差异,具体请自行查看~~(ps:这样才能记住)
这里的几何属性主要有以下几种:

1. 添加元素或删除(removeChild,display:none)
2. 元素位置改变
3. 元素尺寸改变(盒模型)
4. 内容改变所引起的宽高改变
5. 页面初始化
6. 浏览器窗口变化,引起resize

举个栗子:

回流展示

假设D元素是固定宽高,C元素也是,而B与A元素是由D元素撑起的高度。如果我们此时将D元素的高缩减50%,很明显,此时D元素发生了重排,重绘。
因为B元素的高度是有D元素撑起的,D元素高度减少,对应的B元素也随之减少,此时A元素也因此改变了高度,都发生了重排,重绘。
这样的一个过程,称其为回流。(个人觉得这样的理解很通俗易懂,移动端这样的布局可能居多,我们一般不指定实际高度,而是靠子元素来撑起高度)

阮一峰的博客里面解释的很简明扼要。

网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。当发生修改dom,修改样式表,用户事件(比如鼠标悬停、页面滚动、输入框键入文字、改变窗口大小等等),会导致页面重新渲染,即重新生成布局和重新绘制,前者叫重排,后者叫重绘

重绘,重排这些是不可避免的,因为你总要去渲染页面。但是大量的重绘,重排将非常的消耗资源,这也是前端性能优化之中的需要考虑的地方。解决方案肯定是尽可能少的去触发重绘与重排。(这里就不举例子了,网上一搜一大把)
解决方案:(ps:阮总貌似总结的很详细,我就借一下~~)

1. 如果dom有多个读写操作,那么尽量将相同的放在一起执行,避免穿插执行。
2. 获取计算属性,最好缓存使用,避免下次使用重新获取,再次触发
3. 改变样式属性,通过classname或者csstext,尽量一次性改变
4. 使用离线dom而非真实dom,Document Fragment,或克隆对象,进行操作,然后进行替换
5. 如果有频繁大量的操作,先将元素隐藏,操作完成后,再展示
6. 设置绝对定位或静止定位,或通过css3 transfrom,opacity,等进行动画或移动
7. 使用虚拟dom
8. 函数节流,函数去抖

函数节流—函数去抖

函数节流(throttle)

场景如下,如果用源生方法实现元素的拖拽功能,我们会监听mousemove时间,如果我们针对每一个像素都做处理,那鼠标滑动的很快的时候,就会触发很多次回调函数,每次回调函数的执行,都是一次重排,重绘,这样势必影响性能,
我们要做的就是限制触发回调的频率,所以,函数节流的本质就是不让函数太过频繁的执行,减少一些过快的调用来节流。
以上面场景为例,我们用setInterval,设置一个合理的间隔来执行回调。It’s time to show code!!!(没代码,说个毛线!!!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var flag = true;
document.addEventListener('mouseover',function(){
if(!flag){
//如果上一次动作正在执行,这一次就不执行
return
}
//正在执行
flag = false;
setTimeout(function(){
//执行逻辑

//执行结束
flag = true
},100)
})

函数去抖(debounce)

场景如下,滚动加载,我们会监听元素的scroll事件,总不能每滚动一下就触发回调,这样又是N次的回调,如果在这儿使用节流,似乎也并不合适,节流是在间隔的时间去触发,并不合理,我们要做的就是让它只触发一次。
本质,对一定时间段内的连续调用的函数,只让其执行一次。It’s time to show code!!!(没代码,说个毛线!!!)

1
2
3
4
5
6
7
8
9
10
11
12
var timer = null;
document.addEventListener('scroll',function(){
if(timer){
//不管上一次执行是否结束,都清除,直接执行下一次
clearTimeout(timer)
}

timer = setTimeout(function(){
//执行逻辑

},100)
});

这样解释起来肯定会感觉比较绕口,或者有点理解理解不清的意思,但是相信看过简单的代码,应该就比较容易区分了。

动画掉帧

这个所谓的bug,里面实际上涉及的知识点还真不少,一点一点来。

setTimeout VS setInterval

用法类似:

1
2
setTimeout(function,millisecond)
setInterval(function,millisecond)

setTimeout 只执行一次,setInterval执行多次,setTimeout我们一般用递归的方法来实现setInterval.每当间隔时间达到,我们就会去执行回调函数,但是,这里并没有说里面的回调函数一定可以执行完成。

第一个参数,一个回调函数,第二个参数,间隔时间,单位毫秒(一般情况下是两个参数,但是也允许传入第三个参数,可传入回调函数中使用)。在js权威指南中,有这样的解释,第一个参数也可以作为字符串传入,如果是字符串传入,会进行eval()进行求值。
但是在nodejs的api里面,如果第一个参数不是function的话,会抛出一个TypeError.

我们经常会看到如下的写法:

1
setTimeout(function(){},0);

从代码上来看,意思应该是立即执行,但实际上并不是这样的,实际上它是将其放入事件队列中,等其他的执行完成后,再”立即”执行它。由此,看上面就知道,每一个回调函数都被放入队列中,等待上一个队列事件完成,它再执行,
这本身就已经损失了精度,更不可想象我执行N次之后的后果是什么了。

在nodejs的api中,有这样一段描述:

The callback will likely not be invoked in precisely delay milliseconds. Node.js makes no guarantees about the exact timing of when callbacks will fire, nor of their ordering. The callback will be called as close as possible to the time specified.

Note: When delay is larger than 2147483647 or less than 1, the delay will be set to 1.

如果看不懂,也可以有道或者google。第一句,它大致意思是:这个延迟时间并不是一个精确的延迟时间,只是尽可能的接近这个延迟时间。第二句,应该都可以的吧?

掉帧的原因

说到掉帧,先解释什么是帧。帧这里指的是帧率,也就是设备刷新率。现在大部分移动设备的帧率基本上都是在60fps(frame per second)。关于这些名词解析,就借助大漠的w3cplus来说明。(在漠叔面前,我等只可望其项背)

  • 帧:在动画过程中,每一幅静止画面即为一“帧”。
  • 帧率:即每秒钟播放的静止画面的数量,单位是fps(Frame per second)。
  • 帧时长:即每一幅静止画面的停留时间,单位一般是ms(毫秒)。
  • 跳帧(掉帧/丢帧):在帧率固定的动画中,某一帧的时长远高于平均帧时长,导致其后续数帧被挤压而丢失的现象。

现在我们一般都是以60fps为一个标准来进行的,如果能达到这个,基本上就算是完美(应该说是感觉动画很流程)。所以对于我们来说,我们就是要处理好每一帧,对于我们的动画而言,在每一帧里面我们可能要去触发重绘或重排等。
简单计算一下,60fps即每秒60帧,对应60个静止画面。也就是我们需要在1000/60ms,即16.67ms里面完成一次回调。这里就要求我们对于这个把控要严格一点了。到此,我们应该基本知道为什么setTimeout,setInterval。对于我们当前的要求来说,
这两个精度方面的欠缺估计是达不到我们的预期,同时,如果我们在这16.67ms里面并没有完成我们的静止画面绘制,那就影响到了下一个绘制,以此类推,我们可能就出现多帧或者丢帧。

requestAnimationFrame

接下来正主来了,window.requestAnimationFrame,简单来说,这个是浏览器用于定时循环操作的一个接口,类似于setTimeout,主要用途是按帧对网页进行重绘。很切题,主要用途~~参考这个
设置这个API的目的是为了让各种网页动画效果(DOM动画、Canvas动画、SVG动画、WebGL动画)能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。代码中使用这个API,就是告诉浏览器希望执行一个动画,让浏览器在下一个动画帧安排一次网页重绘。

requestAnimationFrame的优势,在于充分利用显示器的刷新机制,比较节省系统资源。显示器有固定的刷新频率(60Hz或75Hz),也就是说,每秒最多只能重绘60次或75次,requestAnimationFrame的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了CPU、GPU和电力。(11点多写到现在,眼睛有点酸了,请允许我复制一下)
这里面提到了电力,是因为在笔记本不充电的情况下使用电池,浏览器在该模式下为了节省电量,正常情况下,chrome等最小为的间隔为4ms,此时会将更新频率与系统时间保持一致,也就是更慢了。
使用这个api就是为了保证我们可以稍微稳定的执行一秒60次的操作,以此来避免掉帧情况。

但是这样并不尽然,假设你在该时间间隔内的操作是大量的,或者比较复杂的情况,你本身的操作要超过16.67,那你的动画可能还要掉帧,或者不顺畅,所以才处理动画的时候,还是尽可能的遵循下面的原则:

* 避免频繁的重排。
* 避免大面积的重绘。
* 优化JS运行性能。

requestIdleCallback

在看阮总的那篇文章的时候他提到了这个,于是又去查了一下这个api到底是做什么的。

MDN上是这样解释的:

window.requestIdleCallback() 会在浏览器空闲时期依次调用函数, 这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这样延迟触发而且关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。
具体用法请移步MDN,因为没有用过,这里并不赘述。这个方法也正好对应了大漠里面关于–保证帧率平稳(避免跳帧)的解决方案:

  • 不在连续的动画过程中做高耗时的操作(如大面积重绘、重排、复杂JS执行),避免发生跳帧。
  • 若高耗时操作无法避免,则尝试化解,比如:将高耗时操作放在动画开始或结尾处。将高耗时操作分摊至动画的每一帧中处理。

至此,关于setTimeout,setInterval的动画掉帧问题应该解释的比较清楚了,如果你还有不清楚的,请移步参考链接进行查阅。

从看文章到写博客,花了将近三个小时,但是这三个小时感觉很受益,本文可能略长,在看的时候希望可以一块一块的看,这样对于理解会很有用,文中并没有对调试工具的使用,和如何观察重排,重绘以及渲染时间的使用,如想了解,请自行查阅。

如果你发现文中有错或者不足,请在下方评论区执政批评,欢迎拍砖~~文中有复制现象,但是我觉得别人的解释很到位,如果我再转译一次,很可能信息失真,所以不怕拍砖,但本文也基本上算是98%的纯手打。

如果你觉得本文对你有用,那证明我这三个小时就更加值得了,如果喜欢,欢迎转载~~~

参考链接