篇四:大前端基础之JavaScript性能优化笔记

634 阅读18分钟

文章输出主要来源:拉勾大前端高新训练营(链接)。小哥哥小姐姐请不要嫌弃啰嗦,下面肯定都是干货。

1 JavaScript性能优化

内存管理:

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

2 垃圾回收

JavaScript中的内存管理机制是自动的,因此垃圾回收也是自动进行的。

JavaScript中的垃圾:

  • js中定义的对象不再被引用时就是垃圾
  • js中定义的对象占用了内存空间,但是却无法从跟上访问到这个对象,它也是垃圾

对于js中产生的垃圾,js会自动释放他们占用的内存空间,这个过程就是垃圾回收。

可达对象: js中从出发能够访问到的对象就是可达对象

**js中的根:**全局上变量对象

3 GC算法(Gabbage Collection)

3.1 简介

垃圾:

  • 程序中不再使用的对象
  • 程序中不能再访问到的对象

GC算法:

垃圾回收是一种查找垃圾、释放空间、回收空间的机制,GC算法就是查找和回收时遵循的规则。

常见GC算法:

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

3.2 引用计数算法

原理

核心思想:通过给对象空间设置引用数,当引用关系发生改变时,相应地修改引用数,判断引用数是否为0,为0时候对对象空间进行回收。

核心点:

  • 引用计数器(判断是否要回收的标志)
  • 引用关系改变时会修改引用计数器(增加或减少)
  • 回收时机:引用计数器为0立即回收

优点:

  • 引用计数算法在发现引用计数器为0的时候回立即进行垃圾回收,可以做到发现垃圾及时回收
  • 通过及时回收垃圾,可以最大限度减清内存的被垃圾沾满的情况,减少程序暂停

缺点:

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

    function fn() {
      const obj1 = {};
      const obj2 = {};
    
      obj1.name = obj2;
      obj2.name = obj1;
    
      return 'hello world';
    }
    
    fn(); //运行过后, obj1, obj2都无法从根开始被找到,但是由于互相引用,引用计数器不为0,因此其占用的内存空间无法被有效释放
    
  • 引用计数法由于维护一组引用计数器,需要时刻去监听引用状态的变化,产生的时间开销比较大

3.3 标记清除算法

原理

核心思想:通过两次遍历所有的对象,第一次遍历所有的对象找到活动的对象并进行标记,第二遍遍历所有的对象,清除没有标记的对象,同时将被标记过的对象的标记去掉,以此实现回收相应的空间。回收空间之后,会将回收的空间放入空闲列表中,方便后续的程序申请使用空间。

核心点:两次遍历

  • 第一次遍历,标记活动对象
  • 第二次遍历,清除非活动的对象,将第一次遍历标记对象标记去除。

**优点:**解决引用计数器中循环引用对象不能回收的问题。

缺点:

  • 由于每次遍历回收的对象空间大小可能不连续,将其放入空闲列表中后,不一定刚好满足后续程序申请空间的大小,会造成比较多的碎片空间无法使用,无法最大化使用空闲空间,造成空间浪费。
  • 不会立即回收垃圾对象

3.4 标记整理算法

标记整理算法是标记清除算法的增强操作

原理

核心思想:通过两次遍历所有对象,第一次遍历所有对象找到活动的对象并进行标记(与标记清除法第一遍遍历操作相同)。第二次遍历与标记清除法不同的是,需要先进行一次整理,移动对象的位置,使其占用的内存空间连续,然后对非活动对象进行清除。

核心点:找到活动对象后会对活动对象的空间进行整理,将其整理为在连续空间中的对象

**优点:**相比标记清除发,回收的空间是连续的,解决了回收空间碎片化的问题

缺点:

  • 无法立即回收垃圾对象
  • 整理对象空间也会有时间消耗

4 V8 引擎

4.1 简介

简介:主流的JavaScript执行引擎,可以高效运行JavaScript代码。

优势:

  • 采用即时编译,在执行过程中直接编译为可执行的机器码,不需要进行编译为字节码的操作,速度非常块
  • V8的内存设有上限,在64位的操作系统,内存上限不超过1.5G,对于32位操作系统,内存不超过800M。内存设置上限的好处:
    1. V8主要针对浏览器设计的,对于网页应用,现有内存大小已足够使用
    2. V8内部的垃圾回收机制决定当前设置的内存上限比较合理。当垃圾内存达到1.5G时,V8采用增量标记算法回收内存空间只需50ms,采用非增量标记需要1s。

4.2 V8垃圾回收策略

回收思想:采用分代回收的思想,将内存空间按照一定规则分为新生代与老生代两个类型的内存区,针对不同代采用不同的算法,达到高效的垃圾回收。

