JavaScript性能提升及代码优化

344 阅读12分钟

JavaScript性能提升及代码优化

随着互联网技术的不断发展,前端也从最开始的简单页面展示及交互变得越来越工程化。在此,前端项目的性能优化也变得越来越重要。如何提高项目性能以及用户体验也成为了一个热门话题。

内存管理

内存管理即是开发者主动申请内存空间、使用空间以及释放空间。在JavaScriptECMAScript并未提供相应的内存调控API,但是并不会妨碍我们去经历这样一个流程。

// 申请空间
var obj = {}

// 使用空间
obj.action = '学习'

// 释放空间
obj = null

垃圾回收

JavaScript中,内存分配都是自动的,每当定义一个对象、数组等都会给其分配相应的空间,当往后找不到变量对其有引用时便会将其视为垃圾,同时当一个对象无法从根上访问时也会将其视为垃圾,JavaScript引擎会将垃圾所占据的内存给释放掉,这样一个过程便被称为垃圾回收。

TIPS:

JavaScript中的可以被访问到的对象被称为可达对象,包括通过引用以及作用域链进行访问。可达的标准是从根出发是否能被找到,JavaScript中的根可以看作全局变量对象。

var o1 = { name: 'study' }

定义了一个namestudy的对象,此时该对象可以通过变量o1进行访问所以这个对象是可达的

var o2 = o1
o1 = null

这个时候又定义了变量o2再次引用了该对象,之后又将o1给设置为null。因为该对象还有o2这个变量对其有着引用,所以这个对象还是可达对象。

GC算法介绍

GC就是垃圾回收机制的简称,GC可找到内存中的垃圾同时释放回收空间。

GC里面的垃圾可分为以下几类:

  • 程序中不再需要使用的对象

    function fn() {
        name = 'study'
        return `${name} everyday`
    }
    fn()
    
  • 程序中不能再访问到的对象

    function fn() {
        const name = 'study'
        return `${name} everyday`
    }
    fn()
    

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

常见的GC算法包括以下几种:

  1. 引用计数:通过数字来判断一个对象是不是一个垃圾
  2. 标记清除:在工作的时候给对象添加上标记来判断对象是不是一个垃圾
  3. 标记整理:和标记清除类似,只不过会在回收时做一些额外的事情
  4. 分代回收:在下文介绍V8时会有详细介绍

引用计数算法

引用计数是最简单的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收

下面代码中,两个对象a、b被创建,一个作为另一个的属性被引用,另一个被分配给变量o

var o ={ a: {b:2}}

o2引用了o

var o2 = o

“这个对象”的原始引用o被o2替换了

o = 1

现在,“这个对象”有两个引用了,一个是o2,一个是oa

var oa = o2.a

最初的对象现在已经是零引用了,然而它的属性a的对象还在被oa引用,所以还不能回收

o2 = "yo"

a属性的那个对象现在也是零引用了,它可以被垃圾回收了

oa = null

引用计数算法优缺点

优点:

  • 发现垃圾时立即回收

  • 最大限度减少程序暂停

缺点:

  • 无法回收循环引用的对象
  • 时间开销大

标记清除算法

标记清除算法分为标记和清除两个阶段:

  • 标记阶段:遍历所有对象,并找到活动的可达对象对其进行标记
  • 清除阶段:再次遍历所有对象,将没有被标记的对象进行清除;同时将标记清除,以便GC下次正常工作。

通过这样两次遍历对垃圾进行回收,最终交给空闲链表进行维护。

标记清除算法优缺点

优点:

  • 可以解决对象循环引用的回收操作

缺点:

  • 某些情况下在垃圾回收之后,交给空闲链表的地址不连续,造成空间的碎片化

标记整理算法

标记整理算法可以看作为标记清除算法的增强版,只不过在清除阶段会先执行整理操作,将活动的可达对象的地址整理到一块,然后再进行清除操作。

V8垃圾回收策略

V8

V8是一款主流的JavaScript执行引擎,它被应用在谷歌浏览器以及node平台中,它拥有优秀的垃圾回收机制。除此之外,它还采用了即时编译:一般的JavaScript执行引擎都会先将js代码转译成字节码然后再执行,而V8则是将源码编译成机器码。

V8还对内存设有上限(在64位操作系统中上限为1.5G,在32位操作系统中上限位800M)

