JavaScript日常代码开发代码层面的性能优化

1,007 阅读16分钟

随着软件开发行业的不断发展,性能优化已经是一个不可避免的话题,本质上说任何可以提高运行效率,降低运行开销的行为,都可以看做是一种优化操作,前端开发过程中,性能优化无处不在,请求资源的网络,数据传输方式,开发框架等都可以优化,本文探索的是,JavaScript语言本身的优化。

这里主要从内存管理、垃圾回收与常见的GC算法、V8引擎的垃圾回收、Chrome浏览器的performance工具,代码优化实例进行性能优化相关的内容介绍

JavaScript的内存管理

内存:可读写的单元组成,表示一片可操作的空间
管理:人为的去操作一片空间的申请、使用和释放
内存管理:就是开发者中东申请空间、使用空间、释放空间 管理流程:申请--使用--释放

js中,没有直接操作内存的api,所以

// 申请空间   就是声明一个变量,js执行引擎会自动分配一个相应的空间
let obj = {}

// 使用空间  就是读写操作
obj.name = "lg"

// 释放空间  
obj = null

// 按照内存管理的流程,实现了js的内存管理

JavaScript的垃圾回收

引用、从根上访问

js中可达对象

可以访问到的对象就是可达对象(可以通过引用,作用域链找到的对象)
可达的标准就是从出发是否能被找到 js的根就可以理解为全局变量对象,也就是全局执行上下文

js中的引用和可达
function objGroup(obj1, obj2){
    obj1.next = obj2
    obj2.prev = obj1
    return {
        o1: obj1,
        o2: obj2
    }
}
let obj = objGroup({nam: 'obj1'},{name: 'obj2'})
console.log(obj);
// 这里obj obj1 obj2都是可以直接或者通过属性的引用从根上可达的

GC算法介绍

GC的定义和作用
GC就是垃圾回收机制的简写
当GC工作的时候,可以找到内存中的垃圾对象,然后对于这些空间进行释放,并且回收分配方便后续代码使用

GC里的垃圾是什么

程序中不在需要使用的对象
从程序需求的角度考虑,某个数据使用完之后,上下文不再需要用到它了,就可以当做垃圾看待

function func(){
    name = 'lg'
    return `${name} is a coder`
}
func()
如例子中的name,执行完不再用到了,应该被当做垃圾进行回收的,至于有没有被回收,现在不做讨论

程序中不能再访问到的对象
从当前程序运行过程中,变量能否被引用到的角度考虑,

function func(){
   const name = 'lg'
    return `${name} is a coder`
}
func()
当函数调用完之后,我们在外部空间就不能再访问到name了,当我们找不到他的时候,它可以算作是一种垃圾
GC算法是什么

GC是一种机制,它里面的垃圾回收器会完成具体的回收工作
工作内容的本质就是查找垃圾,释放空间、回收空间
算法就是工作时查找和回收所遵循的规则

常见的GC算法
引用计数算法实现原理

核心思想:内部通过一个引用计数器,来维护当前对象的引用数,当判断当前对象的引用数为0的时候,GC就开始工作,将其所在的对象空间进行回收和释放再使用

当某一个对象的引用关系发生改变,引用计数器主动修改这个对象所对应的引用数值,当引用数字为0的时候,GC工作,立即回收

引用计数算法的优缺点

优点
发现垃圾时立即回收(及时回收)
最大限度减少程序暂停(当发现内存即将占满的时候,引用计数器回去立马找到数值为0的,对其进行释放)
缺点
无法回收循环引用的对象(循环引用对象引用数字永远不为0)
资源消耗大(需要维护数值,数值修改需要时间,很多对象的时候,频繁操作就会消耗很多时间,是相对于其他算法而言的)

// 循环引用案例
function fn () {
    const obj1 = {}
    const obj2 = {}

    obj1.name = obj2
    obj2.name = obj1

    return 'lg'
}
标记清除算法的实现原理

核心思想:分标记和清除两个阶段完成
第一个阶段,遍历所有对象,找到所有的活动对象(可达对象),(如果找到,还有子对象,就进行递归查找)进行标记操作
第二个阶段,遍历所有对象,直接把身上没有标记的对象进行清除,有标记的身上的标记抹掉,便于下次能够正常工作
通过两次遍历行为,把当前的垃圾空间进行回收,最终把回收的空间交给相应的空闲列表进行维护,给后续代码使用
标记清除算法的优缺点
优点
相对于引用计数算法,解决了循环引用的对象清除问题
缺点
不会立即回收垃圾对象,并且在清除的时候,当前程序是停止工作的 空间碎片化,不能让空间得到最大化的使用
由于当前回收的垃圾对象在地址上是不连续的,由于这种不连续,造成回收之后,分散在各个角落,后续想要使用,后续代码需求的空间,如果比单个多了或者少了,那么之前释放的那片空间就不适合使用了

