本文将主要关注浏览器获取到资源后,进行渲染部分的相关优化内容。
页面渲染阶段对性能体验的应i昂与资源加载阶段同样重要,而对于设计高交互频次的应用来说可能更加重要。
本节将整个渲染过程划分为五个串行阶段进行概述。优化渲染的实质,就是尽量压缩每个阶段的执行时间或跳过某些阶段的执行。
7.1.1 流畅的使用体验
要达到怎样的性能指标,才能满足用户流畅的使用体验呢?
目前大部分折别的屏幕分辨率都在60fps左右,也就是每秒屏幕会刷新60次,所以要满足用户的体验期望,就需要浏览器在渲染页面动画或响应用户操作时,每一帧的生成速率尽量接近屏幕的刷新率。若按60fps算,则留给每一帧画面的时间不到17ms,再除去浏览器对资源的一些整理工作,一帧画面的渲染应尽量在10ms内完成,如果达不到要求而导致帧率下降, 则屏幕上的内容会发生抖动或卡顿。
7.1.2 渲染过程
渲染过程可以大体将其划分为五个部分:
7.2.1 实现动画效果
实践经验告诉我们,使用定时器实现的动画会在一些低端机器上出现抖动或者卡顿的现象,这主要是因为浏览器无法确定定时器的回调函数的执行时机。其次屏幕分辨率和尺寸也会影响刷新频率,不同设备的屏幕绘制频率可能会有所不同,而setInterval只能设置某个固定的时间间隔,这个间隔时间不一定与所有屏幕的刷新时间同步,那么导致动画出现随即丢帧也在所难免。
为了避免这种动画实现方案中因丢帧而造成的卡顿现象,推荐使用window中的requestAnimationFrame方法。与setInterval方法相比,其最大的优势是将回调函数的执行时机交由系统决定,如果屏幕刷新频率是60Hz,则它的回调函数大约回每16.7ms执行一次,如果屏幕的刷新频率是75Hz,则它回调函数大约会每13.3ms执行一次,就是说requestAnimationFrame方法的执行时机会与系统的刷新频率同步。
在页面的一些高频事件中,比如页面滚动的scroll、页面尺寸更改的resize,需要防止在一个刷新时间间隔内发生多次函数执行,也就是所谓的函数节流。对60Hz的显示器来说,差不多每16.7ms刷新一次,多次绘制并不会在屏幕上体现出来,所以requestAnimationFrame方法仅在每个刷新周期中执行一次函数调用,既能保证动画的流畅性又能很好地节省函数执行的冗余开销。
7.2.2 恰当使用Web Worker
总所周知JavaScript是单线程执行的,所有任务放在一个线程杀死那个执行,只有当前一个任务执行完才能处理后一个任务,限制了多和计算机充分发挥它的计算能力。同时在浏览器上,JavaScript的执行通常位于主线程,这恰好与样式计算、页面布局及绘制一起,如果JavaScript运行时间过长,必然就会导致其他工作任务的阻塞而造成丢帧。
为此可将一些纯计算的工作迁移到Web Worker上处理,它为JavaScript的执行提供了多线程环境,主线程通过创建出Worker子线程,可以分担一部分子级的任务执行压力。在Worker子线程上执行的任务不会干扰主线程,待其上的任务执行完成后,会把结果返回给主线程,这样的好处是让主线程可以更专注地处理UI交互,保证页面的使用体验流程。需要注意的是Worker子线程一旦创建成功就会始终执行,不会被主线程上的事件所打断,这就意味着Worker会比较耗费资源,所以不应当过度使用。
7.2.3 事件节流和事件防抖
所谓事件节流,简单来说就是在某段时间内,无论出发多少次回调,在计时结束后都只响应第一次的触发。
例子:
// 事件节流
function throttle(time, callback) {
// 上次触发回调的时间
let last = 0
// 事件节流操作的闭包返回
return (params) => {
// 记录本次回调触发的时间
let now = Number(new Date())
// 判断事件触发时间是否超出节流时间间隔
if (now - last >= time) {
// 如果超出节流时间间隔,则触发响应回调函数
callback(params);
}
}
}
// 通过事件节流优化的事件回调函数
const throttle_scroll = throttle(1000, () => console.log('页面滚动'));
// 绑定事件
document.addEventListener('scorll', throttle_scroll);
事件防抖策略是当事件被触发时,设定一个周期延迟执行动作,若期间又被触发,则重新设定周期,直到周期结束,执行动作。 这是debounce的基本思想,在后期又扩展了前缘debounce,即执行动作在前,然后设定周期,周期内有事件被触发,不执行动作,且周期重新设定。
例子:
// 事件防抖
function debounce(time, callback) {
// 设置定时器
let timer = null
// 事件防抖的闭包返回
return (params) => {
// 每当事件被触发时,清除旧定时器
if (timer) clearTimeout(timer);
// 设置新的定时器
timer = setTimeout(() => callback(params), time);
}
}
// 通过事件防抖优化事件回调函数
const debounce_scroll = debounce(1000, () => console.log('页面滚动'));
// 绑定事件
document.addEventListener('scorll', debounce_scroll);
7.2.4 恰当的JavaScript优化
通过优化JavaScript能够带来的性能优化,除上述几点之外,通常是有限的。所以对于渲染层面的JavaScript优化,我们首先应当定位出导致性能问题的瓶颈点,然后有针对性地去优化具体的执行函数,而避免投入产出比过低的微优化。
具体定位工具可以使用Chrome浏览器开发者工具中的Performance。
在JavaScript处理过后,若发生了添加和删除元素,对样式属性和类进行了修改,就都会导致浏览器重新计算所涉及元素的样式,某些修改还可能会引起页面布局的更改和浏览器的重新绘制。
7.3.1 减少要计算样式的元素数量
首先我们需要知道与计算样式相关的一条重要机制:CSS引擎在查找样式表时,对每条规则的匹配顺序是从右向左的,这与我们通常从左向右的书写习惯相反。
如:
.product-list li {}
如果不知道样式规则查找顺序,则推测这个选择器规则应该不会太费力,首先类选择器.product-list的数量有限应该很快就能查找到,然后缩小范围在查找其下的li标签就顺理成章。
但CSS选择器的匹配规则实际上是从右向左的,这样再回看上面的规则匹配,其实开销相当高,因为CSS引擎需要首先遍历页面上的所有li标签元素,然后确认每个li标签有包含名为product-list的父元素才是目标元素,所以为了提高页面的渲染性能,计算样式阶段应当尽量减少参与样式计算的元素数量。
实战建议:
7.3.2 降低选择器的复杂性
.container:nth-last-child(-n+1) .content{
...
}
浏览器在计算上述样式时,首先就需要查询有哪些应用了content类的元素,并且欺负元素恰好带有container类的倒数第n+1个元素,这个计算过程可能就会花费许多时间, 如果仅对确定的元素使用单一的类名选择器,那么浏览器的计算开销就会大幅降低。如id选择器。
7.3.3 使用BEM规范
BEM是一种书写规范,其含义为块(Block)、元素(Element)、和修饰符(Modifier)。理论上它希望每行CSS代码只有一个选择器,对选择器的命名要求通过以下三个符号的组合来实现。
页面布局也叫做重排和回流,指的是浏览器对页面元素的集合属性进行计算并将最终结果绘制出来的过程。凡是元素的宽高尺寸、在页面中的位置及隐藏或显示等信息发生改变时,都会触发页面的重新布局。
通常页面布局的作用范围会涉及整个文档,所以这个环节会带来大量的性能开销,我们在开发过程中,应当从代码层面出发,尽量避免页面布局或最小化其处理次数。如果仅修改了DOM元素的样式,则浏览器会跳过页面布局的计算环节,直接进入重绘阶段。
虽然重绘的性能开销不及页面布局高,但为了更高的性能体验,也应当降低重绘发生的频率和复杂度。
7.4.1 出发页面布局与重绘的操作
能触发浏览器的页面布局与重绘的操作大致可以分为三类:
这些属性和方法有一个共性,就是需要通过即时计算得到,所以浏览器就需要重新进行页面布局计算。
7.4.2 避免对样式的频繁改动
通常情况下,页面的一帧内容被渲染到屏幕上会按照如下顺序依次进行,首先执行JavaScript代码,然后依次是样式计算、页面布局、绘制与合成。运行阶段设计上述三类操作,浏览器就会强制提前页面布局的执行,为了尽量降低页面布局计算带来的性能损耗,我们应当避免使用JavaScript对样式进行频繁的修改。修改样式可参考以下几种方式。
1. 使用类名对样式逐条修改
在JavaScript代码中逐行执行对元素样式的修改,每行都会触发一次对渲染树的更改,于是会导致页面布局重新计算而带来巨大的性能开销。合理做法是,将多行的样式修改合并到一个类名中,仅在JavaScript脚本中添加或更改类名即可。
2. 缓存对敏感属性值的计算
有些场景我们想要通过多次计算来获得某个元素在页面中的布局位置,如:
const list = document.getElementById('list');
for (let i = 0;i < 10;i ++){
list.style.top = '${list.offsetTop + 10}px';
list.style.left= '${list.offsetLeft + 10}px';
}
这不但在赋值环节会触发页面布局的重新计算,而且取值涉及即时敏感属性得获取,也会出发页面布局的重新计算。这样的性能是非常糟糕的,作为优化我们可以将敏感属性通过变量的形式缓存起来,等计算完成后再统一进行赋值触发布局重排。
const list = document.getElementById('list');
//将敏感属性缓存起来
let offsetTop = list.offsetTop, offsetLeft = list.offsetLeft;
for (let i = 0;i < 10;i++){
offsetTop += 10;
offsetLeft += 10;
}
//计算完成后统一赋值触发重排
list.style.left = offsetLeft;
list.style.top = offsetTop;
3. 使用requestAnimationFrame方法控制渲染帧
requestAnimationFrame方法可以控制回调在两个渲染帧之间仅触发一次,如果在其回调函数中一开始就取值到即时敏感属性,其实获取的是上一帧旧布局的值,并不会触发页面布局的重新计算。
//在帧开始时触发回调
requestAnimationFrame(queryDivHeight);
function queryDivHeight(){
const div = document.getElementById('div');
//获取并在命令行中打印出指定div元素的高
console.log(div.offsetHeight);
}
如果在请求此元素高度之前更改其样式,浏览器就无法直接使用上一帧的旧有属性值,而需要先应用更改的样式,再运行页面布局计算后,才能返回所需的正确高度值。这样多余的开销显然时没有必要的。因此考虑到性能因素,在requestAnimationFrame方法的回调函数中,应始终优先样式的读取,然后再执行相应的写操作。
//requestAnimationFrame方法的回调函数
function queryDivHeight(){
const div = document.getElementById('div');
//获取并在命令行中打印出指定div元素的高
console.log(div.offsetHeight);
//样式的写操作应放在读取操作后进行
div.classList.add('my-div');
}
7.4.3 通过工具对绘制进行评估
chrome浏览器开发者工具有可以辅助我们发现渲染阶段可能存在的性能问题。
7.4.4 降低绘制复杂度
使用渲染性能分析工具发现了用明显性能瓶颈需要优化时,需要确认是否存在高复杂度的绘制内容,可以使用其他实现方式来替换以降低绘制的复杂度。比如位图的阴影效果,可以考虑使用ps等图像处理工具直接为图片本身添加阴影效果,而非全交给CSS样式去处理。
除此之外,还要注意对绘制区域的控制,对不需要重新绘制的区域应尽量避免重绘。例如,页面顶部有一个固定区域的header标头,若它与页面其他位置的某个区域位于同一图层,当后者发生重绘时,就有可能触发包括固定标头区域在内的整个页面的重绘。对于固定不变不期望发生重绘的区域,建议可将其提升为独立的绘图层,避免被其他区域的重绘连带着触发重绘。
合成处理是将已绘制的不同图层放在一起,最终在屏幕上渲染出来的过程。在这个环节中,有两个因素可能会影响页面性能:一个是所需合成的图层数量,另一个是实现动画的相关属性。
7.5.1 新增图层
以降低绘制复杂度小节中讲到,可通过将固定区域和动画区域拆分到不同图层上进行绘制,来达到绘制区域最小化的目的。接下来我们就来探讨如何创建新的图层,最佳方式便是使用CSS属性will-change来创建:
.new-layer{
will-change: transform;
}
该方法在Chrome、FIrefox和Opera上均有效,而对于Safari等不支持will-change属性的浏览器,则可以用3D变换来强制创建:
.new-layer{
transform: translate(0);
}
虽然创建新的图层能够在一定程度上减少绘制区域,但也应当注意不能创建太多的图层,因为每个图层都需要浏览器为其分配内存及管理开销。如果已经将一个元素提升到所创建的新图层上,也最好使用Chrome开发者工具中的Layers对图层详情进行评估,确定是否真的带来了性能提升,切记在未经分析评估前就盲目地进行图层创建。
7.5.2 仅与合成相关地动画属性
在了解了渲染过程各部分的功能和作用后,我们知道如果一个动画的实现不经过页面布局和重绘环节,仅在合成处理阶段就能完成,则将会节省大量的性能开销。目前能够符合这一要求的动画属性只有两个:透明度opacity和图层变换transform。
在使用opacity和transform实现相应的动画效果时,需要注意动画元素应当位于独立的绘图层上,以避免影响其他绘制区域。这就需要将动画元素提升至一个新的绘图层。
五个阶段:JavaScript执行、样式计算、页面布局、绘制和合成。
随着前端技术的迭代、业务复杂度的加深,我们所要面对的性能问题是很难罗列穷尽的。面对更复杂的性能问题场景时,我们应该学会熟练使用浏览器的开发者工具,去分析出可能存在的性能瓶颈并定位到问题元素的位置,然后制定出合理的优化方案进行性能改进。应当做到所有性能优化都要量化可控,避免盲目地为了优化而优化,否则很容易画蛇添足。
前端性能优化系列:
前端性能优化:1.什么是前端性能优化
前端性能优化:2.前端页面的生命周期
前端性能优化:3.图像资源优化
前端性能优化:4.资源加载优化
前端性能优化:5.高性能的JavaScript代码
前端性能优化:6.项目构建优化
前端性能优化:7.页面渲染优化