为了使每一帧页面渲染的开销都能在期望的时间范围内完成。就需要开发者了解渲染过程的每个阶段,以及各阶段中有哪些优化空间是我们力所能及的。经过分析根据开发者对优化渲染过程的控制力度,可以大体将其划分为5各部分:js处理、计算样式、页面布局、绘制与合成。这个过程中的每一个阶段都有可能产生卡顿。注意:并非对于每一帧画面都会经历这5个部分,比如仅修改与绘制相关的属性(文字颜色,背景图片或边缘阴影等),而未对页面布局产生任何修改,那么在计算样式阶段完成后,便会跳过页面布局直接执行绘制。如果所更改的属性既不影响页面布局又不需要重新绘制,便可直接跳到合成阶段执行。具体修改哪些属性会触发页面布局、绘制或合成阶段的执行,这与浏览器的内核存在一定关系。
属性 | Blink | Gecko | Webkit |
---|---|---|---|
z-index | 绘制/合成 | 绘制/合成 | 布局/绘制/合成 |
transform | 合成 | 合成 | 布局/绘制/合成 |
opacity | 绘制/合成 | 合成 | 布局/绘制/合成 |
min-width | 布局/绘制/合成 | 布局/合成 | 布局/绘制/合成 |
color | 布局/绘制 | 布局/绘制 | 布局/绘制/合成 |
background | 布局/绘制 | 布局/绘制 | 布局/绘制/合成 |
border-radius | 布局/绘制 | 布局/绘制 | 布局/绘制/合成 |
border-style | 布局/绘制/合成 | 布局/绘制/合成 | 布局/绘制/合成 |
border-width | 布局/绘制/合成 | 布局/绘制/合成 | 布局/绘制/合成 |
Google的chrome实验室在网站上列出了许多css属性的详细表现,可以自行查看。
推荐使用requestAnimationFrame方法来实现动画效果,requestAnimationFrame方法的执行时机会与系统的刷新频率同步。这样就能保证回调函数在屏幕的每次刷新间隔中只被执行一次,从而避免因随机丢帧而造成的卡顿现象。
其使用方法也十分简单,仅接受一个回调函数作为入参,即下次重绘之前更新动画帧所调用的函数。返回值为一个long类型整数,作为回调任务队列中的唯一标识,可将该值传给window.cancelAnimationFrame来取消回调,以某个目标元素的平移动画为例:
let start;
//定义目标动画元素
const element = document.getElementById("MyAnimate");
element.style.position = 'absolute'
//定义动画回调函数
function updateScreen(timestamp){
if(!start) start = timestamp;
//根据时间戳计算每次动画位移
const progress = timestamp-start;
element.style.left = "12px"
if(progress<2000) window.requestAnimationFrame(updateScreen)
}
//启动动画回调函数
window.requestAnimationFrame(updateScreen)
除了通过让回调函数的触发时机与系统刷新频率同步来消除动画的丢帧卡顿,requestAnimationFrame方法还能通过节流不必要的函数执行,来帮助cpu的节能。
具体而言,对于cpu节能方面,考虑当浏览器页面最小化或被隐藏起来时,动画对用户来说是不可见的,那么刷新动画所带来的页面渲染就是对cpu资源的浪费,完全没有意义。
当创建setInterval定时器后,除非显式调用clearInterval去销毁该定时器,不然在后台的动画任务会不断执行,而requestAnimationFrame方法则完全不同,当页面未被激活时,屏幕刷新任务会被系统暂停,只有当页面被激活时,动画任务才会被激活并从上次暂停的地方继续执行,所以能有效地节省cpu开销。
在页面地一些高频事件中,比如页面滚动的scroll、页面尺寸更改的resize,需要防止在一个刷新时间间隔内发生多次函数执行,也就是所谓的函数节流。对60hz的显示器来说,差不多每16.7ms刷新一次,多次绘制并不会在屏幕上体现出来,所以requestAnimationFrame方法仅在每次刷新周期中执行一次函数调用,既能保证动画的流畅性又能很好地节省函数执行地冗余开销。
js是单线程执行的,所有任务放在一个线程上执行,只有当前一个任务执行完才能处理后一个任务,不然后面的任务只能等待,这就限制了多核计算机充分发挥它的计算能力。同时在浏览器上,js的执行通常位于主线程,这恰好与样式计算、页面布局以及绘制一起,如果js运行时间过长,必然就会导致其他工作任务的阻塞而造成丢帧。
为此可将一些纯计算的工作迁移到web worker上处理,它为js的执行提供了多线程环境,主线程通过创建出worker子线程,可以分担一部分自己的任务执行压力。在worker子线程上执行的任务不会干扰主线程,待其上的任务执行完成后,会把结果返回给主线程,这样的好处是让主线程可以更专注地处理ui交互,保证页面的使用体验流程。需要注意的是,worker子线程一旦创建成功就会始终执行,不会被主线程上的事件所打断,这就意味着worker会比较耗费资源,所以不应当过度使用,一旦任务执行完毕就应及时关闭。除此之外,在使用中还有以下几点应当注意。
web worker的使用方法非常简单,在主线程中通过new Worker()方法来创建一个worker子线程,构造函数的入参是子线程执行的脚本路径,由于代码文件必须来自网络,所以如果代码文件没能下载成功,worker就会失败。
//创建子线程
const worker = new Worker("demo_worker.js");
//主线程向子线程发生消息
const dataToWorker = {
//...
}
worker.postMessage(dataToWorker);
//接下来主线程就可以继续其他构造,只需通过监听子线程返回的消息再进行相应处理
worker.addEventListener('message',(event)=>{
const workedData = event.data;
//将数据更新到屏幕上
})
在子线程处理完相关任务后,需要及时关闭worker子线程以节省系统资源,关闭的方式有两种:在主线程中通过调用worker.terminate方法来关闭;在子线程中通过调用自身全局对象中的self.close方法来关闭。
考虑到上述关于web worker使用中的限制,并非所有任务都适合采用这种方式来提升性能。如果所要处理的任务必须要放在主线程上完成,则应当考虑将一个大型任务拆分为多个微任务,每个微任务处理的耗时最好在几毫秒之内。能在每帧的requestAnimationFrame更新方法中处理完成。
//将一个大型任务拆分为多个微任务
const taskList = splitTask(BigTask);
//微任务处理逻辑,入参为每次任务起始时间戳
function processTaskList(taskStartTime){
let taskStartTime;
do{
//从任务堆栈中推出要处理的下一个任务
const nextTask = taskList.pop();
//处理下一个任务
processTask(nextTask);
//获取任务执行完成的时间,如果时间够3毫秒就继续执行
taskFinishTime = window.performance.now();
}while(taskFinishTime-taskStartTime < 3);
//如果任务堆栈不为空则继续
if(taskList.length>0){
requestAnimationFrame(processTaskList)
}
}
requestAnimationFrame(processTaskList)
所谓事件节流,简单来说就是在某段时间内,无论触发多少次回调,在计结束后都只响应第一次的触发。以scroll事件为例,当用户滚动页面触发了一次scroll事件后,就为这个触发操作开启一个固定时间的计时器。在这个计时器呈持续时间内,限制后续发生的所有scroll事件对回调函数的触发,当计时器计时结束后,响应执行第一次触发scroll事件的回调函数。
function throttle(time,callback){
//上次触发回调的时间
let last = 0;
//事件节流操作的闭包返回
return(params)=>{
let now = Number(new Date())
if(now-last >=time){
//超出节流时间间隔,触发响应回调函数
last = now;
callback(params);
}
}
}
const throttle_scroll = throttle(1000,()=>console.log('页面滚动'))
document.addEventListener('scroll',throttle_scroll )
事件防抖的实现与事件节流类似,只是所响应的触发事件是最后一次事件。具体来说,首先设定一个事件防抖的时间间隔,当时间触发开始后启动计时器,若在定时器结束计时之前又有相同的事件被触发,则更新计时器但不响应回调函数的执行,只有当计时器完整计时结束后,才去响应执行最后一次事件触发的回调函数。
function throttle(time,callback){
//上次触发回调的时间
let last = 0,timer = null;
//事件节流操作的闭包返回
return(params)=>{
let now = Number(new Date())
if(now-last >=time){
//超出节流时间间隔,触发响应回调函数
last = now;
callback(params);
}else{
//重新设置防抖定时器
clearTimeout(timer);
timer = setTimeout(()=>{
last = now;
callback(params)
},time)
}
}
}
const throttle_scroll = throttle(1000,()=>console.log('页面滚动'))
document.addEventListener('scroll',throttle_scroll )
在js处理过后,若发生了添加和删除元素,对样式属性和类进行了修改,就都会导致浏览器重新计算所涉及到的元素的样式,某些修改还可能会引起页面布局的更改和浏览器的重新绘制。
.product-list li{}
如上面例子的css引擎需要首先遍历页面上的所有li标签元素,然后确认每个li标签有包含类名未product-list的父元素才是目标元素。所以为了提高页面的渲染性能,计算样式阶段应当尽量减少参与样式计算的元素数量。页面布局也叫做重排和回流,指的是浏览器对页面元素的集合属性进行计算并将最终结果绘制出来的过程。凡是元素的宽高尺寸、在页面中的位置以及隐藏或显示等信息发生改变时,都会触发页面的重新布局。
通过页面布局的作用范围会涉及整个文档,所以这个环节会带来大量的性能开销,开始时应当从代码层面出发,尽量避免页面布局或最小化其处理次数。如果仅修改了dom元素的样式,而未影响其几何属性,则浏览器会跳过页面布局的计算环节,直接进入重绘阶段。
虽然重绘的性能开销不及页面布局高,但为了更高的性能体验,也应当降低重绘发生的频率个复杂度。
触发页面布局与重绘的操作
要想避免或减少页面布局与重绘的发生,首先就是需要知道有哪些操作能够触发浏览器的页面布局与重绘的操作,然后在开发过程中尽量去避免。
这些操作大致可以分为三类:首先就是对DOM 元素几何属性的修改,这些属性包括width、height、padding、marginleft、top等,某元素的这些属性发生变化时,便会波及与它相关的所有节点元素进行几何属性的重新计算,这会带来巨大的计算量;其次是更改DOM 树的结构,浏览器进行页面布局时的计算顺序,可类比树的前序遍历,即从上向下、从左向右。
这里对DOM 树节点的增、删、移动等操作,只会影响当前节点后的所有节点元素,而不会再次影响前面已经遍历过的元素;最后一类是获取某些特定的属性值操作,比如页面可见区域宽高offsetWidth、offsetHeight, 页面视窗中元素与视窗边界的距离offsetTop、 offsetLeft,类似的属性值还有ScrollTop、 scrollLeft, scrollWidth, scrolHeight,clientWidth、clientHeight 及调用 window.getComputedStyle方法。clientTop
这些属性和方法有一个共性,就是需要通过即时计算得到,所以浏览器就需要重新进行页面布局计算。
避免对样式的频繁改动
在通常情况下,页面的一帧内容被渲染到屏幕上会按照如下顺序依次进行,首先执行JavaScript代码,然后依次是样式计算、页面布局、绘制与合成。如果在JavaScript运行阶段设计上述三类操作,浏览器就会强制提前页面布局的执行,为了尽量降低页面布局计算带来的性能损耗,应当避免使用js对样式进行频繁修改。
使用类名对样式逐条修改
在JevaScript代码中逐行执行对元素样式的修改,是一种糟糕的编码方式,对未形成编码规范的前端初学者来说经常会出现这类的问题。错误代码示范如下:
//获取dom元素逐行修改样式
const div =document.getElementByid('mydiv');div.style.height ='100px'
div.style.width ='100px'
div.style.border-t2px solid blue'
上述代码对样式逐行修改,每行都会触发一次对渲染树的更改,于是会导致页面布局重新计算而带来巨大的性能开销。合理的做法是,将多行的样式修改合并到一个类名中,仅在JavaScript 脚本中添加或更改类名即可。CSS类名可预先定义:
.my-div {height:100px;width:100px;border:2px solid blue;}
然后统一在JavaScript 中通过给指定元素添加类的方式一次完成,这样便可避免触发多次对页面布局的重新计算:
const div =document.getElementById ('mydiv');
mydiv.classList.add ('my-div');
缓存对敏感属性值的计算
有些场景我们想要通过多次计算来获得某个元素在页面中的布局位置,比如:
const list = document .getElementByid('list) ;
for (let i =0;i<10;i++){
list.style.top='$(list.offsetrop+10}px';}
这不但在赋值环节会触发页面布局的重新计算,而且取值涉及即时敏感属性的获取,如offsetTop和offsetLeft,也会触发页面布局的重新计算。这样的性能是非常糟糕的,作为优化我们可以将敏感属性通过变量的形式缓存起来,等计算完成后再统一进行赋值触发布局重排。
const list = document .getElementByid('list) ;
//将敏感属性缓存起来
let offsetTop = list.offsetTop;
for (let i =0;i<10;i++){
offsetTop +=10;
}
//计算完成后统一赋值触发重排
list.style.top=offsetTop
//在帧开始时触发回调
requestAnimationFrame(test())
function test(){
const div = document.getElementById("div");
console.log(div.offsetHeight);
}
如果在请求此元素高度之前更改其样式,浏览器就无法直接使用上一帧的旧有属性值,而需要先应用更改的样式,再运行页面布局计算后才能返回所需的正确高度值。这样的多余开销显然是没有必要的。因此考虑到性能因素,在requestAnimationFrame方法的回调函数中,应始终优先样式的读取,然后再执行相应的写操作:
function test(){
const div = document.getElementById("div");
console.log(div.offsetHeight);
div.classList.add("my-div")
}
合成处理是将已绘制的不同图层放在一起,最终在屏幕上渲染出来的过程。在这个环节中,有两个因素可能会影响页面性能:一个是所需合成的图层数量,一个是实现动画的相关属性。
.new-layer{
will-change:transform;
}
will-change在chrome、火狐以及opera上均有效,而对于safari等不支持的浏览器,可以是3d变化来强制创建:
.new-layer{
transform:translate(0);
}
在使用这两个属性实现相应的动画效果时,需要注意动画元素应当位于独立的会图层上,以避免影响其他绘制区域。这就需要将动画元素提升至一个新的会图层。
最后,多使用chrome的开发者工具对渲染优化进行评估。