V8垃圾回收策略描述

对于V8来说,它采用了分代回收的思想,主要是将内存空间按照一定的规则分成两类:新生代存储区和老生代存储区,然后针对新老生代分别采取最高效的算法完成垃圾回收。

V8回收新生代对象

V8引擎会将内存一分为二,小空间部分会用于存储新生代对象(64位32M,32位26M),其中新生代对象是指存活时间较短的对象。

回收的过程采用复制算法+标记整理算法,新生代内存区划分为两个相同大小的空间,分别称为 使用空间(from)和空闲空间(to)。如果需要申请空间,首先会将所有变量分配至from空间,当from内存占满之后会触发GC进行标记整理后将活动对象拷贝至to。由于此时from内的活动对象在to中都有备份,所有可以将from的内存给清空,清空完成之后,置换form和to的状态。

在拷贝的过程中,如果有对象所使用的内存空间在老生代对象内也会出现,这时候就会将新生代对象移动至老生代进行存储(晋升)。晋升的触发时机:

  • 经过一轮GC后还存活的新生代需要晋升
  • 在拷贝过程中,to的空间使用率达到25%后,也会将这次的活动对象移动至老生代区

V8回收老生代对象

老生代对象是指活动时间较长的对象,老生代区的内存大小为1.4G(32位操作系统下为700M)。老生代对象一般是在全局环境下存放的变量、闭包中放置的变量数据。

V8回收老生代对象主要采用标记清除、标记整理以及标记增量的算法。它首先会用标记清除算法完成垃圾空间的释放和回收。当新生代区有要存入老生代区的数据且老生代区的空间不足以存放这些要移入的数据时,这个时候会触发标记整理,将标记清除后所产生的碎片空间进行整理回收,同时还会采用增量标记的方法进行效率优化。

新老生代垃圾回收对比

  • 新生代区垃圾回收更像是用空间去换时间。由于它采用的是一个复制算法,这就意味着每时每刻它的内部都会有一个空闲时间的存在,但是由于新生代区本身的空间就比较小,所以这样一个空间上的浪费相比于时间来说是微不足道的。
  • 老生代区垃圾回收不适合复制算法。老生代区的存储空间是比较大的,如果给其也像新生代那样一分为二,那么就会有几百兆的空间会被浪费;其次老生代区存储的对象还是比较多的,如果采用复制算法,那么所需要的时间也就比较多了。

Performance工具介绍

GC的目的是为了实现内存空间的良性循环,而通过Performance时刻关注才能确定程序是否合理。

Performance使用的步骤大致为:

  1. 打开浏览器输入目标地址

  2. 进入开发人员工具面板,选择性能

  3. 开启录制功能,访问具体页面

  4. 执行用户行为,一段时间后停止录制

  5. 分析记录中的内存信息

内存问题的外在表现形式

  • 页面出现延迟加载或经常性暂停
  • 页面出现持续性的糟糕的性能
  • 页面性能随时间延长越来越差

代码优化

Extra:

如需要更精准的测试JavaScript性能,可以使用jsperf

Jsperf的使用流程:

  1. 使用GitHub账号进行登录
  2. 填写个人信息(非必填)
  3. 填写详细的测试用例信息(title、slug)
  4. 填写准备代码
  5. 填写必要的setup和teardown
  6. 填写具体的测试代码片段

慎用全局变量

当我们在全局范围内定义完变量之后,它就存在于全局的上下文之中,这个上下文处于后续程序查找作用域链的最顶端。如果以这样的层级往上查找,那么所需要的耗时也是很高,降低了代码的执行效率。而且全局执行上下文一直存在于上下文执行栈,直到程序退出,这对GC也是非常不利。而且如果某个局部作用域定义了一个同名的变量,可能会遮蔽或者污染全局。

优化全局变量可如下:

// first
var i, str = ''
for(i = 0; i < 100; i++) {
    str += i
}

// optimization
for(let i = 0; i < 100; i++) {
    let str = ''
    str += i
}

缓存全局变量

在我们编写程序的时候,有些全局变量是无法避免的,比如在获取DOM节点的时候,document对象就是存于顶级节点中的。这个时候就可以将需要大量使用的全局变量放置某一个局部作用域之中。

