❝性能优化是一个很大的概念,性能优化的方向有很多比如底层、框架层面上、页面上等等,本篇文章介绍的是JavaScript语言的优化,了解JavaScript的运行的机制❞
本片文章主要从如下几个方面讲解:
- 内存管理
- 垃圾回收与常见GC算法
- V8引擎的垃圾回收
- Performance 工具
- 代码优化实例
内存管理
内存为什么需要管理呢?当程序执行的时候需要去内存申请一片空间进行使用,如果内存不进行管理释放内存空间,那么内存很容易就会溢出。
- 内存是由可读写单元组成,表示一片可操作空间
- 管理:认为的的去操作一片空间的申请、使用与释放
- 内存管理:开发者主动申请空间、使用空间、释放空间
- 管理流程:申请-使用-释放
JavaScript中的内存管理
- 申请内存空间
- 使用内存空间
- 释放内存空间
如下代码,JavaScript 中的内存管理
代码语言:javascript复制// 申请空间
let obj = {}
// 使用空间
obj.name = 'foo';
//释放空间
obj = null;
垃圾回收
JavaScript中的垃圾回收
- JavaScript中内存管理是自动的
- 对象不再被引用时是垃圾
- 对象不能从根上访问到时是垃圾
JavaScript 中的可达对象:
- 可以访问到的对象就是可达对象(引用、作用域链)
- 可达的标准就是从根出发是否能够被找到
- JavaScript中的根就可以理解为全局变量对象
下面通过代码来看JavaScript中的引用与可达
如下代码,obj→xm,ali→xm
,当设置obj=null,ali → xm
引用的概念理解很容易,其实引用就是指向了一个内存空间(堆内存),这个内存空间(堆内存)可以被栈内存中的变量指向也就是栈内存中的变量存储的是指向堆内存的指针地址。
let obj = {name:'xm'} //全局的对象
let ali = obj;//多了一层的引用
obj = null; //obj到xm的引用就断掉了, 但是ali还在引用xm 所以xm是可达的
那么什么是可达对象呢?下面来通过一段代码演示.
代码语言:javascript复制function objGroup(obj1,obj2){
obj1.next = obj2;
obj2.prev = obj1;
return {
o1:obj1,
o2:obj2,
}
}
let obj = objGroup({name:'obj1'},{name:'obj2'});
console.log(obj);
上述代码可以用下图来表示,全局找到一个可达的对象obj
然后返回了o1
和 o2
而o1
和 o2
是互相指向的,下面的都是可达的对象
那么什么情况下是不可达的呢?看下图将obj
指向o1
的链条断掉,o2
指向o1
的链条也断掉,那么我们在看从global
根出发就找不到o1
那么o1
就是一个不可达
的对象,也就是垃圾对象会被JavaScript引擎回收掉。
那么什么是可达,什么是引用就讲述清楚了。
GC算法
- GC 就是垃圾回收机制的简写
- GC可以找到内存中的垃圾、并释放和回收空间
GC里的垃圾是什么
- 程序中不再需要使用的对象
- 程序中不能再访问到的对象
什么是GC算法
- GC是一种机制,垃圾回收器完成具体的工作
- 工作的内容就是查找垃圾释放空间、回收空间
- 算法就是工作时查找和回收所遵循的规则
常见的GC算法:
- 引用计数
- 标记清除
- 标记整理
- 分代回收
引用计数算法
核心思想:设置引用数,判断当前引用数是否为0. 引用计数器;引用关系改变时就会修改引用数字,比如有一个内存空间有一个变量指向它引用计数就会加一,如果这个变量不再指向它了引用计数就会减一,当这个内存空间引用数字为0时立即回收。
如下的代码片段,user1 → user3
被nameList
引用着 此时的引用数不是0 就不会被GC回收掉,在fn()函数如果num1 num2
不被const
修饰那么num1 num2
就会挂载到全局上,即使函数fn()执行完毕也不会被回收,如果加上const
修饰符只作用于函数内部,那么函数执行完毕就会被回收掉。
const user1 = { age: 11 }
const user2 = { age: 12 }
const user3 = { age: 13 }
//user1 - user3 都被nameList 此时引用数不是0 就不会被GC回收掉
const nameList = [user1.age, user2.age, user3.age];
function fn() {
//挂载在全局下 加上const 就会只在fn()其效果 一旦函数调用完毕 num1 num2 的引用计数就为0
const num1 = 1
const num2 = 2
}
fn();
引用计数算法的优点:
- 发现垃圾时立即回收
- 最大限度减少程序暂停(应用程序在执行的过程中会对内存进行消耗,内存是有限制的,当内存将要爆满的时候引用计数就会立即找到引用数0的内存空间立即释放)
引用计数算法的缺点:
- 无法回收循环引用的对象
如下代码片段:函数执行结束以后,内部所在的空间需要被回收因为全局已经没有引用了,但是在函数的内部空间,obj1.name → obj2;[obj2.name](http://obj2.name) → obj1
,所以obj1和obj2的引用数并不是为0的,那么引用计数算法就无法回收obj1 和 obj2导致内存空间上的浪费。
function fn1(){
const obj1 = {}
const obj2 = {}
//但是obj2的一个属性是指向了 obj1的两者之间还存在引用 引用计数并不是为0的
obj1.name = obj2;
obj2.name = obj1;
return '';
}
fn1();
- 时间开销大(引用计数要维护着引用数的变化,时刻监控当前对象的引用数值是否需要修改,如果内存中有非常多的对象需要修改,那么时间开销会大一些)
标记清除算法
- 核心思想:分标记和清除两个阶段
- 第一个阶段:遍历所有对象找标记活动对象(活动对象:可达对象)
- 第二个阶段:遍历所有对象清除没有标记对象,并且抹掉第一个阶段的标记,便于下一次的标记清除正常工作
- 回收相应的空间
看下图来理解标记清除算法:
我们都知道标记清除算法标记的都是可达对象,可达的标准就是全局作用域Global
下查找到的对象就是可达对象。下面来仔细看图,图中global
是全局作用域就是根,下面的 A B C D E
都是可达对象,而右边的obj1
和 obj2
在局部作用域中并且两个互相引用,不是可达对象无法进行标记就会被清除掉,其实标记清除算法也就解决了上述中的引用计数算法 的无法回收循环引用对象的问题。将回收的空间放在「空闲链表」的地方。
标记清除算法优点:相对于引用计数算法
- 可解决循环引用对象的问题
标记清除算法缺点:
标记清除算法的空间回收,地址不连续会导致空间碎片化
如下图所示通过标记清除算法标记了可达对象B
,而对象A
和对象C
都是不可达的,就会被回收掉他们的内存空间,但是B
的内存空间正好在A
和C
的中间位置 这样就会导致回收的空间地址不连续的,比如对象D
空间大小正好是2
或者1
就会被分配到A
或C
,如果D
的空间大小是1.5
那么找A
的空间就会太大,而找C
的空间就会太小,这样会导致内存空间会有很多碎片。
标记整理算法
- 标记整理可以看做是标记清除的增强
- 标记阶段的操作和标记清除一致(遍历所有对象找标记活动对象(活动对象:可达对象))
- 清除阶段会先执行整理,移动对象位置
看下图,是回收前的内存分布,后很多的活动对象、非活动对象、空闲的空间
标记整理算法会在清除的时候先整理内存空间,移动对象的位置,整理的内存空间如下图
把活动对象进行移动在地址上变成一个连续的,然后再将非活动的对象进行回收。
回收后的内存空间,如下面的图示
相对于之前的标记清除算法就不会大量的分散的碎小的空间,使得回收后的空间尽量是连续的
在回顾一下常见的GC算法
- 引用计数
- 标记清除
- 标记整理
引用计数优缺点:
- 可以及时回收垃圾对象
- 减少程序卡顿时间
- 无法回收循环引用的对象
- 资源消耗较大
标记清除优缺点:
- 可以回收循环引用的对象
- 容易产生碎片化空间,浪费空间
- 不会立即回收垃圾对象(清除的时候程序是停止工作的)
标记整理优缺点:
- 减少碎片化空间
- 不会立即回收垃圾对象(清除的时候程序是停止工作的)
V8 垃圾回收策略
什么是V8:
- V8是一款主流的JavaScript执行引擎
- V8采用即时编译(一般的JS引擎源代码- 字节码才会执行,而V8会直接翻译成机器码)
- V8内存设有上限的(64位 ≤ 1.5G;32位 ≤ 800M )
V8垃圾回收策略:
- 采用分代回收的思想
- 内存分为新生代、老生代
- 针对不同对象采用不同算法
如下图示V8的垃圾回收策略
V8中常用GC算法
- 分代回收
- 空间复制
- 标记清除
- 标记整理
- 标记增量
V8如何回收新生代对象
首先我们先看一下V8的内存分配,如下图所示左侧红色区域专门存储新生代存储区,右侧为老生代存储区
- V8内存空间一分为二
- 小空间用于存储新生代对象(64位→32M | 32位→16M)
- 新生代指的是存活时间较短的对象 (什么是存活时间较短的对象:当前的代码内有一个变量a在局部作用域,变量b在全局作用域,a的存活时间是比较短的)
新生代对象回收实现:
- 回收过程采用复制算法 标记整理
- 新生代内存区分为二个等大小空间From 和 To
- 使用空间为From,空闲空间为To
- 活动对象存储于From空间
- 标记整理后将活动对象拷贝至To空间 From空间的活动对象就会有一个备份
- From与To交换空间完成释放
拷贝过程中可能出现晋升,晋升就是将新生代对象移动至老生代,如果一轮GC还存活的新生代需要晋升,如果To空间的使用率超过25%将新生代对象移动至老生代
那么V8如何回收老生代呢?
- 老生代64位→1.4G , 32位→ 700M
- 老生代对象就是指存活时间较长的对象(如全局作用域下所存放的变量、闭包的情况下所存储的变量数据)
- 主要采用:标记清除、标记整理、增量标记算法
- 首先使用标记清除完成垃圾空间的回收
- 采用标记整理进行空间优化(当新生代区域内容移动至老生代区域,而且老生代的存储空间不足以存储新生代所移动过来的对象,就会执行标记整理优化空间)
- 采用增量标记进行效率优化
细节对比:
- 新生代区域垃圾回收使用空间换时间
- 老生代区域垃圾回收不适合复制算法
关于增量标记算法如何优化垃圾回收?
如下图示
分会两个部分一个是程序的执行一个是垃圾回收,当执行垃圾回收操作会停止程序的执行,将一整段的垃圾回收操作组合的完成垃圾回收,垃圾回收与程序执行交替执行这样所带来的时间消耗会合理一些,程序执行一会标记一轮,最后标记操作完成操作后就进行垃圾回收操作,当垃圾回收操作完成之后程序继续执行操作。以前的垃圾回收会进行一整段操作,也会使程序停顿很长的一段时间。
回顾V8垃圾回收
- V8是一款主流的JavaScript执行yinq
- V8内存设置上限 主要针对浏览器
- V8采用基于分代回收思想实现垃圾回收
- V8内存分为新生代和老生代
- V8垃圾回收常见的GC算法(新生代:复制算法 标记整理;老生代:标记清除 标记整理 增量标记)
Performance工具
- GC的目的是为了实现内存空间的良性循环
- 良性循环的基石是合理使用
- 时刻关注才能确定是否合理
- Performance提供多种监控方式
Performance工具是浏览器提供的一种工具,如下图示
内存问题的体现配和工具进行定位
- 页面出现延迟加载或经常新暂停 → 频繁的垃圾回收
- 页面持续性出现糟糕的性能 → 内存膨胀
- 页面的性能随时间延长越来越差 → 可能会出现内存泄漏
监控内存的几种方式,界定内存问题的标准
- 内存泄漏:内存使用持续升高
- 内存膨胀:在多数设备上都存在性能问题
- 频繁垃圾回收:通过内存变化图进行分析
- 浏览器任务管理器可以监控内存
- Timeline时序图记录监控内存
- 堆快照查找分离DOM
- 判断是否存在频繁的垃圾回收
监控内存的方式
- 使用 Chrome 的任务管理器了解您的页面当前正在使用的内存量。
- 使用 Timeline 记录可视化一段时间内的内存使用。
- 使用堆快照确定已分离的 DOM 树(内存泄漏的常见原因)。
- 使用分配时间线记录了解新内存在 JS 堆中的分配时间。
任务管理器监控内存
首先我们需要写一段代码,来模拟内存变化,触发点击事件的时候 创建一个特别大的数组
代码语言:javascript复制<!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">点击</button>
<script>
//模拟内存的变化 点击事件触发创建一个长度非常长的数组
let btn = document.querySelector('#btn');
btn.onclick = function(){
let arr = new Array(1000000);
}
</script>
</body>
</html>
如何打开浏览器的任务管理器呢?如下图所示
打开任务管理器之后找到我们写的对应的页面的任务,然后显示JavaScript的内存
点击页面的按钮可以明显的看到内存增大了,但是任务管理器无法定位问题,只能够监控JavaScript脚本内存的变化
Timeline 记录内存
任务管理器更多的是判断当前的脚本的内存是否存在问题,但是无法精准的定位问题。
首先我们需要通过一段模拟的代码
代码语言:javascript复制<!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">点击</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'));//一百万长度的字符串
}
//模拟内存的变化 点击事件触发创建一个长度非常长的数组
let btn = document.querySelector('#btn');
btn.onclick = test;
</script>
</body>
</html>
打开性能面板,进行录制,点击几次按钮之后停止录制,就可以得到如下图的程序的运行信息,注重关注JS Heap 可以看到内存是有增长也有降低这是因为点击了按钮内存立马就会增长,而内存下降的原因是执行了垃圾回收操作内存就会下降,在最上面的信息中还可以看到代码执行的时间,从而分析出程序出现的问题。
堆快照查找分离DOM
- 界面元素存活在DOM树上
- 垃圾对象时的DOM节点(从DOM树上脱离,在JS代码中也没有引用)
- 分离状态的DOM节点(从DOM树上脱离,在JS代码中存在引用,那么这样是有问题的占用内存,需要找到代码进行优化)
首先写模拟代码 创建的DOM但是没有添加到DOM树上,那么这种情况就是分离DOM
代码语言:javascript复制<!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">点击</button>
<script>
//新建DOM但是不在界面上使用
var temEle;
function fn(){
var ul = document.createElement('ul');
for(let i = 0;i<10;i ){
let li = document.createElement('li');
ul.appendChild(li);
}
temEle = ul;
}
let btn = document.querySelector('#btn');
btn.onclick = fn;
</script>
</body>
</html>
运行页面,打开开发者工具选择Memory
选项,会看到Heap snapshot
这个就是堆快照,先不要点击按钮,先进行一次快照和之前的进行对比,点击Task snapshot
生成快照
生成快照 Snapshot1
没有点击按钮之前的快照,检索deta
这个就是查找是否存在分离DOM
之后点击按钮,然后在点击生成快照,看一下两个快照有什么不同,如下图所示点击按钮之后确实在堆中生成了DOM但是并没有在DOM树上引用,这样其实是占用空间,浪费空间的,解决方案:「在确定不使用的地方直接置为null即可」
判断是否存在频繁GC
- GC工作时应用程序是停止的
- 频繁且过长的GC会导致应用致死
- 用户使用中感知应用卡顿
确定频繁垃圾回收
- Timeline中频繁的内存上升下降
- 任务管理器中数据频繁的增加减小 瞬间增大瞬间减小这样的表象就会频繁垃圾回收
Performance总结
- Performance 使用流程
- 内存问题的相关分析方式
- Performance时序图监控内存变化
- 任务管理器监控内存变化
- 堆块照查找分离DOM 可能会存在内存泄漏的现象
代码优化
如何进准测试JavaScript性能
- 本质上就是采集大量的执行样本进行数学统计和分析
- 使用基于Benchmark.js完成
Jsperf使用流程 测试JavaScript代码
- 测试用例信息(title、slug)
- 准备代码(DOM 操作时经常使用)
- 填写setup和teardown代码
- 填写测试代码片段
慎用全局变量
- 全局变量定义在全局执行上下文,是所有作用域链的顶端
- 全局执行上下文一直存在于上下文执行栈,直到程序退出
- 如果某个局部作用域出现了同名变量则会遮蔽或污染全局
//1
var i,str = '';
for(i = 0; i<1000;i ){
str = i;
}
//2
for(let i = 0; i<1000;i ){
let str = '';
str = i;
}
通过Jsperf来测试全局变量代码,测试结果如下,很明显全局变量会导致JavaScript的性能下降,在实际开发中要慎用全局变量
缓存全局变量
将使用中无法避免的全局变量缓存到局部中。
如下代码示例:
代码语言:javascript复制<!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" name="" id="btn1" value="btn">
<input type="button" name="" id="btn2" value="btn">
<input type="button" name="" id="btn3" value="btn">
<input type="button" name="" id="btn4" value="btn">
<p>1111</p>
<input type="button" name="" id="btn5" value="btn">
<input type="button" name="" id="btn6" value="btn">
<p>2222</p>
<input type="button" name="" id="btn7" value="btn">
<input type="button" name="" id="btn8" value="btn">
<p>33333</p>
<input type="button" name="" id="btn9" value="btn">
<input type="button" name="" id="btn10" value="btn">
<script>
//没有缓存全局变量的情况
function getBtn(){
let btn1 = document.getElementById('btn1');
let btn3 = document.getElementById('btn3');
let btn5 = document.getElementById('btn5');
let btn7 = document.getElementById('btn7');
let btn9 = document.getElementById('btn9');
}
//缓存了全局变量的情况
function getBtn2(){
let obj = document;
let btn1 = obj.getElementById('btn1');
let btn3 = obj.getElementById('btn3');
let btn5 = obj.getElementById('btn5');
let btn7 = obj.getElementById('btn7');
let btn9 = obj.getElementById('btn9');
}
</script>
</body>
</html>
jsperf 中进行添加测试测试结果如下,缓存全局变量比不缓存全局变量快一些
通过原型新增方法
在原型对象上新增实例对象需要的方法。
如下代码示例
代码语言:javascript复制//添加到实例内部
var fn1 = function () {
this.foo = function () {
console.log(1111);
}
}
let f1 = new fn1();
//添加到原型上
var fn2 = function () { }
fn2.prototype.foo = function () {
console.log(1111);
}
let f2 = new fn2();
在jsperf上添加两种方式测试代码,进行性能对比,性能上在原型对象上添加方法性能要更好
避开闭包陷阱
闭包特点
- 外部具有指向内部的引用
- 在”外“部作用域访问”内“部作用域的数据
- 闭包使用不当很容易出现内存泄露
- 不要为了闭包而闭包
下面来演示闭包导致的内存泄露的问题
代码语言:javascript复制<!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还有引用存在 无法回收el的内存的
// el.onclick = function(){
// //跨作用域的引用 当前的内存可能会泄露的 这样导致el无法被回收的
// console.log(el.id);
// }
// }
// foo();
function foo(){
var el = document.getElementById("btn");
el.onclick = function(){
//内部引用了外部作用域变量
console.log(el.id);
}
//当el 不再使用的时候手动置为null 比如删除了btn元素 dom元素的引用消失了 但是代码中el还在引用中 要手动置为null
el = null;//解决当前内存的泄露 el清除 dom对他的引用消失了
}
foo();
// 要理清逻辑 避开闭包的陷阱 把闭包的引用关系在不使用的时候尽可能的去清除引用
</script>
</body>
</html>
避免属性访问方法使用
- JS不需要属性的访问方法,所有属性都是外部可见的
- 使用属性访问方法只会增加一层重定义,没有访问的控制力
如下测试用例,从性能上避免属性访问方法的使用性能上要更好一些
for循环优化
如下示例代码:主要进行了两个for循环的对比,第一个for循环每次循环获取length,第二个for循环对length进行了保存。
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>for 循环优化</title>
</head>
<body>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<script>
var btns = document.querySelectorAll('.btn');
//每次循环获取length
for(var i=0;i<btns.length;i ){
console.log(i);
}
//对len的值进行了保存
for(var i = 0,len = btns.length;i<len;i ){
console.log(i);
}
</script>
</body>
</html>
测试结果 显然对length进行了一个保存性能要更好一些
For循环优化:提前对length进行缓存。
采用最有循环方式
比对for forEach for..in..三种循环的对比
如下对比结果forEach循环是最优的,然后是for循环而for..in..是最差的
节点添加优化
节点添加操作必然会有回流和重绘
通过文档碎片来提高append和created的操作
代码语言:javascript复制<!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>
克隆优化节点操作
代码示例如下:
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</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>
下面来看一下性能对比:
直接量替换Object操作
如下的测试结果,使用var a = [1,2,3]
的性能要更好一些
总结
- JS中的内存空间在变量定义时自动分配,程序员无法指定大小
- JS中内存的生命周期为:申请内存、使用内存、释放内存三个步骤
- JS中的内存释放可以由开发者自己来完成 JS平台虽然都存在GC机制,但是由于不同算法的限制,代码书写不当同样会导致内存无法回收,产生泄露
- 标记清除算法的缺点就是找到垃圾对象空间后直接进行回收,而有可能产生大量碎片化空间
- 在一个作用域链上,只要通过跟可以有路径查找到的对象都是可达对象
- 标记清除算法的一个阶段会找到所有的可达对象
- GC操作的执行会导致应用程序的停止,等到GC工作结束之后应用执行才会继续