本文,我们来聊聊如何写出高性能的JavaScript代码,以及JavaScript可以优化之处。
本文主要内容:
- 内存管理
- 垃圾回收与GC算法
- V8引擎的垃圾回收
- 谷歌开发者面板的Performance工具
- 代码优化示例
JavaScript的内存管理
为什么要关注内存管理?
- 在前端的开发过程中,我们似乎很少关注内存,毕竟不关注内存页能够完成业务功能的开发。
- 不同的代码编写及代码运行逻辑依然会影响程序的运行处理能里,为了编辑高质量的代码和优化功能,需要我们去关注内存产生的影响。
下面我们来看个js代码示例:
(1)访问list索引1;
function fn() {
const list = [];
list[1] = "hello";
}
fn();
复制代码
内存分型图:
(2)访问list索引100000;
function fn() {
const list = [];
list[100000] = "hello";
}
fn();
复制代码
内存分型图:
由以上两种分析结果可知:不同的js代码对内存使用同,随着业务的复杂,需要我们对编写高质量的代码,不然,会造成不必要的内存泄漏,造成不必要的性能浪费。
什么是内存管理?
- 内存:有可读写单元组成,表示一片可操作空间;
- 管理:认为操作一片空间的申请、使用、释放;
- 内存管理:开发者主动申请、使用、释放空间;
- 流程:申请->使用->释放;
JavaScript是如何管理内存呢?
JavaScript中没有API让我们主动管理内存,JavaScript的引擎会自动帮我们管理内存。
// 申请内存
const obj= {};
// 使用内存
obj.name= 'hello';
// 释放内存
obj= null;
复制代码
性能分析图示:
由图示可分析:js执行上述代码时,经历了申请->使用->释放,三个阶段。
JavaScript的垃圾回收
JavaScript中的垃圾
- JavaScript中的内存管理是自动的;
- 对象不在被引用时时垃圾;
- 对象不能从根上访问到时是垃圾;
JavaScript中的可达对象
- 可以访问到的对象(引用、作用域链);
- 可达的标准就是从根除法是否能够被找到;
- JavaScript中的根可以认为是全局变量对象;
JavaScript中的引用与可达
- 可达对象图示
- 删除引用途径
- 回收无法可达对象
gc算法介绍
gc的定义与作用
- gc是垃圾回收机制的简写;
- gc可以找到内存中的垃圾、释放并回收空间;
gc里面的垃圾是什么
- 不在使用的对象
function fn() {
name = 'lisi'
console.log(`${name}`)
}
fn()
复制代码
- 程序中不能再访问到的对象
function fn() {
const name = 'lisi'
console.log(`${name}`)
}
fn()
复制代码
gc算法是什么
- gc是一种机制,垃圾回器完成具体工作;
- 工作是查找垃圾,释放、回收空间;
- 算法就是垃圾查找、回收遵循的规则;
常见gc算法
- 引用计数
- 标记清除
- 标记整理
- 分代回收
引用计数法原理
每个对象在创建的时候,就给这个对象绑定一个计数器。每当有一个引用指向该对象时,计数器加一;每当有一个指向它的引用被删除时,计数器减一。这样,当没有引用指向该对象时,该对象死亡,计数器为0,这时就应该对这个对象进行[垃圾回收]操作。
- 核心思想:设置引用数,判断当前引用数是否为0;
- 引用计数器;
- 引用关系改变时,修改引用数字;
- 引用数字为0时立即回收;
引用计数法优点
- 发现垃圾时,立即回收;
- 最大限度减少程序暂停;
引用计数法缺点
- 无法回收循环引用的对象
function fn() {
const obj1= {}
const obj2= {}
// 互相引用,引用计数算法无法清除
obj1.name=obj2;
obj2.name=obj1;
}
fn()
复制代码
- 时间开销大
标记清除法原理
为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。
- 核心思想:分标记和清除两个阶段完成
- 遍历所用对象找标记活动对象;
- 遍历所用对象清除没有标记对象;
- 回收相应空间
标记清除法优点
- 相比于引用计数法,标记清除算法中每个活着的对象的引用只需要找到一个即可,找到一个就可以判断它为活的。
- 在指针操作上也没有太多的花销。更重要的是,这个算法并不移动对象的位置
标记清除法缺点
- 清除算法的使用过程中会逐渐产生被细化的分块,不久后就会导致无数的小分块散布在堆的各处
- 清除算法中分块不是连续的,因此每次分配都必须遍历空闲链表,找到足够大的分块。最糟的情况就是每次进行分配都得把空闲链表遍历到最后。
- 很长的幽灵时间,判断对象已经死亡,消耗了很多时间,这样从对象死亡到对象被回收之间的时间过长。
- 每个活着的对象都要在标记阶段遍历一遍;所有对象都要在清除阶段扫描一遍,因此算法复杂度较高。
标记整理算法原理
标记整理法是标记清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。
图示:
标记整理算法优缺点
- 减少碎片化空间
- 不会立即回收垃圾对象
- 移动对象位置,回收效率慢
V8引擎
什么是V8?
- V8是一款主流的JavaScript执行引擎
- V8采用实时编译
- V8内存有上限
V8垃圾回收策略
V8中的数据分为原始数据类型和对象数据类型(存储在堆中)。此处提到的垃圾回收策略主要针对对象数据类型的垃圾。
垃圾回收策略
- 采用分代回收的思想;
- 内存分为新生代对象和老生代对象;
- 针对不同对象采用不同算法;
回收策略图示
回收使用gc算法
- 分代回收
- 空间复制
- 标记清除
- 标记整理
- 标记增量
回收新生代对象
V8内存分配
可分为新生代存储空间和老生代存储空间。
- 内存空间一分为二
- 小空间用于存储新生代对象
- 新生代指存活时间较短的对象
新生代对象回收过程
- 回收过程采用复制算法及标记整理
- 新生代分为二个等大空间
- 使用空间为上图From,空闲空间为To
- 标记的活动对象存储于From空间
- 标记整理后将活动对象复制到To空间
- From与To交换空间完成释放
回收细节
- 复制过程可能出现晋升
- 晋升指将新生代对象移动至老生代
- 一轮gc还存活的新生代需要移到老生代
- To空间使用率超过25%,要晋升,防止from空间的不能都复制过来
回收老生代对象
老生代对象
- 老生代对象存放在右侧老生代区
- 老生代对象指存活时间较长的对象
老生代对象回收过程
- 主要采用标记清除、标记整理、增量标记算法
- 使用标记清除完成垃圾空间的回收
- 采用标记整理进行空间优化
- 采用增量标记进行效率优化
新老细节对比
- 新生代区域的垃圾回收使用空间换时间
- 老生代区域的垃圾回收不适合复制算法
增量标记优化垃圾回收图示
Performance工具
为什么使用这个工具?
- gc的目的是为了实现内存空间的良性循环
- 良性循环需要对内存的合理使用
- 垃圾回收是自动,但是需要时刻关注内存使用是否合理
- Performance提供了监控方式
Performance使用方法
- 打开浏览器输入网址
- 打开开发者工具,选择Performance面板
- 开启录制功能,访问页面
- 执行用户行为,一段时间后停止录制
- 分析界面中记录的内存信息
内存问题的体现
外在表现
- 页面出现延迟加载或经常性暂停
- 页面持续性出现糟糕的性能
- 页面性能随时间延长越来越差
监控内存的几种方式
判定内存问题的标准
- 内存泄漏:内存使用持续升高
- 内存膨胀:在多数设备上都存在性能问题
- 频繁垃圾回收:通过内存变化图进行分析
监控方式
- 浏览器任务管理器
- Timeline时序图记录
- 堆快照查找分离
- 判断是否存在频繁的垃圾回收
任务管理器监控内存(不推荐)
<body>
<button id="btn">我点</button>
<script>
const btn =document.querySelector('#btn');
btn.onclick=function () {
const arrList=new Array(10000000)
}
</script>
</body>
复制代码
未点击按钮前:
点击按钮后:
对比上下图,可发现内存的增大。
Timeline时序图记录
示例代码:
<body>
<button id="btn">我点</button>
<script>
const btn = document.querySelector("#btn");
btn.onclick = function () {
for (let i = 0; i < 10000; i++) {
document.body.appendChild(document.createElement("p"));
}
console.log(new Array(10000000).join("-"));
};
</script>
</body>
复制代码
点击button前后性能记录时间线:
分析:途中js堆的内存在某个时间段会增大及之后的下降,体现了在创建dom时的使用内存,及之后gc回收空降的内存下降,由此可分析出,在那个代码段发生了内存的使用,定位关于内存造成的问题。
堆快照查找分离
使用堆快照查看js堆照片,获取js堆信息。
什么是分离dom
- 界面元素存活在dom树上
- 垃圾对象时的dom节点:指从dom树上删除,并没有被引用
- 分离状态的dom节点:指从dom树上删除,并被引用,占用内存
示例代码:
<body>
<button id="btn">我点</button>
<script>
const btn = document.querySelector("#btn");
let tem;
btn.onclick = function () {
let ul = document.createElement("ul")
for (let i = 0; i < 10; i++) {
let li = document.createElement("li")
ul.appendChild(li);
}
tem=ul
};
</script>
</body>
复制代码
点击按钮前的堆快照:
点击按钮后(创建了ul li元素并被引用)的堆快照:
点击按钮后(创建了ul li元素被引用后置空)的堆快照:
分析:根据上述三幅图的元素创建被引用、引用后置空、未创建dom前的堆快照可分析,分离dom占用了不必要的空间,使用堆查抄找可发现空间浪费,进而优化。
判断是否存在频繁的垃圾回收
为什么频繁垃圾回收
- gc工作时应用程序时停止的
- 频繁且过长的gc会导致应用假死
- 用户使用时感知应用卡顿
确定频繁垃圾回收
- Timeline中频繁的上升下降
- 任务管理器中数据频繁的增加减小
V8引擎执行流程
为什么要说V8的工作流程,js的执行、编译时在V8的环境下的。
传送门:V8引擎执行流程
代码优化示例
事件委托
优化前:
<body>
<button index='1'>按钮1</button>
<button index='2'>按钮2</button>
<button index='3'>按钮3</button>
<script >
var Buttons = document.querySelectorAll('button');
for (let i = 0; i < aButtons.length; i++) {
Buttons[i].onclick = function () {
console.log(`当前索引值为${i}`)
}
}
</script>
</body>
复制代码
优化后:
<body>
<button index='1'>按钮1</button>
<button index='2'>按钮2</button>
<button index='3'>按钮3</button>
<script >
document.body.onclick = function (ev) {
var target = ev.target,
targetDom = target.tagName
if (targetDom === 'BUTTON') {
var index = target.getAttribute('index')
console.log(`当前点击的是第 ${index} 个`)
}
}
</script>
</body>
复制代码
变量局部化
- 变量局部化分为全局和局部。
- 变量需尽量放到距离使用该变量的局部作用区域,可以减少访问变量的层级,这样可以提高代码的执行效率( 减少了数据访问时需要查找的路径 )
- 数据的存储和读取需要时间。
示例:
var i, str = ""
function packageDom() {
for (i = 0; i < 1000; i++) {
str += i
}
}
packageDom()
复制代码
优化后:
function packageDom() {
let str = ''
for (let i = 0; i < 1000; i++) {
str += i
}
}
packageDom()
复制代码
减少访问层级
访问层级越多,时间消耗变大。
示例:
function Person() {
this.name = 'aaa'
this.age = 40
this.getAge = function () {
return this.age
}
}
let p1 = new Person()
console.log(p1.getAge())
复制代码
优化后:
function Person() {
this.name = 'aaa'
this.age = 40
}
let p1 = new Person()
console.log(p1.age)
复制代码
数据缓存
为了对于需要多次使用的数据进行提前保存,后续进行使用。
示例:
<body>
<div id="skip" class="skip"></div>
<script>
var oBox = document.getElementById('skip')
// 假设在当前的函数体当中需要对 className 的值进行多次使用,那么我们就可以将它提前缓存起来
function hasClassName(ele, cls) {
console.log(ele.className)
return ele.className == cls
}
console.log(hasClassName(oBox, 'skip'))
function hasClassName(ele, cls) {
var clsName = ele.className
console.log(clsName)
return clsName == cls
}
console.log(hasClassName(oBox, 'skip'))
/*原因:
01 减少声明和语句数(词法、语法编译时间减少)
02 缓存数据(作用域链查找变快)
*/
</script>
</body>
复制代码
防抖和节流
防抖和节流的前因:
- 在一些高频率事件触发的场景下我们不希望对应的事件处理函数多次执行
应用场景:
- 滚动事件
- 输入的模糊匹配
- 轮播图切换
- 点击操作
浏览器机制:
- 浏览器默认情况下都会有自己的监听事件间隔( 4~6ms),如果检测到多次事件的监听执行,那么就会造成不必要的资源浪费
防抖: 对于这个高频的操作来说,我们只希望识别一次点击,可以人为是第一次或者是最后一次。
节流: 对于高频操作,我们可以自己来设置频率,让本来会执行很多次的事件触发,按着我们定义的频率减少触发的次数
防抖函数实现
<button id="btn">点击</button>
<script>
var oBtn = document.getElementById('btn')
/**
* handle 最终需要执行的事件监听
* wait 事件触发之后多久开始执行
* immediate 控制执行第一次还是最后一次,false 执行最后一次
*/
function myDebounce(handle, wait, immediate) {
// 参数类型判断及默认值处理
if (typeof handle !== 'function') throw new Error('handle must be an function')
if (typeof wait === 'undefined') wait = 300
if (typeof wait === 'boolean') {
immediate = wait
wait = 300
}
if (typeof immediate !== 'boolean') immediate = false
// 所谓的防抖效果我们想要实现的就是有一个 ”人“ 可以管理 handle 的执行次数
// 如果我们想要执行最后一次,那就意味着无论我们当前点击了多少次,前面的N-1次都无用
let timer = null
return function proxy(...args) {
let self = this,
init = immediate && !timer
clearTimeout(timer)
timer = setTimeout(() => {
timer = null
!immediate ? handle.call(self, ...args) : null
}, wait)
// 如果当前传递进来的是 true 就表示我们需要立即执行
// 如果想要实现只在第一次执行,那么可以添加上 timer 为 null 做为判断
// 因为只要 timer 为 Null 就意味着没有第二次....点击
init ? handle.call(self, ...args) : null
}
}
// 定义事件执行函数
function btnClick(ev) {
console.log('点击了', this, ev)
}
// 当我们执行了按钮点击之后就会执行...返回的 proxy
oBtn.onclick = myDebounce(btnClick, 200, false)
</script>
复制代码
节流函数实现
function myThrottle(handle, wait) {
if (typeof handle !== 'function') throw new Error('handle must be an function')
if (typeof wait === 'undefined') wait = 400
let previous = 0 // 定义变量记录上一次执行时的时间
let timer = null // 用它来管理定时器
return function proxy(...args) {
let now = new Date() // 定义变量记录当前次执行的时刻时间点
let self = this
let interval = wait - (now - previous)
if (interval <= 0) {
// 此时就说明是一个非高频次操作,可以执行 handle
clearTimeout(timer)
timer = null
handle.call(self, ...args)
previous = new Date()
} else if (!timer) {
// 当我们发现当前系统中有一个定时器了,就意味着我们不需要再开启定时器
// 此时就说明这次的操作发生在了我们定义的频次时间范围内,那就不应该执行 handle
// 这个时候我们就可以自定义一个定时器,让 handle 在 interval 之后去执行
timer = setTimeout(() => {
clearTimeout(timer) // 这个操作只是将系统中的定时器清除了,但是 timer 中的值还在
timer = null
handle.call(self, ...args)
previous = new Date()
}, interval)
}
}
}
// 定义滚动事件监听
function scrollFn() {
console.log('滚动了')
}
window.onscroll = myThrottle(scrollFn, 600)
复制代码
减少循环体活动
示例1:
const list = ["vue", "react", "js"];
for (let i = 0; i < list.length; i++) {
console.log(list[i]);
}
复制代码
示例2:
const list = ["vue", "react", "js"];
let i,len =list.length;
for (i = 0; i < len; i++) {
console.log(list[i]);
}
复制代码
示例:
const list = ["vue", "react", "js"];
let len =list.length;
while (len--) {
console.log(list[len]);
复制代码
代码性能:示例1<示例2<示例3
字面量与构造式
字面量:
const obj = {
name: "lisi",
age: 18,
};
复制代码
构造式:
const obj = new Object();
obj.name = "lisi";
obj.age = 18;
复制代码
代码性能:构造式<字面量