标记整理算法的实现原理

标记整理算法可以看成是标记清除的增强操作
第一阶段,与上方的标记清除算法第一阶段完全一致
第二阶段,会先执行一个整理的操作,移动对象的位置,在内存地址上产生连续
使活动对象内存地址连续,非活动对象内存地址连续,相对于标记清除算法来说,在内存中不会大批量的出现分散的小空间,回收到的空间是连续的,后续使用过程中,最大化利用当前内存中释放出来的空间
优点
相对于标记清除算法,减少了碎片化空间
缺点
不会立即回收垃圾对象

标记整理算法会配合着标记清除算法在V8引擎中配合实现频繁的GC操作

认识V8引擎

V8是一款主流的js执行引擎
V8采用即时编译,别的很多的js引擎需要将js代码转换成字节码,然后执行,V8直接将源码转成可以执行的机器码
V8的内存设有上限的 64位1.5G 32位800M 垃圾回收机制和为网页应用而生

V8的垃圾回收策略

回收指的是存储在堆区里面的对象类型的数据
内存设有上限所以
采用分代回收的思想
内存分为两类,新生代存储区、老生代存储区针对不同代采用不同的GC算法

V8常用的GC算法

分代回收 空间复制 标记清除 标记整理 标记增量

V8如何回收新生代对象

V8内存空间一分为二
小空间用于存储新生代对象(64位32M,32位16M) 新生代指的是存活时间较短的对象

新生代对象回收实现

1、回收过程采用复制算法+ 标记整理,把左侧的新生代内存区分为两个相等的空间,使用空间为from,空闲空间为to
2、代码在执行的时候,需要申请空间的时候,会将所有的活动对象分配至from空间,这个过程中to是空闲的
3、当form空间使用到一定程度的时候,就要去触发GC操作,采用标记整理的操作对from空间活动对象进行标记,使用整理操作使位置变得连续,以便后续不会产生碎片化空间
4、将活动对象拷贝至to空间,from释放掉,完成释放,最后进行from和to空间的交换

回收细节说明
拷贝过程中可能出现晋升,晋升就是将新生代对象移动至老生代存储区
判断晋升的标准:

一轮GC之后,还存活的新生代需要晋升
如果在拷贝的过程中,to空间使用率超过25%,需要将这次的活动对象移动至老年存储区进行存放 25%以上的时候,当to使用占比达到一定的界限的时候,当from和to交换的时候,从to变成from空间的时候(变成使用状态时),新的对象存储不进去了

老生代对象
老生代对象存放在右侧的老生代区域,64位大小1.4G,32位大小700M,老生代对象就是指存活时间较长的对象

老生代对象回收实现
主要采用标记清除、标记整理、增量标记算法

首先主要采用标记清除完成垃圾空间的回收
当新生代区域要把内容移动到老生代区域,然后老生代空间不足以存放新生代存储所移过来的对象(其实就是晋升),触发标记整理,回收之前的碎片空间,这样就有更多的空间可以使用
采用增量标记的算法,对当前回收效率进行提升

与新生代垃圾回收的对比

新生代区域的垃圾回收,使用空间换时间,采用的是复制算法,虽然一直有闲置空间,但是原本新生代空间就比较小,分出去的就更小,这样的空间浪费,相对于带来的时间上的提升,是微不足道的

老生代存储的内容比较多,如果使用复制算法的话,需要的时间也很多,空间浪费也严重,所以不适合使用复制算法

增量标记算法就是把标记清除算法的第一步的标记操作碎片化,因为js执行和GC标记是不能同步进行的,所以把时间很长的标记操作,分成多个标记操作,就标记一会儿,程序执行一会儿,标记一会儿,程序执行一会儿,交替操作,让整个流程更顺畅,最大限度把停顿时间拆成更小段,对用户更友好

Performance工具的介绍

GC的工作目的让内存空间在程序运行的时候出现良性循环使用
良性的基础就是对内存空间进行合理的分配
时刻关注才能确定内存空间的使用是否合理
Performance提供多种监控方式

优点:通过Performance能时刻监控内存