V8中常用的GC算法:

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

4.3 V8中回收新生代对象

  • V8内部将空间分为两部分,分别为用于存储新生代对象与老生代对象
  • 较小的空间用于存储新生代对象,64位机器为32M,32位机器为16M
  • 新生代对象指的是存活时间较短的对象
    • 例:局部作用域对象在运行完相关代码后不再需要使用,就需要进行回收,全局作用域定义的对象可能需要等待程序执行完后再进行释放,相比来说前者存活时间较短,属于新生代对象

新生代对象回收机制

  • 新生代回收策略为:复制算法 + 标记整理算法
  • 新生代内存区也会被分为两部分等大大的空间(From 空间和 To空间)
  • From空间使用空间To空间空闲空间
  • 程序运行过程中,会将活动对象存储在From空间
  • From空间应用到一定程度后,将会触发GC操作
  • 此时的GC算法为标记整理法,对From空间中的活动对象进行标记并整理到连续的空间,然后将活动对象拷贝至To空间
  • 此时将From空间进行完全释放,其中包括活动对象(在To空间有一份拷贝)与非活动对象;然后将To空间From空间进行交换(From变为To,To变为From),完成释放

回收细节

  • 在将From空间活动对象拷贝至To空间的过程中可能会出现晋升现象
  • 晋升就是将新生代对象移动至老生代进行存储

晋升的的条件(两个判断标准)

  • 在一轮GC后仍然存活的新生代对象需要晋升至老生代
  • 如果在拷贝过程中(将From空间活动对象拷贝至To空间)如果To空间的使用率超过25%,也需要将活动的对象全都移至老生代
    • 原因: 在标记整理并释放完原本的From空间后,需要将FromTo空间进行交换,To空间将会变为From空间,假如没有任何限制,其空间在变为From空间后,剩余的空间要是太小的话可能就不足以放入新的活动对象,因此设置限制。

4.4 V8回收老生代对象

老生代对象说明

  • 老生代区域在64为操作系统中上限1.4G,32位操作系统上限700M
  • 老生代对象指的是存活时间较长的对象

老生代对象回收机制

  • 老生代对象主要采用标记清除、标记整理、增量标记算法进行回收
  • 首先使用标记清除完成垃圾空间的回收:虽然会有碎片化问题,但是速度提升比较明显
  • 采用标记整理进行空间优化,时机:当将新生代的对象移至老生代,但老生代由于碎片化导致无法存入移动的对象,此时会采用标记整理进行空间优化
  • 最后会采用增量标记的方式进行回收效率提升

新生代 vs 老生代 细节处理

  1. 新生代对象中采用了空间复制算法,以空间换时间,新生代区域占用内存少,且被一分为二,使用空间复制算法可以极大提高效率,同时不会造成很大的空间浪费。
  2. 老生代区域内存空间较大,采用与新生代同样的方式进行回收会造成比较大的空间浪费,在较大的空间中进行空间复制也会带来更多的时间消耗,因此不适合采用空间复制算法。

4.5 标记增量优化垃圾回收

垃圾回收会阻塞JavaScript代码的执行,无法同时运行。一般在程序执行完毕后,才会进行垃圾的回收。

标记增量会将一整段的垃圾回收操作拆分为多个小部分进行标记,然后进行清除,组合着完成整个垃圾回收,以替代之前先运行程序,再一口气进行垃圾回收的操作。V8中如果不使用增量标记方法,清除1.5G空间仅需1s,将其拆分为多个间隔在v8中是非常合理的,占用的时间也会很少。

5 浏览器性能工具

5.1 Performance工具介绍

performance工具可以在程序运行过程中对内存变化进行实时的监控,有利于开发者根据内存变化去定位问题所在的代码块。

界面如下:

基本的使用方式为点击录制,然后进行页面的访问,之后点击停止,就可以查看到相应的内存变化的记录以及一些图表,以此进行性能分析。

如下图示:

5.2 内存问题的外在表现

  • 在网络正常的情况下,页面经常出现延迟或暂停的状态,可能会跟内存频繁进行GC有关,可以通过performance工具进一步查看内存使用情况,确定是否与内存有关
  • 页面持续性出现糟糕的性能,在网络正常的情况下,可能会是由于内存膨胀(当前页面为了达到最优的效果而去申请内存空间,且超过了设备本身能够提供的内存大小)导致的
  • 页面性能随着时间的延长变得越来越差,可能会因为存在内存泄漏的问题

5.3 监控内存的几种方式