// first 
function fn() {
   const btn1 = document.getElementById('btn1')
   const btn2 = document.getElementById('btn2')
   const btn3 = document.getElementById('btn3')
   const btn4 = document.getElementById('btn4')
   const btn5 = document.getElementById('btn5')
   const btn6 = document.getElementById('btn6')
   const btn7 = document.getElementById('btn7')
}

// optimization
function fn() {
   const doc = document
   const btn1 = doc.getElementById('btn1')
   const btn2 = doc.getElementById('btn2')
   const btn3 = doc.getElementById('btn3')
   const btn4 = doc.getElementById('btn4')
   const btn5 = doc.getElementById('btn5')
   const btn6 = doc.getElementById('btn6')
   const btn7 = doc.getElementById('btn7')
}

通过原型对象新增实例对象需要的方法

当某一个构造函数中有一个成员方法需要后续的实例对象频繁的使用,可以将该方法添加到原型对象上而不是放在构造函数内部。

// first 
function Fn() {
    this.foo = function () {
        console.log(1)
    }
}
const f = new Fn()

// optimization
function Fn() {
}
Fn.prototype.foo = function() {
    console.log(1)
}
const f = new Fn()

避开闭包陷阱

闭包详见:闭包

使用闭包时,很容易出现内存泄漏,所以不应该为了闭包而闭包。

// 闭包示例
function fn() {
    const el = document.getElementById('btn')
    el.onclick =  function() {
        console.log(el.id)
    }
}

fn()

// optimization
function fn() {
    const el = document.getElementById('btn')
    el.onclick = function() {
        console.log(el.id)
    }
    el = null
}

避免属性访问方法

在一般的面向对象语言中,类一般都会有封装过的方法将其内部属性暴露给外部进行增删改查的操作。但是这个在JavaScript中并不是那么的适用。因为js中类并不需要这种方法,所有属性都是在外部可见的。在使用属性访问方法的时候,只会增加一层定义,没有访问控制能力。所以并不推荐属性访问方法。

function Fn() {
    this.name = 'study'
    this.action = function() {
        return this.name
    }
}
const f = new Fn()
const action = f.action

// optimization
function Fn() {
    this.name = 'study'
}
const f = new Fn()
const action = f.name

for循环优化

for循环在平时编程中算是一个比较经常用到的操作,比如在遍历一个数组或者类数组的数据结构时,for可以很好的进行处理。因此for循环的优化也是很有必要的

// first
const lis = document.getElementsByClassName('li')
for(var i = 0; i < lis.length; i++) {
    console.log(lis[i])
}

// optimization
const lis = document.getElementsByClassName('li')
for(var i = 0, len = lis.length; i < len; i++) {
    console.log(lis[i])
}

当我们只需要遍历数组而不需要其他额外操作时,forforEachfor...of三种方法里,forEach为最优。

DOM节点操作优化

在进行网页开发时,DOM操作是无法避免的,而DOM操作也是比较耗时的,节点的添加必然会引起回流重绘,这两者都提升性能的消耗。所以,对于将很多元素一个一个插入dom中时可以先创建一个文档碎片(DocumentFragment),将所有元素加入文档碎片中再将文档碎片插入dom中。

// first
for(var i = 0; i < 10; i++) {
    var p = document.createElement('p')
    document.body.appendChild(op)
}

// optimization
const frag = document.createDocumentFragment()
for(var i = 0; i < 10; i++) {
    var p = document.createElement('p')
    frag.appendChild(op)
}
document.body.appendChild(frag)

当要添加一些有已经存在的并且高度类似的节点时,可以通过克隆然后再将其插入dom

// first
for(var i = 0; i < 10; i++) {
    var p = document.createElement('p')
    document.body.appendChild(op)
}

// optimization 
const old = document.getElementById('p')
for(var i = 0; i < 10; i++) {
    const newP = old.cloneNode(false)
    document.body.appendChild(newP)
}

直接量替换new操作符

// bad
const arr1 = new Array(3)
arr1[0] = 1
arr1[1] = 2
arr1[2] = 3

// better
const arr2 = [1, 2, 3]

其他代码优化的方法

  • 减少判断层级
  • 减少作用域链查找层级
  • 减少数据读取次数
  • 减少循环体中活动
  • 减少声明及语句数
  • 采用事件委托