使用步骤:

  1. 打开浏览器输入目标地址
  2. 进入开发人员工具面板,选择Performance
  3. 开启录制功能,访问具体的界面
  4. 执行用户行为,一段时间后停止录制
  5. 分析界面中记录的内存信息
内存问题的体现

页面出现延迟加载或经常性的暂停 --可能是当前GC频繁的垃圾回收操作有关 页面持续性出现糟糕的性能 ------可能内存膨胀
页面的性能随着时间延长越来越差 -----可能内存泄漏

内存膨胀的概念:应用程序本身,要达到某种性能,需要很大的内存空间,然后当前设备硬件不支持,

监控内存的几种方式

内存泄漏:内存使用持续升高,没有下降的节点
内存膨胀:在多数设备商都存在性能问题
频繁垃圾回收:通过内存变化图进行分析 浏览器任务管理器
创建一个标签,添加点击事件,点击创建一个长度很长的数组,模拟内存的消耗,内存(dom节点占据的内存),JavaScript内存(界面中所有可达对象在使用的内存大小)

const Btn = document.getElementById('btn')
Btn.onclick = function (){
    let  arrlist = new Array(1000000)
}

更多的是判断有没有问题,但是不好判断哪里出了问题
Timeline时序图记录蓝色线条
放置一个dom节点,添加点击事件,创建大量dom节点,模拟内存消耗,通过数组,配合其他方法模拟大量内存消耗,

const arrrList =[]
function test () {
    for(let i =0;i < 100000; i++){
        document.body.appendChild(document.createElement('p'))
        arrrList.push(new Array(1000000).join('x'))
    }
}
document.getElementById('btn').addEventListener('click',test)

浏览器Performance模块 => 录制 => js堆
浏览器堆快照查找分离domdetached
什么是分离dom

界面元素存活在DOM树上
垃圾对象的DOM节点 --- 如果这个dom节点从dom树上脱离,js代码中也没有引用的dom节点
分离状态的DOM节点 --- 如果这个dom节点从dom树上脱离,js代码中还在引用的dom节点

分离dom在界面上是看不见的,但是在内存中是占据内存空间的,这种情况下是一种内存泄漏

<button id="btn"></button>
// 新建dom,用一个变量引用
var tmpEle

function fn() {
    var ul = document.createElement('ul')
    for(var i=0;i<10;i++){
        var li = document.createElement('li')
        ul.appendChild(li)
    }
    tmpEle =ul 
   // tmpEle = null
}
document.getElementById('btn').addEventListener('click',fn)

控制台=>memory=>快照 detached

判断是否存在频繁的垃圾回收,借助于不同的工具,分析,得出判断
为什么要确定频繁的垃圾回收

GC工作是应用程序是停止的
频繁且过长的GC会导致应用假死
用户使用中感知应用卡顿
如何确定呢
Timeline中频繁的上升下降
任务管理器中的数据频繁增加减小

日常开发中代码优化的介绍(注意点)

快的不一定是最优的,要根据代码的可读性和性能等条件综合考虑代码的编写

如何精准测试JavaScript性能 本质上是采集大量的执行样本进行数学统计和分析
使用基于Becnchmark.js的jsperf.com/ 完成
Jsperf 使用流程 使用Github账号登录
填写个人信息
填写详细的测试用例信息(title,slug)
填写准备代码(DOM操作时经常使用到的,没有就不填
填写必要有setup(准备)与teardown(代码销毁)代码,没有就不填
填写测试代码片段
改用 jsbench 来处理吧 网址:jsbench.me
jsbench的使用方法和jsperf使用方法大致相同

慎用全局变量
  1. 全局变量定义在全局执行上下文,是所有作用域链的顶端,(按照层级往上查找的过程,下面局部作用域没有找到变量,最终都会去查找到最顶端的全局作用域,查找耗时,降低效率)
  2. 全局上下文一直存在于上下文执行栈,知道当前程序退出才消失,(对于GC工作也是不利的,降低程序运行过程中,对于程序的使用)
  3. 如果某个局部作用域出现了同名变量,则会造成全局变量的命名污染,遮蔽当前的全局的数据
// 去jsperf.com比较代码
var i, str = ''
for(i=0;i<1000;i++){
    str += i
}

for(let i=0;i<1000;i++){
    let str
    str += i
}		使用jsperf去做测试	结果是:块级作用域的性能更好
缓存全局变量

将使用中无法避免的全局变量缓存到局部

// 定义一段html代码
    <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>11111</p>
    <input type="button" value="btn" id="btn5">
    <input type="button" value="btn" id="btn6">
    <p>22222</p>
    <input type="button" value="btn" id="btn7">
    <input type="button" value="btn" id="btn8">
    <input type="button" value="btn" id="btn9">
    <p>3333333</p>
    <input type="button" value="btn" id="btn10">
    <input type="button" value="btn" id="btn11">
    
    // 定义了两个获取元素的函数
    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		// 对于全局变量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")
    }
    使用jsperf去做测试	结果是:对于全局变量document 做了局部的缓存性能更好
