阅读 106

学习笔记-JavaScript性能优化

内存管理

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

JavaScript中的内存管理:JavaScript中没有专门的API来操作内存空间,那么它是怎么申请使用并且释放的呢?

image.png

垃圾回收

Javascript中的垃圾:

  • JavaScript中内存管理是自动的
  • 对象不再被引用时是垃圾
  • 对象不能从根上访问到时是垃圾

JavaScript执行引擎把垃圾占据的对象空间进行回收,这个过程就是垃圾回收

JavaScript中的可达对象:

  • 可以访问到的对象就是可达对象(引用、作用域链)
  • 可达的标准就是从根出发是否能够被找到
  • JavaScript中的根就可以理解为是全局变量对象

GC算法

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

常见的GC算法:

  • 引用计数
  • 标记清楚
  • 标记整理
  • 分代回收

引用计数

实现原理:在内部通过一个引用计数器来维护当前对象的引用数,判断当前引用数是否为0来决定它是不是一个垃圾对象。如果是,执行引擎就将它回收并释放。

优点

  • 发现垃圾时立即回收
  • 最大限度减少程序暂停

缺点

  • 无法回收循环引用的对象
  • 资源消耗较大

标记清除

实现原理:分标记和清除两个阶段。第一个阶段:遍历所有对象找到活动对象进行标记;第二阶段:遍历所有对象把没有标记的对象进行清除(在这个阶段会把第一阶段的标记给抹掉,便于GC下次正常工作)。

优点

  • 可以回收循环引用的对象

缺点

  • 容易产生碎片化空间,浪费空间
  • 不会立即回收垃圾对象

标记整理

  • 标记整理可以看作是标记清除的增强
  • 标记阶段的操作和标记清除一致
  • 清除阶段会先执行整理,移动对象位置

优点

  • 减少碎片化空间

缺点

  • 不会立即回收垃圾对象

V8

  • V8 是一款主流的 JavaScript 执行引擎
  • V8 采用即时编译
  • V8 内存设限

V8 垃圾回收策略

V8 采用分代回收的思想,将内存空间一分为二,并针对不同对象采用不同算法。

  • 左侧小空间为新生代,用于存储新生代对象,在64位系统中占32M,32位系统中占16M,新生代指的是存活时间较短的对象。
  • 右侧大空间为老生代,用于存储老生代对象,在64位操作系统中占1.4G,32位操作系统中占700M。

image.png

V8常用的GC算法:

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

回收新生代对象

新生代对象指的是存活时间较短的对象

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

回收细节说明:

  • 拷贝过程中可能出现晋升
  • 晋升就是将新生代对象移动至老生代
  • 一轮GC还存活的新生代需要晋升
  • To 空间的使用率超过25% 需要晋升

回收老生代对象

老生代对象就是指存活时间较长的对象:全局变量,闭包中放置的变量数据。

  • 主要采用标记清除、标记整理、增量标记算法
  • 首先使用标记清除完成垃圾空间的回收
  • 当新生代对象向老生代移动时,采用标记整理进行空间优化
  • 最后采用增量标记进行效率优化

增量标记

image.png

当垃圾回收的时候,是会阻塞 JavaScript 代码的执行,所以会有空档期。所谓的增量标记就是将一整段的垃圾回收操作拆分成多个小步,组合着完成整个回收,从而是程序执行和垃圾回收交替进行,这样带来的时间消耗更加合理。

老生代 vs. 新生代

  • 新生代区域垃圾回收使用空间换时间:采用的复制算法就意味着有空闲空间的存在,但是由于新生代存储区域本身空间就很小,分出来的空间就更小,这一部分空间浪费对于时间上的提升是微不足道的。
  • 老生代区域垃圾回收不适合复制算法:老生代空间区域比较大,如果采用一分为二,会有几百兆的空间是浪费不用的,这样太奢侈了。而且老生代存储的数据比较多,复制的话会占用很多时间。

Performance 工具介绍

为什么使用Performance?

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

