【学习笔记】JavaScript 性能优化

193 阅读8分钟

1. JavaScript 内存管理

  • 内存:由可读写的单元组成,表示一片可操作空间
  • 管理:人为操作一片空间申请、使用、释放
  • 内存管理:开发者主动申请空间、使用空间、释放空间
  • 管理流程:申请——使用——释放
    • 申请:遇到变量,自动分配空间 let obj = {}
    • 使用:obj.name = 'zhangsan'
    • 释放:obj = null

2. JavaScript 垃圾回收

什么是垃圾

  • 对象不再被引用时是垃圾
  • 对象不能从根上访问时是垃圾

JavaScript的根:全局变量对象
可达对象:能访问到的对象(引用、作用域链)
可达标准:从根出发是否能被找到

3. GC 算法

  • GC:垃圾回收机制的简写。
    GC 可找到内存中的垃圾,并释放&回收空间,方便后续代码即迅速使用

  • GC 里的垃圾

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

      function func() {
        name = 'lg'
        return name
      }
      func()
      
    • 程序中不能再访问到的对象

      function func() {
        const name ='lg'
        return name
      }
      func()
      
  • 常见 GC 算法

    • 引用计数:通过数字判断当前是否是垃圾
    • 标记清除:通过标记为活动的对象标记是否是垃圾
    • 标记整理:类似标记清楚,在清除阶段要先整理再清除
    • 分代回收:V8 引擎将内存分为新生代区及老生代区

3.1 引用计数算法

核心:在内部通过引用计数器,维护当前对象的引用数,判断当前引用数是否为0

  • 对象引用关系发生改变,引用计数器会主动修改当前对象对应的引用数字。
  • 引用数字为0立即回收
const user1 = { age: 11 }
const user2 = { age: 22 }
const user3 = { age: 33 }

const nameList = [ user1.age, user2.age, user3.age ]

// function fn () {
//   num1 = 1
//   num2 = 2
// }

function fn () {
  const num1 = 1
  const num2 = 2
}
fn()

分析:

  • 全局 window 下可以获取到 user1、user2、user3,fn 里由于 num1 和 num2 没有设置声明关键字,因此也在 window 下。当给 fn 中的两个变量加上关键字后,两个变量的作用域则在 fn 内,一旦 fn 调用结束后,全局不再能找到这两个变量,他们身上的引用计数变为0,GC 开始进行垃圾回收。
  • nameList 内部有对 user1、user2、user3 的引用,及时执行完成,也不会被当做垃圾回收

优点:

  • 发现垃圾立即回收
  • 最大限度减少程序暂停,减少程序卡顿时间:当前执行平台内存有上限,此方法会时刻监听,内存爆满时会立即找到计数为0的对象进行回收,释放空间

缺点:

  • 无法回收循环引用的对象

  • 造成内存浪费

    function fn1 () {
      const obj1 = {}
      const obj2 = {}
    
      // 对象之间互相指引,即使在方法调用之后,引用计数也依然无法将其置为0
      obj1.name = obj2
      obj2.name = obj1
    
      return 'test code'
    }
    fn1()
    
  • 时间开销大:需要维护数值变化,时刻监控数值是否修改

3.2 标记清除算法

核心:将垃圾回收操作分为标记和清除两个阶段

  • 阶段1:遍历所有对象,找到活动对象(可达对象)进行标记
  • 阶段2:遍历所有对象,找到没有标记的对象清除掉;也会将阶段1中设置的标记抹除,便于 GC 下一次的操作
  • 通过两次遍历,回收相应空间

找到所有可达对象,有递归则引用查找,将其标记。找到未标记的对象以及之前的标记,清除。把回收的空间放到【空闲链表】

优点:

  • 相对标记清除,解决对象循环引用的无法回收的问题;不标记函数内部的变量,因为在外部不可找到

缺点:

  • 空间碎片化:由于当前回收的垃圾对象在地址上不连续,回收后分散在各个地方,若后续从空闲链表中开辟区域,会有空间不够的情况
  • 不会立即回收垃圾对象

3.3 标记整理算法

标记整理可看做是标记清除的增强:标记阶段同标记清除;清除阶段会先执行整理,移动对象的位置,让他们在地址上产生连续

优点:

  • 减少碎片化空间

缺点:

  • 不会立即回收垃圾对象

4. V8

V8 是一款主流的 JavaScript 执行引擎。
V8 采用及时编译,将字节码转为机器码。
V8 内存有上限:64位 不超过1.5G,32位 不超过800M

4.1 垃圾回收策略

采用分代回收的思想;内存分为新生代、老生代存储区;针对不同对象采用不同算法

4.2 内存分配

v8内存分配

  • 新生代内存空间一分为二
  • 小空间存储新生代对象(32M (64位)| 16M(32位))
  • 新生代:存活时间较短的对象

4.3 回收新生代对象

  • 回收过程采用复制算法 + 标记整理
  • 新生代内存区分为两个等大的空间
  • 使用空间为 From——存储活动对象,空闲空间为 To
  • 标记整理后将活动对象拷贝至 To
  • From 空间的对象有了备份,From 与 To 交换空间,完成释放

回收细节:

  • 若在新生代区拷贝的对象在老生代区也存在,则会发生晋升
  • 晋升就是将新生代对象移动至老生代
    • 一轮 GC 后,还有存活的新生代,则需要晋升
    • To 空间的使用率若超过 25%,也需要晋升

4.4 回收老生代对象

  • 老生代对象存放在右侧老生代区
  • 空间大小:1.4G(64位),700M(32位)
  • 老生代对象:存活时间较长的对象(全局变量、闭包等)