通过原型对象添加附加方法

构造函数内部添加方法和原型对象上添加方法两者之间的性能

var fn1 = function() {
  this.foo = function() {
    console.log(111111);
  }
}
let f1 = new fn1()

var fn2 = function() {}
fn2.prototype.foo = function() {
  console.log(111111);
}
let f2 = new fn2()
使用jsperf去做测试      结果是:在原型对象上添加方法性能更好
避开闭包陷阱

闭包的特点:

外部具有指向内部的引用 可以在外部作用域访问内部作用域的数据

function foo() {
  var name = 'lg'
  return function fn (){
    console.log(name);
  }
}
var a = foo()	// a =null 消除引用
a()

闭包是一种强大的语法
闭包使用不当容易出现内存泄漏
不要为了闭包而闭包

避免属性访问方法的使用

JS不需要属性的访问方法,所有属性都外部可见的
使用属性访问方法只会增加一层重定义,没有访问的控制力

// 第一种
function Person() {
  this.name = 'log'
  this.age = 18
  this.getAge = function() {
    return this.age
  }
}
const p1 =  new Person()
const a=pi.getAge()
// 第二种
function Person() {
  this.name = 'log'
  this.age = 18
}
const p2 =  new Person()
const pAge2 = p2.age
// 使用jsperf去做测试    从执行速度上来说,直接通过属性访问方法更快
For循环优化
{/* <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> */}

var btns = document.getElementsByClassName('btn')
// 第一种
for (var i = 0; i < btns.length; i++) {

}
// 第二种
for (var i = 0, len = btns.length; i < len; i++) {
	// 使用jsperf去做测试     毫无疑问,这边的执行效率要比上面的效率高得多 ,原因
}
采用最优的循环方式

采用多种选择遍历时,有多种 for in /for/ foreach

var arrList =new Array(1,2,3,4,5,6,7)
// 第一种
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]);
}

// 使用jsperf去做测试  性能foreach最好 for in最差
节点添加的优化操作

节点添加操作必然会有回流和重绘

// 第一种
for (i = 0; i < 10; i++) {
  var op = document.createElement('p')
  op.innerHTML = i
  document.body.appendChild(op)
}
// 第二种
const fragEle = document.createDocumentFragment()
for (i = 0; i < 10; i++) {
  var op = document.createElement('p')
  op.innerHTML = i
  fragEle.appendChild(op)
}
document.body.appendChild(fragEle)

// 使用jsperf去做测试  文档片段添加节点优于一般的添加节点  
// 但是jsbench测试 多数情况下文档片段添加节点却是要慢的   
// 所以理论上和实际现象有时候不一定是完全吻合的
主要是了解一下document.createDocumentFragment() 创建一个新的空白的文档片段
克隆优化节点操作
<p id="box1">old</p>

// 第一种
for (i = 0; i < 3; i++) {
  var op = document.createElement('p')
  op.innerHTML = i
  document.body.appendChild(op)
}
// 第二种
var oldp = document.getElementById('box1')
for (i = 0; i < 3; i++) {
  var op = oldp.cloneNode(false)
  op.innerHTML = i
  document.body.appendChild(op)
}
// 使用jsperf去做测试  克隆方法是明显优越的
// 但是jsbench测试 多数情况下克隆节点却是要慢的 
// 要自己去实践
字面量替换 new Object()
堆栈中代码执行流程

栈内存和堆内存的回收机制和时机

浅谈js执行的AO/VO

