概述
性能优化时不可避免的
哪些内容可以看做是性能优化?
任何一种可以提升程序运行效率,降低程序开销的行为,我们都可以看做是一种优化操作。这就意味着在软件开发的过程中,必然存在着很多值得优化的地方。
无处不在的前端性能优化
特别是在前端开发过程中,性能优化时无处不在的,例如请求资源时的网络、数据的传输方式,开发过程中所使用的的框架等。
本篇的核心是JavaScript语言的优化,具体来说就是认知内存空间的使用,垃圾回收的方式介绍。从而可以让我们编写出高效的JavaScript代码。
内存管理
内存管理介绍
- 内存:由可读写单元组成,表示一片可操作性空间
- 管理:人为的去操作一片空间的申请、使用和释放
- 内存管理:开发者主动申请空间、使用空间、释放空间
- 管理流程:申请-使用-释放
JS中内存管理
- 申请内存
- 使用内存
- 释放内存,
和其他语言相通,JavaScript内存管理的流程也是申请内存空间-使用内存空间-释放内存空间。但是由于ECMAScript中并没有提供操作内存的相关API,所以JavaScript语言不能像C或者C++那样,由开发者主动去调用相应的API来完成内存管理。不过,我们仍然可以通过js脚本去演示当前空间的生命周期是怎样完成的。
// 申请空间
let obj = {}
// 使用空间
obj.name = 'jack'
// 释放空间
obj = null
JavaScript中的垃圾回收
- JavaScript中的内存管理时自动的
- 对象不再被引用时是垃圾
- 对象不能从根上访问到时是垃圾(比如说语法代码错误无法找到这个对象)
JavaScript中的可达对象
- 可以访问到的对象就是可达对象(引用、作用域链)
- 可达的标准就是从根触发是否能够被找到
- JavaScript中的根可以理解为全局变量
如果把obj中的o1和o2中prev对o1的引用delete掉,那么o1就会变成垃圾,因为没有办法通过某些方式找到o1了
GC算法介绍
GC定义与作用
- GC就是垃圾回收机制的简写(Garbage Collection)
- GC可以找到内存中的垃圾、并释放和回收空间
GC算法是什么
- GC是一种机制,垃圾回收器完成具体的工作
- 工作内容就是查找垃圾、释放空间、回收空间
- 算法就是工作时查找和回收所遵循的规则
常见的GC算法有以下几种:
- 引用计数
- 标记清除
- 标记整理
- 分代回收
引用计数算法实现原理
所谓的引用计数法就是给每个对象一个引用计数器,每当有一个地方引用它时,计数器就会加1;当引用失效时,计数器的值就会减1;任何时刻计数器的值为0的对象就是不可能再被使用的。
这个引用计数法时没有被Java所使用的,但是python有使用到它。而且最原始的引用计数法没有用到GC Roots。
核心思想:设置引用数,判断当前引用数是否为0
- 引用计数器
- 引用关系改变时修改引用数字
- 引用数字为0时立即回收
图解
优点:
- 发现垃圾立即回收,在该方法中,每个对象始终知道自己是否有被引用,当被引用的数值为0时,对象马上可以把自己当做空闲空间链接到空闲链表;
- 最大限度减少程序暂停(内存占满前会立即回收垃圾释放内存);
缺点:
- 无法回收循环引用的对象。
- 时间开销大(需要监控数值变化,而且还要去修改,想象一下很多对象需要修改的时候)。
无法回收循环引用的对象代码。循环引用浪费内存空间,无法被回收。
标记清除算法实现原理
该算法分为标记和清除两个阶段。标记就是把所有活动对象都做上标记的阶段(阶段1);清除就是将没有做上标记的对象进行回收的阶段(阶段2),因为原理更简单,解决问题多,V8中大量使用此算法。
- 核心思想:分标记和清除两个阶段完成
- 遍历所有对象找标记活动对象 stage1
- 遍历所有对象清除没有标记对象 stage2
- (l俩次遍历后)回收相应的空间
图
标记清除算法优缺点
优点:相对引用计数来说,解决了循环引用浪费内存的问题。
缺点: 空间碎片化(不能得到空间最大化的使用)。
空间碎片化图解
标记整理算法实现原理
和标记清除算法一样会在V8中大量使用。
- 标记整理可以看作是标记清除的增强
- 标记阶段的操作和标记清除一致
- 清除阶段会先执行整理,移动对象位置(使得地址连续)
获得的空间都是连续的
常见GC算法总结
引用计时器优缺点
- 可以立即回收垃圾对象
- 减少程序卡顿时间
- 无法回收循环引用对象
- 资源消耗较大(观察计数修改计数)
标记整理优缺点
- 可回收循环引用对象
- 不会立即回收垃圾对象(遍历过程中发现垃圾也不会立即清除,在阶段2才清除)
-容易产生碎片化空间,浪费空间
标记整理优缺点
- 减少空间碎片化
- 不会立即回收垃圾对象
认识V8
V8 是一款主流的 javascript 执行引擎
javascript 之所以能高效的运转,正是因为 V8 的存在
高效,是 V8 的一个最大卖点,速度之所以快,除了背后有一套优秀的管理机制之外,V8 还有一个特点:采用 即时编译
之前很多 javascript 引擎都需要将代码先转换成字节码,然后去执行,而 V8 就可以直接将源码翻译成可以执行的机器码,所以这时候速度是非常快的
还有一个特点:V8 内存设上限
64位 – 1.5G
32位 – 800M
为什么有这么一个操作?
第一:本身是为了浏览器去制造的,所以现有的内存大小对于网页应用来说已经足够使用
第二:V8 内部的垃圾回收机制也决定了它这样的一个设置是非常合理的
官方做过这样一个测试:
当垃圾内存达到 1.5G 的时候,采用增量标记算法进行垃圾回收,只需要消耗 50ms 而如果采用非增量标记去回收,需要 1S,从用户体验来说,1S已经算是很长的时间了,所以这里就以 1.5G 为界,对 V8 内部的内存进行一个上行的设置。
总结:
- V8 是一款主流的 javascript 执行引擎
- 采用即时编译
- V8 内存设上限
V8垃圾回收策略
在程序的运行中,会用到很多的数据,这些数据又可以分为原始数据和对象类型的数据,对于原始数据来说,都是由语言自身来控制的,所以这里所提到的回收主要还是指的是当前存活在我们堆区里的对象数据,因此,这个过程是离不开内存操作的。
而V8当中对内存是做了上限的,所以我们就要知道,在这样的一个情况下,它是怎样对垃圾进行回收的。
- 采用分代回收的思想
- 内存分为新生代、老生代
- 针对不同的对象采用不同的算法
V8如何回收新生代对象
- V8空间一分为二
- 小空间用于存新生代对象(64位中32M | 32位中16M)
- 新生代是存活较短的对象(例如局部变量,函数完了就回收了,但全局变量需要等整个程序退出才回收)
回收新生代对象实现原理
- 回收过程采用复制算法和标记整理
- 新生代内存一分为二from和to
- 使用空间为from,空闲空间为to
- 活动对象放入from中(form空间到达一定存储量后会标记整理拷贝到to,到to后释放form的空间,完成新生代回收操作。)
- 标记整理后讲活动对象拷贝到to
- from to 交换空间完成释放。
细节-----------------
- 拷贝时某个使用空间老年代也有 这个时候就会晋升。
- 一轮GC后还存活的
- To空间使用率超过25%的时候
V8如何回收老生代对象
- 老生代对象放在右侧的老生代区域
- 老生代指的是存活较长的对象
- 64位中1.4G,32位中700M
回收老生代对象实现
- 主要采用标记清除,标记整理,增量标记算法
- 1首先用标记清除回收空间
- 2如果新生代移动到老生代空间时老生代空间不够的情况会进行标记整理(使空间连续)
- 3采用增量标记进行效率优化
细节对比
新生代使用垃圾回收是空间换时间(空间小)
老生代不适合复制算法(如果频繁除法会有好几百M用不完,而且复制的时候耗时)
标记增量算法优化垃圾回收的理解
明确一点:当垃圾回收进行工作的时候,它会阻塞我们 javascript 程序的执行。
所谓的标记增量,简单来说就是将整个垃圾回收操作分成多个小步,组合着去完成当前的整个回收,从而去替代之前一口气做完的垃圾回收操作。
好处就是让程序执行和垃圾回收去交替着完成,而不像以前那样程序运行的时候不做垃圾回收,做垃圾回收的时候不能运行程序。这样带来的时间消耗更加合理一些。
虽然这样看来,可能让人觉得当前程序停顿了很多次,但是要明白,整个 V8 最大的垃圾回收当它达到 1.5G 的时候采用非增量标记的形式时间也不超过 1秒钟,所以这个间断分割是合理的。而且这样一来呢,就最大限度的把以前的很长一段的停顿时间直接拆分成更小段,这样对于用户来说,就会显得更加得好一些。
V8 垃圾回收总结
- V8 是一款主流得 javascript 执行引擎
- V8 内存设置上限(设太大可能会超过用户感知的时间)
- V8 采用基于分代回收思想实现垃圾回收
- V8 内存分为新生代和老生代
- V8 垃圾回收常见得 GC 算法(新生代 复制+标记整理)(老年-清除+整理+增量)
Performance 工具介绍
为什么使用 Performance 工具?
GC 的目的是为了实现内存空间的良性循环
良性循环的基石是合理使用
时刻关注才能确定是否合理
Performance 提供多种监控方式
通过使用 Performance 时刻监控内存
使用步骤:
打开浏览器输入目标网址
进入开发人员工具面板,选择 性能(performance)
开启录制功能,访问具体界面
执行用户行为,一段时间后停止录制
分析界面中记录的内存信息
内存问题的体现
这里看一下当我们的应用程序在执行的过程中,如果内存出现了问题,那么它具体在界面上如何展示
这里就可以更好的配合 performance 工具进行一个问题的定位,这里就依据一些性能的模型给定的一些判定的标准。
内存问题的外在表现:
页面出现延迟加载或经常性暂停(网络环境除外)
一般就判定内存有问题,而且与当前的 GC 存在频繁的垃圾回收是相关的(某些代码可能让内存爆掉了)
页面持续性出现糟糕的性能(网络环境除外)
一般会认为存在内存膨胀,所谓的内存膨胀指的是当前的界面为了达到最佳的使用速度,它会申请一定的内存空间,但是这个内存空间的大小远超过当前设备本身所能提供的大小。
页面的性能随着时间延长越来越差
这个过程一般伴随着内存泄漏
因为在这种情况下刚开始是没有问题的,可能伴随着某些代码的出现,让我们的内存空间越来越少,这就是所谓的内存泄漏
监控内存的几种方式
任务管理器监控内存
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>任务管理器监控内存变化</title>
</head>
<body>
<button id="btn">Add</button>
<script>
const btn = document.getElementById('btn')
btn.onclick = function() {
let arrList = new Array(1000000)
}
</script>
</body>
</html>
shift + esc 打开浏览器的任务管理器(记得右键把JS的选项勾选,默认不显示JS内存)
第一列内存指的是 DOM 内存,如果这个内存不断增大,说明页面内部不断的在创建内存
最后一列 javascript 内存,这里表示的是 js 的堆,这一列当中需要关注的是小括号里面的值,它表示的是界面当中,所有可达对象正在使用的内存大小。如果这个数值一直在增大,意味着界面中,要么在创建新对象,要么就是现有对象在不断的增长。
得出结论:如果说小括号里面的数值一直在增大,意味着当前的内存是有问题的,具体是什么问题,浏览器的任务管理器就看不出来了,只能看出来是有问题的,不能定位问题。
Timeline记录内存
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>时间线记录内存变化</title>
</head>
<body>
<button id="btn">Add</button>
<script>
const arr = []
function test() {
for(let i = 0; i<100000; i++){
document.body.appendChild(document.createElement('p'))
}
arr.push(new Array(1000000).join('x'))
}
document.getElementById('btn').addEventListener('click',test)
</script>
</body>
</html>
F12选Performance,打开录制,点击几次按钮,停止录制,生成下方图表,勾选内存和下面的JS堆就可以了,其他不用看。
这里只关注 JS 堆内存,可以看到,从刚开始的平稳状态,到内存突然间就上去了,是因为我们的点击按钮操作,而上去之后就又下来了,这是因为浏览器本身的垃圾回收机制,在脚本运行稳定之后 GC 就开始工作了,回收非活动对象,后边几次的上升就是我们的连续点击按钮操作,而中间穿插的下降就是浏览器自己的回收机制,有涨有降。
如果说这个内存走势图是一直往上走,而不往下降,那么这里就存在内存消耗的问题,更有可能是内存泄漏,这时候怎么去定位问题呢?
可以根据时序图找出问题时间点
堆快照查找分离DOM
工作原理:
找到当前的JS堆,然后进行照片留存,就可以看到里面的所有信息。就像是一个专门针对分离DOM的一种行为。
什么是分离DOM?
界面上看到的很多元素,其实都是DOM 节点,而这些节点本应该存活在DOM树上,不过对于DOM节点会有几种形态:垃圾对象、分离DOM。
如果这个节点从DOM树上脱离,而且在JS代码中也没有人引用着,它其实就成为了一个垃圾,而如果说当前的DOM节点只是从DOM树上脱离了,但是在 JS 代码中还有人在引用着,这种DOM就叫分离DOM。
这种分离DOM 在界面上是看不见的,但是在内存里却占据着空间,所以在这种情况下就是内存泄漏,因此可以通过堆快照的功能把它从这里都找出来,只要能找到,就可以回到代码里面针对它进行清除就行
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heap</title>
</head>
<body>
<button id="id">Add</button>
<script>
var temEle
var btn = document.getElementById('id')
// console.log(btn);
btn.addEventListener('click', function () {
var ul = document.createElement('ul')
console.log(ul);
for (let i = 0; i < 10; i++) {
var li = document.createElement('li')
ul.appendChild(li)
}
console.log(ul); //console.log也被引用记得注释
temEle = ul
})
</script>
</body>
</html>
F12后点击memory
找到问题后,清除对应的代码即可。
注意!!!如果console.log也打印了ul 那么也是被引用的!!!add后搜索deta也会有分离dom
判断是否存在频繁GC
Performance 总结
谷歌浏览器所提供的一款性能工具
- 使用流程
- 内存问题的相关分析
- Performance 时序图监控内存变化
- 任务管理器监控内存变化
- 堆快照查找分离 DOM
代码优化介绍
本质上就是采集大量的执行样本进行数学统计和分析
使用基于 Benchmark.js 的 https://jsperf.com 完成(停止维护了)后续会结束JSbench代替
JSBench 使用
JSBench 一款JSPerf 的替代品,因为 jsperf 网站停止维护。
setup: 前置初始化的一些东西
setup html: 前置的一些HTML元素
setup js: 前置的一些统一的JS代码
teardown: 一些收尾的统一的操作
慎用全局变量
为什么要慎用?
- 全局变量定义在全局执行上下文,是所有作用域的顶端
js 查找是按照层级往上查找的,下边局部作用域的变量没有找到,最终都会去查到最顶端的全局上下文,这种情况下,查找的时间消耗是非常大的,降低了代码的执行效率, - 全局执行上下文一直存在于上下文执行栈,直到程序退出
所以这种情况下对于 GC 的工作是非常不利的,不会把它当作垃圾对象进行回收 - 如果某个局部作用域出现了同名变量,则会遮蔽或污染全局
总归来说,我们在使用全局变量的时候,就需要考虑更多的事情,否则,就会给我们带来一些意想不到的情况。
// 全局变量
var i, str = ''
for(i = 0; i < 1000; i++){
str += i
}
// 局部变量
for(let i = 0; i < 1000; i++){
let str = ''
str += i
}
缓存全局变量
把一些无法避免使用的全局变量缓存到局部。
这里对比一下采用局部缓存和不采用局部缓存,性能差异到底有多大:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>缓存全局变量</title>
</head>
<body>
<input type="button" value="btn" id="btn1">
<input type="button" value="btn" id="btn2">
<input type="button" value="btn" id="btn3">
<input type="button" value="btn" id="btn4">
<p>1111</p>
<input type="button" value="btn" id="btn5">
<input type="button" value="btn" id="btn6">
<p>222</p>
<input type="button" value="btn" id="btn7">
<input type="button" value="btn" id="btn8">
<p>333</p>
<input type="button" value="btn" id="btn9">
<input type="button" value="btn" id="btn10">
<script>
function getBtn() {
let oBtn1 = document.getElementById('btn1')
let oBtn3 = document.getElementById('btn3')
let oBtn5 = document.getElementById('btn5')
let oBtn7 = document.getElementById('btn7')
let oBtn9 = document.getElementById('btn9')
}
function getBtn2() {
let obj = document
let oBtn1 = obj.getElementById('btn1')
let oBtn3 = obj.getElementById('btn3')
let oBtn5 = obj.getElementById('btn5')
let oBtn7 = obj.getElementById('btn7')
let oBtn9 = obj.getElementById('btn9')
}
</script>
</body>
</html>
可以看到,做缓存后的代码会稍微提高运行效率
通过原型对象添加附加方法
js 中的三种概念:
构造函数、原型对象、实例对象,构造函数和实例对象都是可以指向原型对象。
如果某个构造函数的内部有一个成员方法,而后续的实例对象都需要频繁的去调用,这里就可以直接将它添加在原型对象上,而不需要把它放在构造函数内部。
这两种不同的实现方式,在性能上有所差异。
var fn1 = function() {
this.foo = function() {
console.log(11111)
}
}
let f1 = new fn1()
var fn2 = function() {}
fn2.prototype.foo = function() {
console.log(11111)
}
let f2 = new fn2()
避开闭包陷阱
关于闭包:
- 闭包是一种强大的语法
- 闭包使用不当容易造成内存泄漏
- 不要为了闭包而闭包
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>闭包陷阱</title>
</head>
<body>
<button id="btn">add</button>
<script>
function foo() {
var el = document.getElementById('btn')
el.onclick = function() {
console.log(el.id)
}
}
foo()
</script>
</body>
</html>
可以看到,onclick 函数引用到了foo作用域的变量,这里边的 el 是一直被引用着的,无法被回收,当这种代码越来越多的时候,对于内存是非常不友好的,也就是闭包所存在的陷阱,就是内存泄漏。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>闭包陷阱</title>
</head>
<body>
<button id="btn">add</button>
<script>
function foo() {
var el = document.getElementById('btn')
el.onclick = function() {
console.log(el.id)
}
el = null // 清掉之后 GC 会自动清除这块内存空间
}
foo()
</script>
</body>
</html>
清除引用后,就不再被计数GC会进行回收
避免属性访问方法使用
关于属性访问方法,是和面向对象相关的,为了实现更好的封装性,所以更多的时候可能会将一些成员属性和方法给放在一个函数的内部,然后在外部去暴漏这样的一个方法对当前的属性进行增删改查的操作,但是在 js 的面向对象当中并不是特别的适用。
- 在 js 里面是不需要属性的访问方法的,所有的属性都是外部可见的
- 在使用属性访问方法的时候相当于增加了一层重定义,没有访问控制力
For循环优化
这里讲的不细
提前获取length的长度,这样就不会每次判断的时候重新获取了
选择最优的循环方法
这里讲的不细
var arrList = new Array(1, 2, 3, 4, 5)
arrList.forEach(function(item) {
console.log(item)
})
for (var i = arrList.length; i; i--) {
console.log(arrList[i])
}
for (var i in arrList) {
console.log(arrList[i])
}
结果
文档碎片优化节点添加
针对于外部应用开发来说,DOM 操作是非常频繁的,而针对于 DOM 的交互操作,又是非常消耗性能的,特别是创建新的节点,将节点添加至界面中时,这个过程一般都会伴随着回流和重绘,这两个操作对于性能的消耗时比较大的,这里看一下针对于节点的添加有怎样的优化操作。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>优化节点添加</title>
</head>
<body>
<script>
for (var i = 0; i < 10; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
document.body.appendChild(oP)
}
const fragEle = document.createDocumentFragment()
for (var i = 0; i < 10; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
fragEle.appendChild(oP)
}
document.body.appendChild(fragEle)
</script>
</body>
</html>
克隆优化节点操作
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>克隆优化节点操作</title>
</head>
<body>
<p id="box1">old</p>
<script>
for (var i = 0; i < 3; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
document.body.appendChild(oP)
}
var oldP = document.getElementById('box1')
for (var i = 0; i < 3; i++) {
var newP = oldP.cloneNode(false)
newP.innerHTML = i
document.body.appendChild(newP)
}
</script>
</body>
</html>
所谓的克隆指的是:当我们要去新增节点的时候,可以找到一个与他类似的一个节点,把它克隆一下,然后再把克隆好的这样的节点直接添加到我们界面当中。
优化 因为之前的元素有了某些属性和样式,这样就不用重新创建了clone就行。
通过测试得出,克隆的效率要高于新建的效率
直接量替换 new Object
所谓的直接量替换 new Object 就是当我们要创建数组的时候,我们可以直接用 new 的方式创建,也可以直接采用它的字面量。
var a = [1, 2, 3]
var a1 = new Array(3)
a1[0] = 1
a1[1] = 2
a1[2] = 3
可以看出来通过字面量直接创建的这种方式相对效率要更高一点