界定内存问题的标准:

  • **内存泄漏:**内存使用持续升高
  • **内存膨胀:**在多数设备上都可能存在性能问题,需在多种常用设备进行测试,界定是内存原因还是设备原因
  • **频繁垃圾回收:**通过内存变化图进行分析

监控明内存的几种方式:

  • 浏览器任务管理器
  • Timeline时序图记录
  • 利用堆快照查找分离DOM(分离DOM也是一种内存泄漏)
  • 借助工具获取内存占用的走势图,对各时间段内存占用进行分析以判断是否存在频繁的垃圾回收

5.4 任务管理器监控内存

基本使用:在chrome中,通过点击更多工具>任务管理器可以打开浏览器任务管理器

在任务管理器中,右键点击红色框一行,可以添加不同的列,选中JavaScript使用内存,将其添加进监控面板

使用分析

  • 内存占用空间表示的是DOM节点所占用的内存空间,如果数值不断增大,则说明页面中在持续创建新的DOM
  • JavaScript使用的内存表示的是js的堆,在()中的值表示js中可达对象所占用的内存,如果该值持续增加,则表示js在不断创建新对象或已有对象大小一直在增长,程序可能会存在一些问题。

5.4 Timeline记录内存

通过performance中的时序图可以对内存的使用进行记录,通过内存占用的走势图可以对程序性能做一定分析,在出现问题的时间节点还可以定位到相应的页面操作,从而定位问题产生的范围。

5.5 堆快照查找分离DOM

工作原理: 找到js堆,对其进行照片留存,方便开发者对其分析

分离DOM:

  • 浏览器界面中元素都是存活在DOM树上的节点,DOM元素的状态不仅有存活的节点,还有垃圾对象的DOM节点以及分离状态的DOM节点
  • 垃圾对象:DOM元素从dom树上脱离,且js代码中没有地方去引用它
  • 分离状态的DOM:DOM元素从dom树上脱离,但是在js代码中仍然有地方在引用它,在内存中仍然占用着内存空间。

拍摄堆快照:

在chrome开发者工具中Memory选项下可以进行快照的拍摄

image-20200826010521604

通过点击快照,搜索detached可以搜索到分离状态的dom,分离状态的DOM不会再浏览器中展示,但是却会占用空间,也是一种内存的泄漏,通过对快照查找分离DOM,并确定其位置,可以对相关代码进行合适的优化。

image-20200826010812896

5.6 判断是否存在频繁GC

确定频繁GC的原因:

  • GC工作时会阻塞程序的运行
  • 频繁且过长的GC会导致应用程序进入假死状态
  • 使用户在使用过程中感知到明显的卡顿现象

确定方法:

  • 在timeline中内存走势图如果频繁上升下降,可能存在频繁的GC
  • 在浏览器任务管理器中,JavaScript内存实际占用空间频繁增加和减小,也可能存在频繁GC

6 代码层面优化

6.1 jsbench工具介绍

对于测试JavaScript性能,本质上市通过采集大量执行样本进行数学统计和分析。

jsbench:jsbench.me/是一个基于benchmark.js的工具,可以帮助我们进行上述的统计和分析。

6.2 慎用全局变量

原因

  • 查找消耗大: 全局变量定义在全局执行上下文,在所有作用域的顶端,当程序中局部访问到同名变量时,如果局部作用域未定义该变量,则会向上查找,查找消耗大,会降低程序的执行效率
  • **长时间占用内存,在程序结束后才能被GC:**全局执行上下文一直存在上下文执行栈中,直到程序退出才会消失,在程序运行中一直存活因此只有程序结束后才能被GC
  • **同名变量遮蔽或污染全局:**在局部作用域中定义同名变量会遮蔽或污染全局

以上分析来看,在使用局部作用域变量的性能会比使用全局变量的方式高。在jsbench中测试也是同样结果。

6.3 缓存全局变量

在不得不使用全局变量时候,可以通过缓存全局变量进行优化,提高性能。

6.4 通过原型对象添加附加方法

function F1() {
    this.foo = function() {
        console.log('hello')
    }
}

function F2() { 
}
F2.prototype.foo = function() {
    console.log('hello')
}
const f1 = new F1()
const f2 = new F2()

以上两种方式给对象添加公共的方法,F1通过构造函数直接给生成的每个对象新增一个方法,F2通过prototype添加了公共方法。

对比两种方法,通过原型链添加的方式只需添加一次,因此效率更高。

6.5 闭包陷阱

闭包

js中闭包的产生是由于其函数作用域,外层函数定义的变量在内层函数可以使用,在执行完外层函数后,内层函数仍可访问外层函数中定义的变量。