减少判断层级
// 第一种
function doSomthing(part,chapter) {
    const parts =['ES2016','工程化','vue','react','Node']
    if(part){
        if(parts.includes(part)){
            console.log('属于当前课程');
            if(chapter){
                console.log('您需要提供vip身份');
            }
        }
    }else {
        console.log('请确认信息');
    }
}
doSomthing('ES2016',6)
// 第二种
function doSomthing(part, chapter) {
  const parts = ["ES2016", "工程化", "vue", "react", "Node"];
  if (!part) {
    console.log("请确认信息");
    return;
  }
  if (!parts.includes(part)) return;
  console.log("属于当前课程");
  if (chapter) {
    console.log("您需要提供vip身份");
  }
}
doSomthing("ES2016", 6);
  1. 每当我们遇见多层嵌套的if...else时,可以考虑一下,是否可以通过这种提前return的方法,减少嵌套层级
  2. 当遇见多个else if时,而且else if后面的值是固定的,建议使用switch case或者Map,if...else更适合做一些区间性的判断
减少作用域链查找层级

每当一个函数执行的时候,都会产生一个执行上下文,在一个函数体内,多次调用同一个函数的时候,会创建多个执行上下文,这些执行上下文都是有自己的作用域的,这些作用域之间呢,又可以通过作用域链进行连接

// 作用域链查找层级
// 第一种  慢一点 占用更少的内存空间
var name = 'zce'
function foo() {
    name ='zce666'  // 这里的name是属于全局的
    function baz () {
        var age = 38
        console.log(age);
        console.log(name);
    }
    baz()
}
foo()
// 第二种 快一点  空间换时间   占更多的内存空间
var name = 'zce'
function foo() {
   var name ='zce666'  // 这里的name是属于当前作用域的,但是需要新开辟空间存放name的值的
    function baz () {
        var age = 38
        console.log(age);
        console.log(name);
    }
    baz()
}
foo()
减少数据的读取次数

提前缓存,减少层级查找

<div id='skip' class="skip"></div>

var oBox = document.getElementById('skip')
// 第一种
function hasEle(ele,cls) {
    return ele.className === cls
}
// 第二种
function hasEle1(ele,cls) {
    var clsname =ele.className		//快  消耗空间 
    return clsname === cls
}

console.log(hasEle(oBox,'skip'));
字面量与构造函数声明的测试
// new创建obj
let test = () => {
  let obj = new Object();	// 好比调用一个函数,消耗时间
  obj.name = "zce";
  obj.age = 38;
  obj.slogan = "我为前端而活";
  return obj;
};
// 字面量声明	 执行效率高使用
let test = () => {
  let obj = {
    name: "zce",
    age: 38,
    oslogan: "我为前端而活",
  };
  return obj;
};
console.log(test());

new String()调用函数,生成的是一个字符串对象,能直接调用对象原型上的方法 字面量生成的string,(不可以调用原型的方法的,而我们用字面量声明的,可以使用原型对象的方法,原因是在调用方法前,会在内部把字符串转成对象)是直接在堆区中开辟空间,往里放东西

减少循环体中的活动

循环体内的操作尽可能的往外提出来

减少声明及语句数
var oBox = document.getElementById('box')
// 方法一
var test =(ele) => {
    let w = ele.offsetWidth
    let h = ele.offsetHeight
    return  w * h
}
// 方法二
var test1 = (ele) => {
    return ele.offsetWidth * ele.offsetHeight
}
console.log(test(oBox))
// jsbench测试   方法二要快的
惰性函数与性能

惰性载入表示函数执行的分支只会在函数第一次掉用的时候执行,在第一次调用过程中,该函数会被覆盖为另一个按照合适方式执行的函数,这样任何对原函数的调用就不用再经过执行的分支了。 前端开发者进阶之惰性函数定义 惰性函数模式

function foo(){
            console.log(this)
        }

        var obtn = document.getElementById('btn')
        // 第一种
        function addEvent(obj,type,fn){
            if(obj.addEventListener){
                obj.addEventListener(type,fn,false)
            }else if(obj.attachEvent) {
                obj.attachEvent('on'+type,fn)
            }else {
                obj['on'+type] = fn
            }
        }
	// 第二种
        function addEvent(obj,type,fn){ // 惰性函数
            if(obj.addEventListener){
                addEvent = obj.addEventListener(type,fn,false)
            }else if(obj.attachEvent) {
                addEvent = obj.attachEvent('on'+type,fn)
            }else {
                addEvent = obj['on'+type] = fn
            }
            return addEvent
        }

        addEvent(obtn,'click',foo)
        
        //jsbench  第二种是慢的
采用事件委托

事件委托的本质就是利用事件冒泡的机制,把原本应该绑定在子元素上的事件绑定在了父元素身上,让父元素完成了事件的监听,算是委托给了父元素,好处是可以大量减少内存的占用,减少事件的注册。

案例就不用说了把,往上大量文章。

the end!啾咪!