运行时性能优化 13 条规则
1. 减少重绘重排
浏览器渲染过程
- 解析HTML生成DOM树。
- 解析CSS生成CSSOM规则树。
- 将DOM树与CSSOM规则树合并在一起生成渲染树。
- 遍历渲染树开始布局,计算每个节点的位置大小信息。
- 调用 GPU 绘制,合成图层。
- 将渲染树每个节点绘制到屏幕。
重排
当改变 DOM 元素位置或大小时,会导致浏览器重新生成渲染树,这个过程叫重排。
重绘
当重新生成渲染树后,就要将渲染树每个节点绘制到屏幕,这个过程叫重绘。不是所有的动作都会导致重排,例如改变字体颜色,只会导致重绘。记住,重排会导致重绘,重绘不会导致重排 。
重排和重绘这两个操作都是非常昂贵的,因为 JavaScript 引擎线程与 GUI 渲染线程是互斥,它们同时只能一个在工作。
什么操作会导致重排?
- 添加或删除可见的 DOM 元素
- 元素位置改变
- 元素尺寸改变
- 内容改变
- 浏览器窗口尺寸改变
如何减少重排重绘?
- 用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式。
- 如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)或文档碎片(DocumentFragement),都能很好的实现这个方案。
2. 使用事件委托
事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。所有用到按钮的事件(多数鼠标事件和键盘事件)都适合采用事件委托技术, 使用事件委托可以节省内存。
<ul>
<li>苹果</li>
<li>香蕉</li>
<li>凤梨</li>
</ul>
// good
document.querySelector('ul').onclick = (event) => {
const target = event.target
if (target.nodeName === 'LI') {
console.log(target.innerHTML)
}
}
// bad
document.querySelectorAll('li').forEach((e) => {
e.onclick = function() {
console.log(this.innerHTML)
}
})
3. 注意程序的局部性
一个编写良好的计算机程序常常具有良好的局部性,它们倾向于引用最近引用过的数据项附近的数据项,或者最近引用过的数据项本身,这种倾向性,被称为局部性原理。有良好局部性的程序比局部性差的程序运行得更快。
局部性通常有两种不同的形式:
- 时间局部性:在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来被多次引用。
- 空间局部性 :在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。
时间局部性示例
function sum(arry) {
let i, sum = 0
let len = arry.length
for (i = 0; i < len; i++) {
sum += arry[i]
}
return sum
}
在这个例子中,变量 sum 在每次循环迭代中被引用一次。因此,对于 sum 来说,具有良好的时间局部性。
空间局部性示例
具有良好空间局部性的程序
// 二维数组
function sum1(arry, rows, cols) {
let i, j, sum = 0
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
sum += arry[i][j]
}
}
return sum
}
空间局部性差的程序
// 二维数组
function sum2(arry, rows, cols) {
let i, j, sum = 0
for (j = 0; j < cols; j++) {
for (i = 0; i < rows; i++) {
sum += arry[i][j]
}
}
return sum
}
看一下上面的两个空间局部性示例,像示例中从每行开始按顺序访问数组每个元素的方式,称为具有步长为1的引用模式。 如果在数组中,每隔k个元素进行访问,就称为步长为k的引用模式。 一般而言,随着步长的增加,空间局部性下降。
这两个例子有什么区别?区别在于第一个示例是按行扫描数组,每扫描完一行再去扫下一行;第二个示例是按列来扫描数组,扫完一行中的一个元素,马上就去扫下一行中的同一列元素。
数组在内存中是按照行顺序来存放的,结果就是逐行扫描数组的示例得到了步长为 1 引用模式,具有良好的空间局部性;而另一个示例步长为 rows,空间局部性极差。
性能测试
运行环境:
- cpu: i5-7400
- 浏览器: chrome 70.0.3538.110
对一个长度为9000的二维数组(子数组长度也为9000)进行10次空间局部性测试,时间(毫秒)取平均值,结果如下:
所用示例为上述两个空间局部性示例
步长为 1 | 步长为 9000 |
---|---|
124 | 2316 |
从以上测试结果来看,步长为 1 的数组执行时间比步长为 9000 的数组快了一个数量级。
总结:
- 重复引用相同变量的程序具有良好的时间局部性
- 对于具有步长为 k 的引用模式的程序,步长越小,空间局部性越好;而在内存中以大步长跳来跳去的程序空间局部性会很差
4. if-else 对比 switch
当判断条件数量越来越多时,越倾向于使用 switch 而不是 if-else。
if (color == 'blue') {
} else if (color == 'yellow') {
} else if (color == 'white') {
} else if (color == 'black') {
} else if (color == 'green') {
} else if (color == 'orange') {
} else if (color == 'pink') {
}
switch (color) {
case 'blue':
break
case 'yellow':
break
case 'white':
break
case 'black':
break
case 'green':
break
case 'orange':
break
case 'pink':
break
}
像以上这种情况,使用 switch 是最好的。假设 color 的值为 pink,则 if-else 语句要进行 7 次判断,switch 只需要进行一次判断。 从可读性来说,switch 语句也更好。
从使用时机来说,当条件值大于两个的时候,使用 switch 更好。不过 if-else 也有 switch 无法做到的事情,例如有多个判断条件的情况下,无法使用 switch。
5. 查找表
当条件语句特别多时,使用 switch 和 if-else 不是最佳的选择,这时不妨试一下查找表。查找表可以使用数组和对象来构建。
switch (index) {
case '0':
return result0
case '1':
return result1
case '2':
return result2
case '3':
return result3
case '4':
return result4
case '5':
return result5
case '6':
return result6
case '7':
return result7
case '8':
return result8
case '9':
return result9
case '10':
return result10
case '11':
return result11
}
可以将这个 switch 语句转换为查找表
const results = [result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10,result11]
return results[index]
如果条件语句不是数值而是字符串,可以用对象来建立查找表
const map = {
red: result0,
green: result1,
}
return map[color]
6. 避免页面卡顿
60fps 与设备刷新率
目前大多数设备的屏幕刷新率为 60 次/秒。因此,如果在页面中有一个动画或渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。 其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。 此现象通常称为卡顿,会对用户体验产生负面影响。
假如你用 JavaScript 修改了 DOM,并触发样式修改,经历重排重绘最后画到屏幕上。如果这其中任意一项的执行时间过长,都会导致渲染这一帧的时间过长,平均帧率就会下降。假设这一帧花了 50 ms,那么此时的帧率为 1s / 50ms = 20fps,页面看起来就像卡顿了一样。
对于一些长时间运行的 JavaScript,我们可以使用定时器进行切分,延迟执行。
for (let i = 0, len = arry.length; i < len; i++) {
process(arry[i])
}
假设上面的循环结构由于 process() 复杂度过高或数组元素太多,甚至两者都有,可以尝试一下切分。
const todo = arry.concat()
setTimeout(function() {
process(todo.shift())
if (todo.length) {
setTimeout(arguments.callee, 25)
} else {
callback(arry)
}
}, 25)
如果有兴趣了解更多,可以查看一下高性能JavaScript第 6 章和高效前端:Web高效编程与优化实践第 3 章。
7. 使用 requestAnimationFrame 来实现视觉变化
从第 16 点我们可以知道,大多数设备屏幕刷新率为 60 次/秒,也就是说每一帧的平均时间为 16.66 毫秒。在使用 JavaScript 实现动画效果的时候,最好的情况就是每次代码都是在帧的开头开始执行。而保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame
。
/**
* If run as a requestAnimationFrame callback, this
* will be run at the start of the frame.
*/
function updateScreen(time) {
// Make visual updates here.
}
requestAnimationFrame(updateScreen);
如果采取 setTimeout
或 setInterval
来实现动画的话,回调函数将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。
8. 使用 Web Workers
Web Worker 使用其他工作线程从而独立于主线程之外,它可以执行任务而不干扰用户界面。一个 worker 可以将消息发送到创建它的 JavaScript 代码, 通过将消息发送到该代码指定的事件处理程序(反之亦然)。
Web Worker 适用于那些处理纯数据,或者与浏览器 UI 无关的长时间运行脚本。
创建一个新的 worker 很简单,指定一个脚本的 URI 来执行 worker 线程(main.js):
var myWorker = new Worker('worker.js');
// 你可以通过postMessage() 方法和onmessage事件向worker发送消息。
first.onchange = function() {
myWorker.postMessage([first.value,second.value]);
console.log('Message posted to worker');
}
second.onchange = function() {
myWorker.postMessage([first.value,second.value]);
console.log('Message posted to worker');
}
在 worker 中接收到消息后,我们可以写一个事件处理函数代码作为响应(worker.js):
onmessage = function(e) {
console.log('Message received from main script');
var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
console.log('Posting message back to main script');
postMessage(workerResult);
}
onmessage 处理函数在接收到消息后马上执行,代码中消息本身作为事件的 data 属性进行使用。这里我们简单的对这 2 个数字作乘法处理并再次使用 postMessage()
方法,将结果回传给主线程。
回到主线程,我们再次使用 onmessage 以响应 worker 回传的消息:
myWorker.onmessage = function(e) {
result.textContent = e.data;
console.log('Message received from worker');
}
在这里我们获取消息事件的 data,并且将它设置为 result 的 textContent,所以用户可以直接看到运算的结果。
不过在 worker 内,不能直接操作 DOM 节点,也不能使用 window 对象的默认方法和属性。然而你可以使用大量window 对象之下的东西,包括 WebSockets,IndexedDB 以及 FireFox OS 专用的 Data Store API 等数据存储机制。
9. 使用位操作
JavaScript 中的数字都使用 IEEE-754 标准以 64 位格式存储。但是在位操作中,数字被转换为有符号的 32 位格式。即使需要转换,位操作也比其他数学运算和布尔操作快得多。
取模
由于偶数的最低位为 0,奇数为 1,所以取模运算可以用位操作来代替。
if (value % 2) {
// 奇数
} else {
// 偶数
}
// 位操作
if (value & 1) {
// 奇数
} else {
// 偶数
}
取整
~~10.12 // 10
~~10 // 10
~~'1.5' // 1
~~undefined // 0
~~null // 0
位掩码
const a = 1
const b = 2
const c = 4
const options = a | b | c
通过定义这些选项,可以用按位与操作来判断 a/b/c 是否在 options 中。
// 选项 b 是否在选项中
if (b & options) {
...
}
10. 不要覆盖原生方法
无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用 C/C++ 写的,并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。
11. 降低 CSS 选择器的复杂性
(1). 浏览器读取选择器,遵循的原则是从选择器的右边到左边读取。
看个示例
#block .text p {
color: red;
}
- 查找所有 P 元素。
- 查找结果 1 中的元素是否有类名为 text 的父元素
- 查找结果 2 中的元素是否有 id 为 block 的父元素
(2). CSS 选择器优先级
内联 > ID选择器 > 类选择器 > 标签选择器
根据以上两个信息可以得出结论。
- 选择器越短越好。
- 尽量使用高优先级的选择器,例如 ID 和类选择器。
- 避免使用通配符 *。
最后要说一句,据我查找的资料所得,CSS 选择器优化不是必需的,因为最慢和慢快的选择器性能差别非常小。
12. 使用 flexbox 而不是较早的布局模型
在早期的 CSS 布局方式中我们能对元素实行绝对定位、相对定位或浮动定位。而现在,我们有了新的布局方式 flexbox,它比起早期的布局方式来说有个优势,那就是性能比较好。
下面的截图显示了在 1300 个框上使用浮动的布局开销:
然后我们用 flexbox 来重现这个例子:
现在,对于相同数量的元素和相同的视觉外观,布局的时间要少得多(本例中为分别 3.5 毫秒和 14 毫秒)。
13. 使用 transform 和 opacity 属性更改来实现动画
在 CSS 中,transforms 和 opacity 这两个属性更改不会触发重排与重绘,它们是可以由合成器(composite)单独处理的属性。