这也就意味着之前定义的变量在内存中保存着并未释放,因此执行内层函数才能够正常访问。

这就可能带来如果不能即时释放内存,就会产生内存泄漏的问题,这就是闭包陷阱。

function out() {
  const name = 'tom'; // 外层函数定义name
  return function() {
    console.log('hello ', name); //内层函数使用name
  }
}
const inner = out();
// 执行内层函数仍可访问到外层定义的name = tom
inner(); // hello  tom
inner(); // hello  tom
inner(); // hello  tom
inner(); // hello  tom

闭包陷阱与解决方案

// 闭包陷阱
function foo() {
  const el = document.getElementById('btn');
  el.onclick = function() { // onclick事件触发的函数作用域在foo内部
    console.log(el.id);
  }
}

foo() // foo执行后,该函数仍未得到释放 

以上代码中,el保存了对id为btn的dom元素的引用,并对其添加了onclick事件触发时执行的函数。

但是存在一点问题是,以上使用方式中el变量无法得到释放,即时id为btn的元素被移除,但foo中定义的el变量仍在,其指向的内存空间中的内容仍在,因此会产生内存泄漏。如果此类问题很多,则会影响到性能。

解决方法:

// 解决方法
function foo() {
  const el = document.getElementById('btn');
  el.onclick = function() { // onclick事件触发的函数作用域在foo内部
    console.log(el.id);
  }
  el = null; // 释放el: 此时btn元素上的onclick事件还在,因为el其实只是个引用变量,本质是个指针,它指向了dom元素所在的空间,释放掉这个指针并不会对对象的内容有影响。
}

foo() // 执行后仍然会给id为`btn`的元素绑定onclick事件,并且及时释放掉了el,在dom元素被移除后,相应的内存空间引用计数为0,会得到有效释放。

对于闭包陷阱,解决它的方法就是想办法及时释放掉闭包中定义的变量。如果可以避开闭包,尽量不使用闭包。

6.6 避免属性方法访问属性

JavaScript的面向对象与Java等不同,其所有属性均可直接访问,不必设置专门的getter方法,通过getter方法去访问对象属性增加了一层定义反而会降低效率。

// 直接访问
const obj = {
  name: 'tom'
}
console.log(obj.name);

// 通过属性方法访问
const obj1 = {
  name: 'tom',
  getName() {
    return this.name;
  }
}
console.log(obj1.getName());

这里的分析但从执行效率上来讲,在没有必要使用getter方式访问的时候,尽可能直接访问。

6.7 for循环优化

使用for循环时可以通过缓存length优化for循环。

const arr = [1,2,3,4,5];
for(let i = 0; i < arr.length; i++){
  console.log(arr[i])
}
// 优化,减少重复获取arr.length属性
for(let i = 0, let len = arr.length; i < len; i++){
  console.log(arr[i]);
}

for in, for, forEach中访问元素最高效的方式为forEach:在需要访问元素时,尽可能使用forEach,for in针对对象设置的循环机制,尽量避免数组遍历时使用

6.8 dom节点操作优化

减少dom操作次数,尽可能合并多次dom操作到一次中

由于浏览器dom操作通常伴随着回流重绘

回流: 浏览器dom tree由于元素规模尺寸、布局、隐藏显示等发生改变后会导致回流,重建render tree。之后导致重绘,重新进行渲染。回流必然导致重绘

重绘: 对展示页面进行重新绘制,重绘可以单独存在,例如更改元素背景色等仅改变页面样式的操作,会导致重绘,但并不会导致回流。

操作dom的消耗会比执行js代码消耗高很多,因此需要尽可能避免多次重复操作dom。例如通过循环产生的dom节点,可以在循环之后进行统一的一次dom操作。

// 未优化,多次执行dom操作
const container = document.createElement('div');
document.body.appendChild(container);
for (let i = 0 ; i < 10; i++) {
  const p = document.createElement('p');
  p.innerHTML = i;
  container.appendChild(p)
}
// 优化:统一执行一次dom操作
const container = document.createElement('div');
for (let i = 0 ; i < 10; i++) {
  const p = document.createElement('p');
  p.innerHTML = i;
  container.appendChild(p)
}
document.body.appendChild(container);

使用cloneNode

使用cloneNode方法基于已有模板创建dom节点比document.createElemnt创建节点效率高。如果可以

6.9 字面量替换new操作

对于一些方便用字面量创建的对象,使用字面量的方式效率会比使用new更高.

例:

const arr = [1,2,3,4,5];

const arr1 = new Array(5);
for(let i = 0, len = arr1.length; i < len; i++) {
  arr1[i] = i + 1;
}
// arr创建性能高于arr1