回收实现:

  • 回收过程采用标记清除、标记整理、增量标记算法
  • 首先使用标记清除完成垃圾空间的回收
  • 把新生代移动至老生代,但是会发生晋升问题,采用标记整理进行空间优化
  • 采用增量标记进行效率优化
新生代老生代
回收使用空间换时间不适合复制算法

增量标记如何优化垃圾回收:
回收过程会阻塞 JavaScript 执行,可将垃圾回收与程序执行交替执行,将回收拆分成小段。

5. Performance

  • GC 的目的为了实现内存空间的良性循环
  • 良性循环的基石是使用合理,时刻关注才能确定是否合理
  • Performance 提供多种监控方式

5.1 内存问题的表现

  • 内存泄露:会导致程序的使用性能随着时间增长而越来越差
  • 内存膨胀:一个应用在绝大多数的设备上运行时都表现出很糟糕的性能
  • 频繁的垃圾回收:一个应用在运行中存在频繁的卡顿

5.2 监控内存的方式

  • 浏览器任务管理器
  • Timeline 时序图记录
  • 堆快照查找 分离Dom
  • 判断是否存在频繁的垃圾回收

6. 代码优化

可使用以下网站进行测试

6.1 慎用全局变量

Q:为什么要慎用?

A: 全局变量定义在全局执行上下文,是所有作用域链的顶端

​ 全局执行上下文一直存在于上下文执行栈,直到程序退出

​ 在局部作用于定义同名变量,则会污染或遮蔽全局数据

// 使用全局变量
var i, str = ''
for (i = 0; i< 1000; i++) {
  str += i
}


// 使用局部变量
for (let i = 0; i< 1000; i++) {
  let str = ''
  str += i
}

6.2 缓存全局变量

将无法避免的全局变量缓存到局部作用域

6.3 通过原型新增方法

在原型对象上新增实例对象需要的方法

var fn1 = function () {
  this.foo = function () {
    console.log(111)
  }
}
const f1 = new fn1()


// 原型链新增方法
var fn2 = function () {}
fn2.prototype.foo = function () {
  console.log(222)
}
const f2 = new fn2()

6.4 避开闭包陷阱

闭包特点:

  • 外部具有指向内部的引用
  • 在”外“部作用域访问”内“部作用于的数据
  • 使用不当的话很容易出现内存泄漏

测试

<button id='btn'></button>
// 会出现内存泄漏
function foo() {
  // el 是页面本身存在的一个元素,只是存储了一下而已,元素有 onclick 事件,是引用了foo函数内部的 function
  var el = document.getElementById('btn')
  // 调用时处在一个新的作用域,不是 foo 函数的作用域,这个函数是用户点击时才调用的,处于独立的作用域中,与 foo 不在同一作用域
  // 在onclick函数内用到了 foo 函数的 el,存在跨作用域用数据的现象
  el.onclick = function () {
    console.log(el.id)
  }
}
// 优化
function foo1() {
  // btn 本身存在于 DOM 中,不论是否引用都存在,这里相当于又对他有一个引用,理解为 btn 元素被引用了两次
  // 若未来 btn 元素被删除,此时代码中的引用还在,GC 不会回收
  var el = document.getElementById('btn')
  el.onclick = function () {
    console.log(el.id)
  }
  // 可将元素置为 null
  el = null
}

6.5 避免属性访问方法的使用

  • JS 不需要属性的访问方法,所有属性外部可见
  • 使用属性访问方法只会增加一层重定义,没有访问的控制力
// 提供成员属性的访问方法
function Person() {
  this.name = 'aa'
  this.age = 11
  this.getAage = function () {
    return this.age
  }
}
const p1 = new Person()
const a = p1.getAage()


// 直接让成员属性访问
function Person2() {
  this.name = 'aa'
  this.age = 11
}
const p2 = new Person2()
const b = p2.age

6.6 For 循环优化

var btns = document.getElementsByClassName('btn')
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)
}

6.7 采用最优循环方式

const arrList = [1, 2, 3, 4, 5]

// 最优
arrList.forEach(item => {
  console.log(item)
})

// 次优
for (let i = arrList.length; i; i--) {
  console.log(arrList[i])
}

// 较差
for (let i in arrList) {
  console.log(arrList[i])
}

6.8 文档碎片优化节点添加

节点的添加操作必然引起回流 & 重绘——耗性能

for (let i = 0; i < 10; i++) {
  let p = document.createElement('p')
  p.innerHTML = i
  document.body.appendChild(p)
}


// 优化 - 相比上一种形式略快
// createDocumentFragment:创建虚拟的节点对象
const fragEle = document.createDocumentFragment()
for (let i = 0; i < 10; i++) {
  let p = document.createElement('p')
  p.innerHTML = i
  fragEle.appendChild(p)
}
document.body.appendChild(fragEle)

6.9 克隆优化节点操作

新增节点时,把与它类似的节点克隆一下,再把克隆之后的节点添加

<p id='oldP'>oldP</p>
for (let i = 0; i < 3; i++) {
  let p = document.createElement('p')
  p.innerHTML = i
  document.body.appendChild(p)
}

// 略快
let oldP = document.getElementById('oldP')
for (let i = 0; i < 3; i++) {
  // 该方法将复制并返回调用它的节点的副本。如果传递给它的参数是 true,它还将递归复制当前节点的所有子孙节点。否则,它只复制当前节点
  let p = oldP.cloneNode(false)
  p.innerHTML = i
  document.body.appendChild(p)
}

6.10 直接量替换 new Object

let arr2 = new Array(3)
arr2[0] = 1
arr2[1] = 2
arr2[2] = 3

// 使用字面量-略快
let arr = [1, 2, 3]