使用步骤:

  • 打开浏览器输入目标网址
  • 进入开发人员工具面板,选择性能
  • 开启录制功能,访问具体界面
  • 执行用户行为,一段时间后停止录制
  • 分享界面中记录的内存信息

监控内存的几种方式

内存问题的外在表现:

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

界定内存问题的标准:

  • 内存泄漏:内存使用持续升高
  • 内存膨胀:在多数设备上都存在性能问题
  • 频繁垃圾回收:通过内存变化图进行分析

监控内存的几种方式:

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

任务管理器监控内存

shift + esc 调出任务管理器,操作页面后观察任务管理器的内存占用空间(DOM 节点所占用的内存)和JavaScript占用内存。

image.png

如果内存一直增长就是有问题的,但是任务管理器无法定位问题。

Timeline 记录内存

image.png

在性能选项录制停止后,可以在这里查看内存走势。

堆快照查找分离 DOM

什么是分离DOM? 界面元素都是DOM节点,存活在DOM树上,DOM 节点存在几种形态:

  • 垃圾对象:节点从当前DOM树上脱离,js中也没有引用。
  • 分离DOM:从DOM树上脱离但是在js中还有引用。

分离DOM在界面上是看不见的,但是在内存里占据了空间,这就是一种内存泄漏。可以通过堆快照的功能找到这些分离DOM。

image.png

image.png

判断是否存在频繁GC

确定频繁的垃圾回收:

  • Timeline 中频繁的上升下降
  • 任务管理器中数据频繁的增加减小

代码优化

如何精准测试 JavaScript 性能?

  • 本质上就是采集大量的执行样本进行数学统计和分析
  • 使用基于 Benchmark.js 的 jsbench.me/ 完成

慎用全局变量

为什么要慎用?

  • 全局变量定义在全局执行上下文,是所有作用域链的最顶端
  • 全局执行上下文一直存在于上下文执行栈,知道程序退出
  • 如果某个局部作用域出现了同名变量则会遮蔽或污染全局

eg:

// 代码 1
var i, str = ''
for (i = 0; i < 1000; i++) {
  str += i
}

// 代码 2
for (let i = 0; i < 1000; i++) {
  let str = ''
  str += i
}
复制代码

将这段代码进行测试。

image.png

可以发现使用局部变量性能要好很多。

缓存全局变量

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

image.png

通过原型新增方法

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

image.png

避开闭包陷阱

闭包的特点:

  • 外部具有指向内部的引用
  • 在“外”部作用域访问“内”部作用域的数据
// 闭包
function foo() {
  var name = 'lg'
  function fn() {
    console.log(name)
  }
  return fn
}
var a = foo()
a()
复制代码

我们根据一个例子来解释闭包陷阱

<button id="btn">Add</button>

<script>
  // 代码 1
  function foo() {
    var el = document.getElementById('btn')
    el.onclick = function () {
      console.log(el.id)
    }
  }
  foo()

  // 代码 2
  function foo() {
    var el = document.getElementById('btn')
    el.onclick = function () {
      console.log(el.id)
    }
    el = null
  }
  foo()
</script>
复制代码

上面例子中,foo 函数中将 document.getElementById('btn') 的引用给了 el,但是这个 btn 元素,本身就有一个dom元素的引用,在这里赋值,就变成了两个引用。在引用计数法中,如果dom元素被删除,这个dom元素的引用减一,但是这个dom元素还存在一个引用,这个内存就会无法释放。所以可以通过将 el 置为 null 的方法,手动释放这个内存,以防大量的这种错误使用造成内存泄漏。

避免属性访问方法使用

JavaScript中的面向对象

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

image.png

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>
<p class="btn">add</p>
<script>
  var aBtns = document.getElementsByClassName('btn')

  for (var i = 0; i < aBtns.length; i++) {
    console.log(i)
  }

  for (var i = 0, len = aBtns.length; i < len; i++) {
    console.log(i)
  }
</script>
复制代码

上面两个for循环,一个将直接用 i < aBtns.length, 一个将 aBtns.length 存到变量 len, 再用 i<len,将两者进行对比,可以看出,将长度先存到变量再进行比较性能要好一些。

image.png

采用最优循环方式

image.png

image.png

image.png

image.png

image.png

image.png

image.png

可以看出如果只是简单的遍历,while在数组大小小于5000时是最快的,但是大于5000就特别慢;而for永远是最慢的;for...in在数组越大它执行的越快;forEach则比较稳定,相对速度都比较快。 数组较小时:while > forEach > for...in > for 数组较大时:for...in > forEach > while > for 使用 forEach 的性能最好,其次是 for...in,最后才是 for 循环。

文档碎片 优化节点添加

节点的添加操作必然会有回流和重绘,而这两个对性能的消耗是非常大的。那我们怎么进行一个最优操作呢?

// 使用普通方法创建节点 p, 将 p 节点加到文档末尾
for (var i = 0; i < 10; i++) {
  var oP = document.createElement('p')
  oP.innerHTML = i
  document.body.appendChild(oP)
}

// 创建一个文档碎片容器,将 p 节点放入文档碎片容器的末尾
// 最后再将文档最偏容器放入 body 的末尾
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)
复制代码

将两段代码进行性能对比后发现,通过“文档碎片容器”的方式添加节点性能更好。

image.png

克隆 优化节点操作

<!-- 页面中有一个 p 标签,要创建一个和它一样的标签 -->
<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)
  }

  // 使用克隆 p 标签的方式
  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>
复制代码

image.png

减少判断层级

function doSomeThing(part, chapter) {
  const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
  if (part) {
    if (parts.includes(part)) {
      console.log('属于当前课程')
      if (chapter > 5) {
        console.log('您需要提供 VIP 身份')
      }
    }
  } else {
    console.log('请确认模块信息')
  }
}

function doSomeThing(part, chapter) {
  const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
  if (!part) {
    console.log('请确认模块信息')
    return
  }
  if (!parts.includes(part)) return
  console.log('属于当前课程')
  if (chapter > 5) {
    console.log('您需要提供 VIP 身份')
  }
}
复制代码

以上两种方式,第一种if嵌套很多,第二种减少了嵌套层级,根据结果可以看出性能更好。

image.png

减少作用域链查找层级

var name = 'aaa'
function foo() {
  name = 'bbb'  // 这里的name是属于全局的
  function baz() {
    var age = 38
    console.log(age)
    console.log(name)
  }
  baz()
}
foo()


var name = 'aaa'
function foo() {
  var name = 'bbb'
  function baz() {
    var age = 38
    console.log(age)
    console.log(name)
  }
  baz()
}
foo()
复制代码

image.png

可以看出,变量在作用域中的位置离的越近,查找的越快。不过这只是从时间上考虑的,如果从空间上考虑,第一种方法的name只占用了一个内存空间,而第二种两个name占了两个空间,占用内存相对较大。具体项目中要根据需求判断考虑空间还是时间。

减少数据读取次数

<div id="skip" class="skip"></div>
<script>
  var oBox = document.getElementById('skip')
  // function hasEle(ele, cls) {
  //   return ele.className == cls
  // }

  function hasEle(ele, cls) {
    var clsName = ele.className
    return clsName == cls
  }
  console.log(hasEle(oBox, 'skip')) // true
</script>
复制代码

使用 var clsName = ele.className 可以减少 className 的读取次数,提高性能。

字面量与构造式

image.png

image.png

image.png

可以看出,直接通过字面量定义的方式,比通过 new 的方式要快很多,尤其是在普通数据类型特别明显,Object 类型虽然相差不多,但也是字面量的方式要快的,因为 new 的方式实际上是去调用了函数。字面量的方式除了快,它书写也是很方便的。

减少生命及语句数

对于后续不需频繁使用的数据建议使用时获取,而不做缓存,减少对内存的消耗。

采用事件委托

文章分类
前端
文章标签