1、概述-如何编写高性能的 JavaScript
随着软件开发行业的不断发展,性能优化是一个不可避免的话题。什么样的行为才能算是性能优化呢?本质上来说任何一种可以提高运行效率降低运行开销的行为,都可以看作是一种优化操作,这也就意味着在软件开发过程中必然存在着很多,值得优化的地方特别是在前端应用开发过程中。性能优化我们可以看作是无处不在的,例如请求资源时所用到的网络,以及数据的传输方式再或者开发过程中所使用到的框架等它们都可以进行去优化。本阶段我们要探索的核型是 JS 语言的优化,具体来说就是从认知内存空间的使用再到垃圾回收的方式介绍从而让我们编写出高效的JS代码。具体会包含以下几块内容:
- 内存管理:在这里首先会说明为什么内存是需要管理的,以及内存管理的的基本流程;
- 垃圾回收于常见的GC算法;
- V8引擎的垃圾回收:具体了解V8引擎当中使用的是什么样的 GC 算法,在实现当前的垃圾回收;
- Performance 工具;
- 代码优化实例
2、JS 内存管理 Memory Management
随着近些年硬件技术的不断发展,同时高级编程语言当中也都自带了 GC 机制。所以这样一些变化,就让开发者不需要特别注意内存空间使用的情况下也能够去正常的完成相应的功能开发。为什么在这里还要重提内存管理呢?下面就通过一段极简单的代码进行说明:这里 fn 函数在调用的时候会向内存尽可能多的申请一片比较大的空间。然后在执行这个函数过程中其实从语法上来说,它是不存在任何类型的问题的。但是当我们用一个相应的性能检测工具在这个脚本执行过程中对它的内存进行监控的时候,会发现它的内存变化就跟当前图示中所看到的蓝色线条一样。它是持续升高的,在这个过程当中并没有看到回落。这代表着内存泄露,如果说在写代码的时候不够了解内存管理的一些机制,从而就会编写出一些不容易察觉到的内存问题性代码。像这种代码多了以后给程序带来的,可能就是一些意想不到的 BUG 。所以掌握内存的管理,还是非常有必要的。
function fn () {
arrlist = [];
arrlist[100000] = 'lg is a coder';
}
fn()
什么是内存管理 ?
从当前这样一个词语本身来说,内存就是由可读写的单元组成,表示一片可操作空间;管理就是由人主动的去操作一片空间的申请、使用和释放,即使我们借助了一些 API 但终归来说我们可以自主的去做一个这样的事情;所以内存管理就是开发者可以主动的去向内存来申请空间、使用空间、释放空间,因此这个流程也就显得非常简单了。一共有三步:申请 - 使用 - 释放。
在 JS 中是如何完成内存管理的 ?
和其他语言是一样的,它也是分三步来执行这样一个过程。但是由于 ES 当中并没有提供相应的 操作 API ,所以它不能向 C、C++ 那样🈶️开发者主动的去调用相应的 API 完成相应的空间管理。不过即使如此,它也不能影响我们去通过 JS 脚本来演示当前在内部一个空间的生命周期是怎样完成的。在 JS 当中由于并没有直接提供相应的操作 API ,所以就只能是在 JS 执行引擎去遇到变量定义语句的时候自动分配相应的空间。所以在申请空间阶段,就是直接定义一个变量;使用空间其实就是对变量的读写操作,比如赋值操作;空间释放操作在 JS 中同样也没有相应的释放操作 API ,所以在这可以采用一种间接的方式,比如将对象变量设置为 null。
// 内存空间的声明周期
// 申请空间
let obj = {}
// 使用空间
obj.name = 'leo'
// 释放空间
obj = null
以上就相当于是按照内存管理的一个流程在 JS 当中去实现了这样一个内存管理。
3、JS 中的垃圾回收
在 JS 当中有哪些内容会被当作垃圾看待 ?
在后面的 GC 算法中,也会存在垃圾的概念两者其实是完全一样的概念。对于前端开发来说 JS 当中所说的内存管理是自动的,每当去创建一个对象、数组、函数的时候内存就会自动的去分配相应的空间。后续程序代码在执行的过程当中,如果通过一些引用关系无法在找到某些对象的时候这些对象就会被看作是垃圾。再或者说这些对象其实已经存在,但是由于代码当中一些不合适的语法或结构性的错误,让我们没有办法再去找到这样一个对象,那么这种对象也会被看成是垃圾。知道什么是垃圾之后,这个 JS 执行引擎就会出来工作,然后把它们所占据的对象空间进行回收。这个过程就是所谓的 JS 垃圾回收,这里用到几个小概念:引用、从根上访问(这个操作在后面的 GC 里面也会被频繁的使用到)、可达对象。综上所述 JS 中的垃圾的特点是:
- 对象不再被引用时是垃圾
- 对象不能从根上访问到时是垃圾
JS 中什么是可达对象 ?
在JS中可达对象理解起来非常的容易,就是能被访问到的对象。至于说怎么访问,可以通过具体的引用也可以在当前上下文当中去通过作用域链来进行查找。只要能被找到,就认为这个对象是可达的。不过这里有一个小的前提,就是一定要是从根上出发找得到才能够去认为它是可达的。所以在这我们又要去讨论一下什么是根?在JS中我们可以认为当前的全局变量对象就是根,也就是所谓的全局执行上下文。JS 中的垃圾回收本质上就是,找到垃圾然后让 JS 执行引擎来进行空间的释放和回收。
在JS中的引用于可达是怎么体现的?
在下面这个案例中定义了一个包含 name 属性的对象空间,当它被赋值给 obj 对象后就产生了一次引用,再者来说站在全局的执行上下文上,当前的 obj 是可以从根上来被找到的,所以说这个 obj 是可达的,这也就间接意味着我们所定义的对象空间其实就是一个可达对象。说完这些以后,我们再进行一个操作将 obj 赋值给 ali 这个变量,昨晚这个操作以后我们就可以认为这个对象空间又多了一次引用,所以说在这里存在了一个引用数值变化的,这个概念在后面的引用计数算法中会用到的。这个操作之后再做一个新操作将 obj 赋值为 null ,这个操作做完以后我们就可以思考一下了,本身来说这个对象空间是有两个引用的,随着 obj 指向 null,现在这个引用对象还是否可达呢?必然是的,因为 ali 还在引用着这样一个对象空间,所以说它依然是一个可达对象。这些就是一些引用的主要说明顺带也看到了一个可达。
let obj = {
name: 'leo'
}
let ali = obj
obj = null
console.log(ali)
JS 可达操作案例:为了方便后面演示 GC 当中的标记清除算法,所以这个实例会稍微写的麻烦一些。
function objGroup (obj1, obj2) {
obj1.next = obj2
obj2.prev = obj1
return {
o1: obj1,
o2: obj2
}
}
let obj = objGroup({name: 'obj1'}, {name: 'obj2'})
console.log(obj)
function objGroup (obj1, obj2) {
obj1.next = obj2
obj2.prev = obj1
return {
o1: obj1,
o2: obj2
}
}
let obj = objGroup({name: 'obj1'}, {name: 'obj2'})
delete obj.o1
delete obj.o2.prev
console.log(obj)
如图将所有可以找到 obj1 的线条都删除了,也就是说没有办法再通过什么样的方式找到 obj1 这样一个对象空间,JS 执行引擎就会认为 obj1 为垃圾对象。
然后就会找到它,对其进行释放和回收操作。在编写代码的时候,会存在一些对象引用的关系,然后可以从根的下边来进行查找,按照这样的线条终归能找到某一个对象。但是如果说去找到这个对象一些路径被破坏掉,或者说被回收了。那么这个时候是没有办法再找到它就会把它视为垃圾对象,最后就会让垃圾回收机制去把它回收掉。
4、GC 算法介绍
GC 就是垃圾回收机制的简写,当 GC 工作的时候它可以帮我们找到内存当中的一些垃圾对象。然后对这些空间可以进行一些释放,并且还可以进行回收分配之后方便后续的代码继续去使用。
GC 里的垃圾是什么?
-
程序中不再需要使用的对象:从程序需求的角度来考虑如果某一个数据在使用完成过后,上下文里面不再需要去用到它,就可以把它当作是垃圾来看待。例如代码当中的 name ,当函数调用完成以后在这里其实已经不再需要使用 name 了,因此从需求的角度来考虑。它应该被当作垃圾进行回收的,至于说到底有没有被回收呢现在不做讨论;
function func () { name = 'leo' return `${name} is a coder` } func() -
程序中不能访问到的对象:从当前程序运行过程中这个变量还能否被引用到的角度去考虑,例如下面案例中依然是在函数内部定义了一个 name 。不过这次在变量前面加上了一个声明变量的关键字,有了这样的一个关键字以后。当函数调用结束之后,在外部的空间当中就不能够再访问到这个 name 了。所以当我们找不到它的时候,其实它也可以算作是一种垃圾。
什么是 GC 算法?
GC 其实就是一种机制,它里面的垃圾回收器可以去完成具体的回收工作。而工作的内容本质就是查找垃圾、释放空间并且回收空间,所以说在这个过程当中就会有这么几个行为:
- 如何去查找垃圾
- 在释放空间的时候又改怎样去释放
- 回收空间的过程中又如何去进行分配
所以这样一系列过程的里面,必然有不同的方式。所以说 GC 的算法,就可以理解为是上述的垃圾回收器在工作过程中所遵循的一些规则,好比就是一些数学公示。
- GC 是一种机制,垃圾回收器完成具体的工作
- 工作的内容就是查找垃圾释放空间、回收空间
- 算法就是工作是查找和回收所遵循的规则
常见的 GC 算法:
- 引用计数:通过一个数字来判断当前的这样一个对象是不是一个垃圾;
- 标记清除:可以在 GC 工作的时候去给到那些活动对象添加上一个标记,来判断它是否是一个垃圾;
- 标记整理:与标记清除其实很类似,只不过后续回收的过程中会做出一些不一样的事情;
- 分代回收:将来在 V8 当中会用到这样的一个回收机制;
5、引用计数算法实现原理
针对引用计数算法来说,它的核心思想其实就是在内部去通过一个引用计数器来维护当前对象的引用数。从而去判断改对象的引用数值是否为 0 ,来决定它是否是一个垃圾对象。当这个数值是 0 的时候,那么 GC 就开始工作将其所在的一个对象空间进行回收和释放在使用。这里提到一个名称叫引用计数器,关于它要又一个小小的印象。因为相对于其他的一些 GC 算法来讲,也正是由于引用计数器的存在导致了引用计数在执行效率上可能与其他的GC算法有所差别。这个说完以后还需要再思考一下,我们引用的这样一个数值什么时候会发生改变。所以在这里它给出的一个规则是这样的,当某一个对象它的一个引用关系去发生改变的时候,引用计数器就会主动的修改当前这个对象所对应的引用数值。什么叫做引用关系发生改变呢?例如说我们的代码里面现在有一个对象空间,目前来说又一个变量名指向它。这个时候我们就把数值加一,如果说在这个时候又多了一个对象还指向它我们就把这个数值再加一。如果是减少的情况下,就减一就可以了。当我们发现这样一个引用数字为 0 的时候,GC 就会立即工作然后将当前的对象空间进行回收。说完这样一段的原理之后,我们再通过一些简单的代码来说明下这个引用关系发生改变的一种情况。
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
}
fn()
从全局的角度去考虑,我们会发现 window 的下面是可以直接找到 user1、user2、user3、nameList;从变量这个角度出发,同时在 fn 函数里面 num1、num2 由于前面没有设置关键字所以它同样是被挂载在当前的这样一个 window 对象下。所这个时候对于这些变量来说,它们的引用计数肯定都不是 0,然后紧接着我们做一些修改。比如我们在 fn 函数里面 num1、num2 前面添加上关键字声明,就意味着 num1、num2 只能在 fn 块级作用域里起效果。所以一但当 fn 调用执行结束过后,我们在外部全局的地方出发就不能够再找到 num1、num2 了。这个时候 num1、num2 它们身上的引用计数就会回到 0,此时此刻只要是 0 的情况情况下 GC 就会立即开始工作。将它们当作垃圾去进行一个对象回收,也就是说这个时候函数执行完成以后它们所在的一个内存空间就会被回收掉。
function fn () {
const num1 = 1
const num2 = 2
}
fn()
由于在这个地方nameList 里面其实刚好都指向了 user 对象空间,所以当我们脚本即使执行完一边以后它后头一看 user 系列对象空间其实都还被人引用着,此时的引用计数就不是0 ,那么这个时候就不会被当做垃圾去进行回收。这块就是关于引用计数这样一个算法在实现过程中,它所遵循的一些基本原理。
总结:靠着当前对象身上一个引用计数的数值来判断是否为 0 ,从而决定它是不是一个垃圾对象。
6、引用计数算法的优缺点
引用计数算法的优点:
- 发现垃圾时立即回收:因为它可以根据当前这个引用数是否为 0 ,来决定这个对象是不是一个垃圾,如果它找到了这个时候就可以立即进行释放;
- 最大限度减少程序暂停:应用程序在执行的过程中,必然会对内存进行一个消耗。而我们当前执行平台它的内存肯定会有上限的,所以内存肯定会有暂满的时候。由于引用计数算法时刻监控着那些引用数值被置为 0 的对象,所以就可以认为举一个极端的情况就是当它发现这个内存即将爆满的时候,引用计数就会立马去找到那些数值为 0 的对象空间。然后对其进行释放,所以这样就能保证当前这个内存就不会有暂满的时候,这就是所谓的减少我们程序暂停的说法。
引用计数算法的缺点:
-
无法回收循环引用的对象:
function fn () { const obj1 = {} const obj2 = {} obj1.name = obj2 obj2.name = obj1 return 'leo' } fn() -
时间开销大:当前的引用计数需要去维护一个数值的变化,所以在这种情况下它要时刻的去监控着当前对象的一个引用数值,是否需要修改。本身它这个数值的修改就需要消耗时间,如果说内存里面有更多的对象休要修改,那么这个时间就会显得更大一些。所以这一块是相对于其他的 GC 算法来说,我们会觉得引用计数算法它的一个时间开销会更大一些。
这一块就是关于引用计数算法优缺点的一个简单说明
7、标记清除算法实现原理
相对于引用计数来说,标记清除算法的原理实现更加简单。而且还能解决一些相应的问题,在 V8 当中它会被大量的使用到。对于标记清除算法来说,它的核心思想就是:将整个垃圾回收操作分成两个阶段来完成。
第一个阶段他会去遍历所有的对象,然后找到这些活动对象进行标记的操作。这里的活动对象和之前所提到的可达对象是一个道理;
第二个阶段,仍然回去遍历所有的对象然后把那些身上没有标记的对象进行清除。同时需要注意的就是在第二阶段当中它也会把第一个阶段所设置的标记给抹掉,便于 GC 下次还能够去正常的工作,这样一来的话它就可以去通过两次的遍历行为,把当前这样一个垃圾空间去进行回收最终在交给相应的空闲列表进行维护。
这就是标记清除算法的实现原理,其实就是两个操作:第一就是标记、第二就是做清除。为了方便理解下面用图示做举例说明:在全局的地方我们可以找到 A、B、C 这样的三个可达对象。找到这三个可达对象之后,会发现它的下边还会有一些子引用,所以这就是标记清除算法强大的地方。如果我们发现它的下面会有还在甚至于说孩子的孩子,这个时候它会去用递归的方式继续寻找那些可达的对象,如图中的 D、E 也会被做可达的标记。那么这个时候图中的 a1、b1 放在右侧它可能是由于我们当前放在了一个局部作用域,而当前这个作用域执行完成以后这样一个空间就被回收了。所以从当前 global 这样一个链条下我们是找不到 a1、b1 的。这个时候 GC 机制就会认为它是一个垃圾对象,没有去给他做标记,最终呢在我们 GC 去工作的时候就会找到 a1、b1 然后直接把它们回收掉。这就是标记清除的标记阶段和清除阶段要做的事情,简单的整理一下就是分成两个步骤:在第一个阶段当中我们要去找到所有可达对象,如果说在这里涉及到了我们这样应用的一个层次关系,那么它回去递归的进行查找。就想图中的 global 找 A 再找 D 这样一个过程。找完以后它会将这些可达对象都进行标记,标记完成以后会进行第二个阶段。然后会开始做清除,找到那些没有去做标记的对象。同时呢还会将之前第一次所做的标记也给它清除掉,这样我们就完成了一次垃圾的回收。同时呢我们还要好留意最终它还会去把回收的空间直接放在当前的和一个叫做空闲列表上面,方便我们后面的程序可以直接在这去申请空间使用。
这一块就是关于标记清除算法的实现原理
8、标记清除算法优缺点
标记清除算法的优点
相对于引用计数来讲,标记清除具有一个最大的优点。就是它可以去解决之前对象循环引用的回收操作。比如我们在一个函数内定义了两个对象 a1、b1,并让它们互相引用。对于这种函数的调用,在结束之后必然要去释放它们内部的空间。所以在这种情况下,一旦当某一个函数调用结束之后它局部空间当中的变量就失去了与当前全局 global 在作用域上的一个连接。所以 a1、b1 在全局 global 下就无法访问到,这个时候它就是一个不可达的对象。不可达的对象在做标记阶段的时候,就不能够完成标记。那么接下来在第二个阶段,我们当前去回收的时候就直接找到这些没有没标记的对象,把它内部的空间进行了释放。这是标记清除可以做到的事情,但是在引用计数里面,虽然当前这个函数调用结束以后它内部的 a1、b1 也没办法在全局的地方去进行访问,可是由于当前判断的标准是引用数值是否为 0 ,所以在这种情况下它就没有办法去释放 a1、b1的空间。这就是标记清楚算法的最大优点,是相对于引用计数算法来说的。
标记清除算法的缺点
如下图我们模拟了一个内存的存储情况,我们当前从根去进行查找,在下方又一个直接的可达对象就是红色标示部位。在它的左右两侧有两块从根无法查找的区域,这种情况在进行第二轮清除操作的时候它就会直接将两块区域所对应的空间进行回收,也就是蓝色表示的区域。然后再把这样一个释放的空间,添加到空闲列表之中。紧接着后续的程序就可以直接去再从空闲列表上去申请相应的空间地址使用,不过在这种情况下就会有一个问题。任何一个空间都会有两个部分组成,一个是存储这个空间原信息的比如它的大小、地址等等,称为为头;另一个部分是用来专门存储数据的,叫做域。经过标记清除回收操作过后,左右两边就要三个字的可用空间。由于它们中间有个可达对象的使用空间分割着,所以在释放完成过后它们其实还是分散的也就是地址不连续。这点很重要地址不连续所以在这种情况下,如果后续我们想去申请一片空间。而刚好巧了这次我们想申请的空间地址大小刚好是 1.5 个字,这种情况下如果找到 B 所释放的空间,会发现它是多了 0.5 个,找 C 又少 0.5 个。这就造成当前标记算法中最大的问题,叫做空间的碎片化。
所谓的空间碎片化,就是由于当前所回收的垃圾对象在地址上它本身是不连贯的。由于这种不连续从而造成了在回收之后它们分散在各个角落,后续想要去使用的时候如果刚好巧了新的生成空间刚好与它们大小匹配就能直接用,一旦是多了或是少了就不太适合去使用了。所以这就是标记清除算法的一个缺点,我们称之为空间碎片化。
总结:
- 优点:相对于引用计数来说,可以去解决循环引用不能回收的问题;
- 缺点:会产生空间碎片化的问题,不能让空间得到最大化的使用;
9、标记整理算法实现原理
和标记清除算法一样,标记整理在 V8 当中也会被频繁的使用到。标记整理可以看作是标记清除的增强,因为它们在第一个阶段的标记工作是完全一样的。都会遍历所有的对象然后,将当前的可达活动对象进行标记。只不过是在清除阶段,我们的标记清除直接将没有标记的垃圾对象做空间回收。但是标记整理会在清除之前会有一个整理的工作,移动对象的位置让它们在地址上产生连续。这一块为了理解这样一个过程我们用图示演示下:
标记活动对象:找出可达对象
整理操作:将活动对象空间移动到一起,在地址上变成连续的一块
回收阶段:对活动对象右侧的一个范围去进行整体的回收,回收完成以后就会得到下图这种情况。这种情况相对于我们之前的标记清除算法来说它的好处就会显而易见,因为我们现在内存里面就不会大批量的出现那些分散的小空间。而回收到的小空间都基本上是连续的,在后续的使用过程中如果我们想要申请的时候,就可以尽可能的去最大化利用当前内存当中所释放出来的空间。
这个过程就是标记整理算法,跟之前所提到的一样它会配合着标记清除在 V8 引擎当中实现频繁的 GC 操作。
10、常见 GC 算法总结
- 引用计数:核心思想就是在内部通过引用计数器来维护每个对象都存在的引用数值,通过这个数值是否为 0 来判读对象是否是一个垃圾对象。从而去回收它的垃圾空间,让垃圾回收器对当前的空间进行回收释放。优点就是即时回收垃圾对象,因为只要数值为 0 就会触发 GC 找到这片空间进行回收和释放。所以也正是由于这样一个特点的存在,引用计数的另外一个优点就是可以最大限度的减少程序的卡顿。因为只要这个空间即将被暂满的时候,垃圾回收器就会进行工作。然后将这个内存去进行释放,让我们的内存空间总是有一些可用的地方;缺点就是无法回收存在循环引用的对象,因为这样的情况意味着当前对象空间的一个引用数字永远是不为 0 的。也就是不能触发当前的一个垃圾回收操作,除此之外它还有一个缺点就是对于资源的消耗是比较大的。因为它这一块有一个引用计数器,然后每次还都要去修改当前这个引用数。而这个对象空间的引用数,有可能会很大也有可能很小。总之频发的操作会有一些资源上的开销,因为我们认为它的速度就不一定那么的快了;
- 标记清除:这种算法分两个阶段来执行,首先会遍历所有的对象,然后给当前的活动对象进行标记。紧接着就会去把那些没有标记的对象去清除掉,从而去释放掉当前这些垃圾对象所占用的空间。优点就是相对引用计数来讲可以回收循环引用的空间,这一点是引用计数所做不到的;缺点是由于当前算法决定不能够把自己所有的空间去最大化利用,所以很容易就差生碎片化的操作。除此之外,标记清除也不能够去立即回收垃圾对象,也就是说即使在遍历的过程中它发现了这样一个对象是不可达的,但是它也要等到最后才去清除。而且它去清除的时候,当前的程序其实是停止工作的。所以说这也是标记清除的一个缺点;
- 标记整理:它的做法和标记清除类似,只不过在清除阶段有一些前置的操作。要先去整理一下当前的地址空间,它的优点是可以解决空间碎片化,因为它多处了一个整理地址的操作。缺点和标记清除一样,就是不能即时回收垃圾对象,所以相对于引用计数来说这块也相对是一个缺点。
11、认识 V8
众所周知 V8 引擎是一款目前市面上最主流的 JS 执行引擎,chrome 浏览器和 nodeJs 平台采用的都是采用 V8 引擎来执行 JS 代码。对于这两个平台来说 JS 之所以能在其上高效运转也正是因为 V8 这样一个幕后英雄的存在,这里就提到 JS 的一个高效运转这也是 V8 的一个重要卖点。这个速度之所以快出来其背后有一套优秀的内存管理机制之外,其实 V8 还有一个特点就是采用即时编译。之前很多 JS 引擎都需要将源代码先去专成字节码,然后才能去执行。而对 V8 来说就可以直接将源码给翻译成当前可以直接执行的机器码,这个速度是非常快的。对于 V8 来讲还有一个比较大的特点就是,V8 它的内存是有上限的。V8 的内存空间设置了以个数值,在 64位操作系统上这个上限是不超过 1.5G ;对于32位浏览器,这个数值是不超过 800M 的。为什么 V8 要采用这样一个做法呢?原因可以从两方面可以总结:
- V8 本身就是为了浏览器去制造的,现有的这样一个内存大小对于网页应用来说是足够使用了。
- V8 内部所遵循的垃圾回收机制,也决定了它采用这样一个设置是非常合理的,因为官方去做个这样一个测试,当我们的垃圾内存去达到 1.5G 的时候,如果 V8 去采用增量标记的算法进行垃圾回收,只需要花费 50ms;而如果采用非增量标记的形式去回收,则需要 1m。从用户体验的角度来说,1m 可以说是很长的时间了,所以在这里就以 1.5G 为界限了。
12、V8 垃圾回收策略
在程序的使用过程中,会用到会多的数据而这些数据分为原始数据和对象类型数据。对于这些基础的原始类型数据来说,都是由程序的语言自身来进行控制的。所以在这里我们所提到的回收只要还是指的是当前存活在堆区里的对象数据,因此这个过程是离不开内存操作的而我们当前也知道在 V8 当中对内存是做了上限的。所以这样的话我们就想知道怎么样在这种情况下来对垃圾进行回收的,对于 V8 来说采用的是分代回收的思想,具体来说如何实现,主要就是把当前的内存空间去按照一定的规则分成两类,一个叫做新生代存储区、另一个叫老生代存储区。有了这样一个分类之后,接下来就会针对不同代采用最高效的 GC 算法。从而去对不同的对象进行一个回收的操作,
V8 中常用的 GC 算法
- 分代回收
- 空间复制
- 标记清除
- 标记整理
- 标记增量
13、V8如何回收新生代对象
V8 内部的内存分配如下图所示:因为 V8 是基于分代的垃圾回收机制,所以在 V8 的内部在内存空间分成了两个部分就像图中显示的这样。内部的一个存储区域被分成了左侧的白色区域,和右侧的偏红色的区域。左侧就是专门又来存储新生代对象,右侧就是专门用于存放老生代对象。在这里我们当前只是关注一下新生代存储区的垃圾回收操作,所以在这我们先做一些文字上的说明。跟刚才看到的一样,我们当前的 V8 内部是吧空间分成了两部分,而左侧专门去用于存储新生代对象。这样的一个空间是有一定设置的,在 64位操作系统当中它的大小是 32M、在 32位操作系统中就是 16M 。有了这样一个空间之后,在它里面就可以去存放相应的新生代对象。新生代对象其实指的就是存活时间较短的对象,如何界定存活时间较短呢?举个例子,比如在当前代码内有一个局部作用域,这个局部作用域当中的变量在执行完成过后,就肯定要去释放回收。而我们在其他的地方,比如全局的地方也可能会有一个变量。而全局下方的这个变量它肯定要等到程序退出之后,才会被回收。所以相对来说新生代就指的是存活时间比较短的变量对象。
V8 当中如何完成新生代对象回收的?
这个过程当中所采用的算法主要就是复制算法和标记整理算法操作。首先它会将会将当前左侧的小空间也去分成两个部分:From、To,而且这两个部分的大小是相等的。From 空间成为使用状态、To为空闲状态,有了这样两个空间之后代码在执行的时候如果要申请空间要进行使用,那么首先会将的变量对象都分配至 From 空间。一旦 From 空间应用到一定程度之后,就会触发 GC 操作,这个以后就会采用标记整理的操作来对 From 空间进行活动对象的标记。找到活动对象之后会继续使用整理操作将空间位置变得连续,也便于后续不会产生空间碎片化。做完这些操作之后会将活动对象拷贝至 To 空间,拷贝完成以后就意味着之前 From 空间当中活动对象就有了一个备份。后面就是回收操作,这个回收操作就是将原 From 空间进行整体的释放回收,并将 From 空间与 To 空间进行交换。
回收细节说明
首先在这个过程当中,肯定会想到的一个现象就是如果在拷贝时发现某个变量对象所使用的空间在当前的老生代对象里面也会出现。这个时候就会出现晋升的操作,晋升指的就是将新生代的对象移动至老生代进行存储。至于说什么时候可以触发晋升操作呢?在这一般有两个判断标准:
- 如果新生代某些对象经过一轮 GC 之后它还活着,这个时候就可以把它拷贝至老生代存储区进行存储操作;
- 如果说当前拷贝的过程中,发现 To 空间使用率超过了 25% 这个时候也需要将这次的活动对象,都移动至老生代存储区中进行存放。之所以界限是 25% 是因为在将来进行回收操作时最终是要把 From 空间和 To 空间进行交换,也就是说以前的 To 会变成 From这就意味着 To 的使用率如果达到了 80% 最终它变成活动对象的一个存储空间后。那么新的对象好像就存不进去了,简单说就是 To 空间的使用率如果超过一定的限制那么将来它变成使用状态时,新进来的对象空间就不够用了。
14、V8 如何回收老生代对象
老生代空间说明
右侧红色区域就是来生代存储区,针对老生代区域在 V8 当中同样是有一个内存大小的限制。在64位操作系统当中是 1.4G 、在32位操作系统中是 700M,有了这样一个大小之后就可以往它里面去存放具体的数据。而老生代对象其实指的就是当前存活时间较长的对象,至于说哪些对象事呢?例如,之前所提到的在全局对象下所存放的一些变量、闭包放置的一些变量数据有可能也会存活很长时间。
老生代如何完成垃圾回收的?
针对老生代采用的垃圾回收策略是:标记清除算法、标记整理算法、增量标记算法。具体来说怎么使用呢,其实它主要采用的是标记清除算法完成对应垃圾空间的释放回收。之所以会使用标记清除,是因为相对于空间碎片它在效率提升上是非常明显的。在什么时候会使用到标记整理算法呢?非常简单,如果说我们发现当它去想把新生代区域里面的内容往当前老生代存储区进行移动的时候而且这个时间节点上,老生代存储区域空间又不足以来存放新生代存储区所移过来的这些对象,也就是之前所提到的晋升。在这种情况下就会触发标记整理,然后把之前的一些碎片空间进行整理回收,这样的话就让我们又更多空间可以进行使用。说到这我们就大致的知道了对应当前的 V8 来讲针对与老生代的一个对象回收,其实主要采用的还是标记清除,当我们想去把新生代对象往老生代存储区域进行移动的时候,如果出现了空间不足的情况就会触发标记整理的操作。最后它还会采用增量标记的方式对当前这样一个回收效率进行提升。
老生代与新生代回收策略对比
新生代的垃圾回收策略是用空间换时间:因为它采用的是复制算法,也样也就意味着每时每刻它的内部都会有一个空闲空间的存在。由于新生代存储区它本身的空间就很小,所以分出来的空间就更小这一部分的空间浪费,相对于它所带来的时间上的提升是微不足道的。在老生代对象回收过程中为什么不去采用这种一分为二的做法呢?非常简单,因为老生代存储区域空间比较大的,如果说一分为二基本上就有几百兆空间是浪费不用的。再者老生代存储的数据比较多,在复制的过程中消耗的时间也就非常的多。由此可见,老生代不适合使用复制算法来实现。
标记增量如何优化垃圾回收?
如图所示整个回收操作分成两个流程,一个是程序的执行一个就是当前垃圾的回收。在这我们要明确的就是当垃圾回收进行工作的时候,它其实会阻塞 JS 脚本的执行,所以图中会出现空档期。所谓的标记增量指的是,简单点说就是将当前一整段的垃圾回收操作拆分成多个小步,组合着完成当前整个回收。从而去替代之前一口气做完的垃圾回收操作,这样做的好处通过这个图也能看的非常明显,主要就是让我们可以实现垃圾回收与程序执行交替着完成。而不是像以前那样程序执行的时候就不能做垃圾回收,而程序去做垃圾回收的时候就不能去做程序的运行。这样所带来的时间消耗会更加的合理一些,简单的去举个例子看下增量标记的实现原理:从左侧开始程序在运行的时候不需要去执行垃圾回收的,一旦当它触发了垃圾回收之后这一块无论采用的是何种算法在这里它都会去进行遍历和标记的操纵。这里需要注意因为我们针对的是老年代存储区域,所以它就会存在遍历操作在遍历过程中,它要做标记这个标记可以不一口气做完。因为存在着直接可达和间接可达操作,也就是说如果我们在做的时候直接第一步先找到第一层的可达对象就可以停下来了。让程序再去执行一会,如果说程序执行完一会之后再继续去让我们的GC机制去做第二步的标记操作。比如它的下面会有一些子元素,也是可达的就会继续做标记。标记一轮以后再让 GC 停下来继续回到程序执行,这也就是所谓的交替执行。到最后标记操作全部完成,最终去完成垃圾回收。并在完成垃圾回收操作之后程序回到它该执行的地方接着去做,虽然这样开来可能让你觉得当前 的程序停顿了很多次,但是你要明白我们整个 V8 最大的垃圾回收当他达到 1.5G 时候采用非增量标记的形式时间也不超过 1 秒钟,所以以上的执行间断间隔是合理的,而且这样一来的话它就最大限度的把以前很多长一段时间的停顿时间直接拆分成了更小段。这种对于用户来说就会显得更加的好一些。
15、V8 垃圾回收总结
- V8 引擎是一款主流的 JS 执行引擎;
- V8 内部内存有设置上限:原因第一是为浏览器而设计,第二由它内部的垃圾回收机制而确定,再大的话它的回收时间会造成用户的感知;
- V8 采用的是分代回收的思想:在这个过程当中将内存分成老生代和新生代,两个区域所存储的数据类型是不同的。并且它们之间也采用了不同的垃圾回收策略,具体就是在新生代主要采用的是复制算法和标记整理的算法、针对于老生代对象主要采用的就是标记清除、标记整理、增量标记。
16、Performance 工具介绍
为什么使用Performance?
GC 的工作目的其实就是为了让内存空间在程序运行的过程中出现良性的循环使用,所谓良性循环的基础就是要就我们在写代码时候能够对内存空间进行合理分配。由于 ES 当中并没有提供操作内存空间的 API 所以是否合理也都没有标准判断,因为它都是由 GC 来完成操作的。所以在这里,我们想要去判断整个过程的内存使用是否合理就必须想办法去时刻关注到当前内存的一个变化。因此,当前就有了这样一个工具可以提供给我们更多的监控方式。在程序运行过程中帮我们去完成对内存空间的监控操作,这一块简单的总结一下就是通过使用 Performance 就可以对当前程序运行过程中内存的变化又一个时刻的监控。有了这样一个操作之后,在我们程序的内存出现异常的时候直接去想办法定位到当前出现问题的代码块。
Performance 使用步骤
- 打开浏览器输入目标网址
- 进入开发人员工具面板,选择性能
- 开启录制功能,访问具体页面
- 执行用户行为,一段时间后停止录制
- 分析界面中内存的记录信息
17、内存问题的体现
内存出现问题的表现
- 页面出现延迟加载或经常性暂停:底层通常会伴随着频繁的垃圾回收出现,之所以会出现频繁的垃圾回收就在于代码中有一些代码是瞬间内存爆掉;
- 页面持续性出现糟糕的性能:底层一般都会认为存在着内存膨胀,所谓的内存膨胀指的就是当前界面为了去达到最佳的使用速度,可能会去申请一定的内存空间但是这个内存空间的大小远超过当前设备本身所能提供的一个大小,这个时候就能感知到一段持续性的糟糕性能的体验;
- 页面的性能随着时间延长越来越差:底层通常会伴随这内存的泄漏,因为在这种情况下刚开始的时候是没有问题的。由于某些代码的出现,可能随着时间的增长让内存空间越来越少。这就是所谓的内存泄漏
18、内存监控的几种方式
当内存出现问题的时候一般可以归纳为三种情况:
- 内存泄漏
- 内存膨胀
- 频繁的垃圾回收
当这些问题出现的时候,我们该以什么样的标准来进行界定呢?下面就给出一些简单的说明:
- 内存泄漏:内存使用的持续升高,在内存走势图上可以看到内存的变化是一直升高的且没有下降的趋势。
- 内存膨胀:在多数设备上都存在的问题。膨胀的本意就是当前的应用程序的本身为了去达到一个最优的效果,它就需要很大的一个内存空间。所以在这样一个过程当中,也许是由于当前设备本身的硬件不支持,才造成了我们在使用过程中出现了一些性能上的差异。所以说如果我们想去判定它当前是程序的问题还是说是我们设备的问题,就应该去多做一些测试。
- 频繁的垃圾回收:通过内存变化图进行分析
监控内存的几种方式:
- 浏览器任务管理器
- Timeline时许图记录
- 堆快照查找分离 DOM
- 判断是否存在频繁的垃圾回收
19、任务管理器监控内存
在这之前我们已经知道,一个 web 应用在执行的过程中如果想要去观察它内部的内存变化,是可以有多种方式的。例如在这里就通过简单的 Demo 演示一下怎样借助于浏览器当中自带的任务管理器去监控脚本运行时内存的变化。
开始之前先做一些简单的说明,将来我们要运行文件的平台是浏览器所以在这里要编写的就是 HTML 文档。至于如何在代码当中模拟内存的变化,这里我们选择的就是在界面当中去放置一个元素,然后给它添加上一个点击事件,当这个事件触发的时候我们就去创建一个长度非常长的数组,这样就是产生一个内存空间上的消耗。
<html>
<body>
<button id="btn">Add</button>
<script>
const btn = document.getElementById('btn');
btn.onClick = function () {
let arr = new Array(100,000)
}
</script>
</body>
</html>
如图中内存管理器所示有一列内存和和一列JavaScript内存,这两列都叫内存有什么区别呢?简单来说明一下,针对于第一列的内存来说它其实表示的是原生内存,简单理解就是我们当前界面里面会有很多的DOM节点,而这个内存指的就是 DOM 节点所占据的内存。如果说这个数值在持续的增大,就说明了我们的界面中在不断的去创建新 DOM ;最后一列叫 JavaScript 内存,这列里面我们表示的就是 JS 的堆在这列当中我们需要关注的其实就会死小括号里的值,它表示的就是是我们界面当中所有可达对象正在使用的内存大小。如果说这个数值一直在增大就意味着我们当前的界面中要么在创建新对象,要么就是当前现有对象在不断的增长。比如说一当前界面为例,点击之前小括号里的值一直都保持不变。当我们在页面中多次点击 Add 按钮过后再看看浏览器任务管理器中 JS 内存的变化,会发现数值变大了。通过这样一个过程我们就可以借助于当前浏览器的任务管理器来监控一下脚本运行时整个内存的变化。我们能得出的结论就是,如果说我们当前做后一列小括号里面的数值一直增大,就意味着我们这个内存是有问题的具体来说是什么样的问题,我们当前这个工具就显得不是那么好用了。因为它只能去帮助我们发现这个地方有没有问题,但是如果说我们想地位问题的时候它就不太好用了。
20、TimeLine 记录内存
在之前使用浏览器任务管理器来判断内存是否存在问题,而如果想去定位问题具体跟什么样的脚本有关,任务管理器就不是那么好用了。所以定位问题还要使用到时间记录内存变化的方式,来演示一下我们可以怎样更精确的定位到当前内存的问题跟哪一段代码是相关的或者说在什么时间节点上发生的。
这里我们就通过脚本编写完成,首先呢还是先说明一下这个里面我们接下来会做这样几个操作:第一放置一个 DOM 节点然后添加一个点击事件;第二在事件触发之后在JS内部去做两件事情,1. 通过创建大量的DOM节点来模拟内存的消耗、2. 通过数组的方式配合着其他方法形成一个非常非常长的字符串也去模拟当前大量的内存消耗。
<html>
<body>
<button id="btn">Add</button>
<script>
const arrList = [];
function test () {
for (let i = 0; i < 1000000; i++) {
document.body.appendChild(document.createElement('p'))
}
arrList.push(new Array(10000000).jion(x))
}
document.getElementById('btn').addEventListenter('click', test)
</script>
</body>
</html>
21、堆快照查找分离 DOM
堆快照功能工作原理:首先找到JS堆,然后对它进行一个照片的留存。有了照片的留存以后就可以看到它里面的所有信息,这也就是我们如何去监控的一个由来。而这堆快照在使用的时候其实也非常有用,因为它更像是专门针对于我们分离 DOM 的一个查找行为。
什么是分离 DOM ?
- 界面元素存活在 DOM 树上:我们在界面上看到的元素其实都是 DOM 节点,而这些 DOM 节点本应该都是存在于一个存活的 DOM 树上,不过对于 DOM 节点呢会存在几种形态。第一种形态我们称之为垃圾对象还有一种叫分离 DOM;
- 垃圾对象时的 DOM 节点:如果这个节点从当前的DOM树上进行了脱离,而且在JS代码当中也没有人在引用它,它其实也就成为了一个垃圾;
- 分离状态的 DOM 节点:如果当前节点只是从 DOM 树上脱离了,但是在 JS 代码中还有人在引用它,这种 DOM 称之为分离 DOM;
这种分离 DOM 在界面上是看不到的,但是在内存里面它却是占据着空间,所以在这种情况下就是一种内存泄漏。因此,就可以通过堆快照的功能去把它们从这里面都找出来只要能找得到就可以回到代码里面针对于这些代码进行清除,从而让我们当前的内存得到一些释放。
<html>
<body>
<button id="btn">Add</button>
<script>
let tempEle;
function fn () {
const $ul = document.createElement('ul');
for (let i = 0; i < 10000000; i++) {
const $li = document.createElement('li');
$ul.appentChild($li)
}
tempEle = $ul
}
document.getElementById('btn').addEventListenter('click', fn)
tempEle = null
</script>
</body>
</html>
22、判断是否存在频繁 GC
为什么确定频繁垃圾回收?
当 GC 工作的时候,当前的应用程序是停止的。所以如果说我们当前的 GC 频繁的工作,而且时间过长,这个时候对于我们的 WEB 应用就很不友好。它会处于一个假死的状态,对于用户来说就会感觉到这样一个应用是卡顿的。所以这个时候就要想办法来确定,当前应用在执行时是否存在频繁的垃圾回收。有连个方式可以迎来判断程序中是否存在频繁的垃圾回收:
- timeLine 中频繁的上升下降
- 任务管理器中数据频繁的增加减少
23、Performance 总结
Performance 使用
- Performance 使用流程
- 内存问题的相关分析:内存膨胀、内存泄漏、频繁的 GC
- Performance 时序图监控内存变化
- 浏览器任务管理器监控内存变化
- 堆快照查找分离 DOM
24、代码优化介绍
如何精准测试 JS 性能?
- 精准测试本质上就是采集大量的执行样本进行数学统计和分析:从而得出一个比对的结果来证明什么样的脚本执行效率更高。而这样的一个过程对于我们的编码来说,显得就有一点麻烦了,因为我们更多的考虑是放在使用脚本实现某一个功能。而不是做大量的数学统计;
- 使用基本 Benchmark.js 的 jsperf.com 完成;
Jsperf 使用流程:
- 使用 GitHub 账号登陆
- 填写个人信心(非必填)
- 填写详细测试用例信息 (title、slug)
- 填写准备代码 (DOM 操作时经常使用)
- 填写必要有 setup 与 teardown 代码
- 填写测试代码片段
25、慎用全局变量
在程序执行过程中如果针对某些数据需要进行存储,要尽可能的放在局部作用域当中作为一个局部变量。
为什么要慎用?
-
全局变量定义在全局执行上下文,是所有作用域链的顶端:这个上下文也就是程序在查找数据过程中所有作用域链的最顶端,如果按照层级往上查找的过程来说,下面某些局部作用域没有找到的变量最终都会查找到当前最顶端的全局执行上下文。在这种情况下查找的时间消耗是非常大的,这样一来就降低了当前代码的执行效率;
-
全局执行上下文一直存在与上下文执行栈,直到程序退出:在当前全局上下文当中定义的变量会一直存活于上下文执行栈,而这个上下文执行栈是直到当前程序退出之后才会消失的。这对于当前的 GC 工作来说也是非常不利的,因为只要我们的 GC 发现这样的一个变量还处于存活的状态,就不会把它当作垃圾对象进行回收。因此这样的做法也会降低我们当前的程序运行过程中对于内存的一个使用;
-
如果某个局部作用域出现了同名变量则会遮蔽或污染全局:
// 定义在全局环境下 var i, str = ''; for (i = 0; i < 1000; i++) { str += i } // 定义在局部变量中 for (let i = 0; i < 1000; i++) { let str = ''; str += i }
26、缓存全局变量
将使用中无法避免的全局变量缓存到局部
使用缓存全局变量可使 JS 代码在执行的时候会具有更高的执行性能,关于缓存全局变量其实指的就是在程序执行过程中针对全局变量的使用无法避免的。将大量可以重复使用的全局变量,放置到局部作用域当中从而达到一种缓存的效果。
<html>
<body>
<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" />
<input type="button" value="btn" id="btn5" />
<input type="button" value="btn" id="btn6" />
<input type="button" value="btn" id="btn7" />
<input type="button" value="btn" id="btn8" />
<input type="button" value="btn" id="btn9" />
<input type="button" value="btn" id="btn10" />
<input type="button" value="btn" id="btn11" />
<input type="button" value="btn" id="btn12" />
<input type="button" value="btn" id="btn13" />
<input type="button" value="btn" id="btn14" />
<input type="button" value="btn" id="btn15" />
<input type="button" value="btn" id="btn16" />
<input type="button" value="btn" id="btn17" />
<input type="button" value="btn" id="btn18" />
<input type="button" value="btn" id="btn19" />
<input type="button" value="btn" id="btn20" />
<input type="button" value="btn" id="btn21" />
<input type="button" value="btn" id="btn21" />
<input type="button" value="btn" id="btn22" />
<input type="button" value="btn" id="btn23" />
<input type="button" value="btn" id="btn24" />
<script>
function getBtn() {
let oBtn1 = document.getElementById('btn1')
let oBtn2 = document.getElementById('btn2')
let oBtn3 = document.getElementById('btn3')
let oBtn4 = document.getElementById('btn4')
let oBtn5 = document.getElementById('btn5')
let oBtn6 = document.getElementById('btn6')
let oBtn7 = document.getElementById('btn7')
let oBtn8 = document.getElementById('btn8')
let oBtn9 = document.getElementById('btn9')
let oBtn10 = document.getElementById('btn10')
let oBtn11 = document.getElementById('btn11')
let oBtn12 = document.getElementById('btn12')
let oBtn13 = document.getElementById('btn13')
let oBtn14 = document.getElementById('btn14')
let oBtn15 = document.getElementById('btn15')
let oBtn16 = document.getElementById('btn16')
let oBtn17 = document.getElementById('btn17')
let oBtn18 = document.getElementById('btn18')
let oBtn19 = document.getElementById('btn19')
let oBtn20 = document.getElementById('btn20')
let oBtn21 = document.getElementById('btn21')
let oBtn22 = document.getElementById('btn22')
let oBtn23 = document.getElementById('btn23')
let oBtn24 = document.getElementById('btn24')
}
function getBtn2 () {
let obj = document
let oBtn1 = obj.getElementById('btn1') let oBtn2 = obj.getElementById('btn2') let oBtn3 = obj.getElementById('btn3') let oBtn4 = obj.getElementById('btn4') let oBtn5 = obj.getElementById('btn5') let oBtn6 = obj.getElementById('btn6') let oBtn7 = obj.getElementById('btn7') let oBtn8 = obj.getElementById('btn8') let oBtn9 = obj.getElementById('btn9') let oBtn10 = obj.getElementById('btn10') let oBtn11 = obj.getElementById('btn11') let oBtn12 = obj.getElementById('btn12') let oBtn13 = obj.getElementById('btn13') let oBtn14 = obj.getElementById('btn14') let oBtn15 = obj.getElementById('btn15') let oBtn16 = obj.getElementById('btn16') let oBtn17 = obj.getElementById('btn17') let oBtn18 = obj.getElementById('btn18') let oBtn19 = obj.getElementById('btn19') let oBtn20 = obj.getElementById('btn20') let oBtn21 = obj.getElementById('btn21') let oBtn22 = obj.getElementById('btn22') let oBtn23 = obj.getElementById('btn23') let oBtn24 = obj.getElementById('btn24')
}
</script>
</body>
</html>
27、通过原型对象添加附加方法
在原型对象上新增实例对象需要的方法
在 JS 中可以通过原型链相关的一些内容,让 JS 代码在执行的时候会具有一些更高的性能。在 JS 中存在三种概念:
- 构造函数
- 原型对象
- 实例对象
实例对象和构造函数都是可以执行原型对象的,如果某一个构造函数的内部具有一个成员方法,可以让实例对象都需要频繁的去进行调用,在这里就可以直接把它添加在我们的原型对象上,而不需要把它放在构造函数内部。这中两种不同的实现方式在性能上,也会有所差异。
// 构造函数方式
var fn1 = function () {
this.foo = function () {
console.log(1111)
}
}
let f1 = new fn1()
// 原型链方式
var fn2 = function () {}
fn2.prototype.foo = function () {
console.log(22222)
}
let f2 = new fn2()
28、避免闭包陷阱
闭包特点
-
外部具有指向内部的引用
-
在外部作用域访问内部作用域的数据
function foo () { var name = 'leo'; function fn () { console.log(name) } return fn } var a = foo() a()
关于闭包
-
闭包是一种强大的语法
-
闭包使用不当很容易出现内存泄漏
-
不要为了闭包而闭包
<html> <body> <button id="btn"> Add </btton> <script> function foo () { var el = document.getElementById('btn') el.onClick = function () { console.log(el.id) } el = null // 避免闭包陷阱 } foo() </script> </body> </html>
29、避免属性访问方法使用
JavaScript 中的面向对象
-
JS 不需要属性的访问方法,所有属性都是外部可见
-
使用属性访问方法只会增加一层重定义,没有访问的控制力
// 使用属性访问方法 function Persong () { this.name = 'leo'; this.age = 18; this.getAge = function () { return this.age } } const p1 = new Person() const age1 = p1.getAge() // 不使用属性访问方法 function P () { this.name = 'leo'; this.age = 18 } const p2 = new P() const age2 = p2.age
30、For 循环优化
const arr = [1, 2, 3, 4 .... , 10000];
for (let i = 0; i < arr.length; i++) {
console.log(i)
}
for (let i = 0, len = arr.length; i < len; i++) {
console.log(i)
}
31、选择最优的循环方法
在我们实际的应用开发过程中,我们往往会遇到数据的遍历结构。而拿到大量这种数据结构之后,我们往往有多种选择来对它们进行遍历。这里我们要对:foreach、for...in、for 当我们去遍历一组相同数据时这三种方式在实践上,谁的执行效率会更加的快一些。
var arrList = new Array(1, 2, 3, 4, 5);
// forEach 最优
arrList.forEach(function(item){
console.log(item)
})
// for
for (var i = arrList.length; i; i--) {
console.log(i)
}
// for ... in
for (var i in arrList) {
console.log(arrList)
}
32、文档碎片优化节点添加
针对于 web 应用开发来说,DOM 节点的操作是非常频繁的。而针对 DOM 的交互操作,是非常消耗性能的,特别是创建一个新的节点添加至界面中时。这个过程一般都会伴随着回流和重绘的一个出现,这两个操作对于性能的消耗又是比较大的。
<html>
<body>
<button id="btn"> Add </btton>
<script>
for (var i = 0; i < 10; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
document.body.appendChild(oP)
}
// 文档碎片化方式
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)
</script>
</body>
</html>
33、克隆优化节点操作
针对于当前节点的操作,比如新增一个节点并且新增完成以后,后续可能还有很多属性以及相应方法的添加。这里还是以节点的新增配合克隆来完成优化的操作行为,
<html>
<body>
<p id="box1">old</p>
<script>
for (var i = 0; i < 10; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
document.body.appendChild(oP)
}
// 克隆 + 文档碎片化方式
const oldP = document.getElementById('box1')
const fragEle = document.createDocumentFragment()
for (var i = 0; i < 10; i++) {
var newP = oldP.cloneNode(flase);
newP.innerHTML = i
fragEle.appendChild(newP)
}
document.body.appendChild(fragEle)
</script>
</body>
</html>
33、直接量替换 Object 操作
所谓的使用直接量去替换 Oject 本意就是想表示,当我们去定义一些对象或数组的时候。有两种不同的形式:使用 new的方式、采用字面量;
var arr = [1, 2, 3]
var a1 = new Array(3)
a1[0] = 1
a1[1] = 2
a1[2] = 3
34、堆栈中的 JS 执行过程
接下来我们看一下,关于 JS 代码在执行过程中偏底层所发生的事情。这样的操作可以更加具象的表达出一段代码在栈内存和堆内存里是如何执行的,同样也有利于我们理解 GC 回收内存的工作过程。
let a = 10;
function foo (b) {
let a = 2;
function baz (c) {
console.log(a + b + c)
}
return baz
}
let fn = foo(2)
fn(3) // 7
栈内存 ECStack 执行环境栈:用于存放执行上下文
-
EC baz
-
this = window
-
<baz.ao, foo.ao, vo>
-
AO:
-
arguments(0:3)
-
c = 3
-
console.log(a + b + c)
-
3 + 2 + 2
-
EC foo
-
this = window This 指向
-
<foo.ao , vo> 作用域链
-
AO:
-
arguments:(0: 2)
-
b = 2
-
a = 2
-
baz = AB2 [[scop]] foo.AO
-
EC G 全局执行上下文:
-
VO:
-
a = 10 存放在栈内存
-
foo = AB1 [[scop]] VO 引用类型
-
fn = AB2
-
fn (3)
这个流程走完了以后,就意味着我们当前整个过程也就执行完毕,剩下的事情栈区里面的东西就叫给了 JS 主线程进行回收。
堆内存:
-
AB1 :
-
function foo (b) { ... }
-
name: foo
-
length: 1 // 形参个数
-
AB2:
-
function bax (c) { ... }
-
name: baz
-
length: 1 // 形参个数
堆内存里的内容所占据的空间会交给 GC 垃圾回收机制进行回收。根据刚才的分析其实主线程里的内容,每次在执行上下文里面的代码执行完成之后,都会检查下要不要把堆内存里的内容进行回收。比如 baz 中的代码在执行完成以后,其中的所有变量在当前作用域里的代码在执行完成以后其他作用域里面根本就没有对它进行引用。所以说这个时候的空间肯定要被进行回收的,也就是所谓的出栈,这个时候完成这个流程之后得出的结果是 7 。
总结:JS 代码在开始执行之后首先会在堆内存里面创建一个执行环境栈,并用来存放不同的执行上下文;第二、代码从上往下执行最先创建的 EC G 也就是全局的执行上下文,在它里面把全局作用域下面的一些代码都进行一些声明和存放;再有就是基本类型的数据是直接存放在栈内存里,对于引用类型的数据存放在堆区中并有 GC 来回收处理,而栈里的内容也就是出栈都是由 JS 主线程来处理。每当遇到函数执行的时候都会重新生成一个执行上下文进栈,代码执行完成以后由是否产生了闭包来决定当前的的上下文里面所引用的堆到底要不要去被释放掉。
35、减少判断层级
在我们编写代码的过程当中,有可能会去出现这种判断条件潜逃的场景。而往往在出现这种 if ... else 多层潜逃的时候,我们都可以去通过提前 return 掉这样一些无效的条件,来达到优化层级的效果。
// 优化前
function doSomething (part, charpter) {
const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
if (part) {
if (parts.includes(part)) {
console.log('属于当前课程')
if (charpter > 5) {
console.log('请提供 VIP 身份')
}
}
} else {
console.log('请确认模块信息')
}
}
// 优化后
function doSomething (part, charpter) {
if (!part) {
console.log('请确认模块信息') return
}
if(!parts.includes(part)) return
console.log('属于当前课程')
if (charpter > 5) {
console.log('请提供 VIP 身份')
}
}
doSomething('ES2016', 6)
36、减少作用域链查找层级
// 优化前
var name = 'leo';
function foo () {
name = 'D.Leo';
function baz () {
var age = 38;
console.log(age)
console.log(name)
}
baz()
}
// 优化后
function foo () {
var name = 'D.leo';
function baz () {
var age = 38;
console.log(age);
console.log(name)
}
}
foo()
37、减少数据读取次数
在 JS 当中经常使用的数据表现形式主要分为:字面量、局部变量、数组元素、对象属性,针对这四种形式访问字面量与局部变量速度是最快的。因为它们都可以直接存放到栈区当中,而访问数组元素和对象成员就会显得相对较慢一些。这是因为需要按照引用的关系,先要找到它们在堆内存当中的位置。例如对象属性的访问在去操作的时候,往往还要去考虑到原型链上的查找,因此和作用域链的道理其实是一样的。如果说减少查询的的时间消耗,就应该尽可能的减少对象的成员的查找次数和属性成员的嵌套层级,一个技巧就是提前将所要用到的数据缓存,方便在后面进行使用。
<html>
<body>
<div id="skip" class="skip"></div>
<script>
// 优化前
var oBox = dovument.getElementById('skip');
function hasEle (ele, cls) {
return els.className === cls
}
// 优化后
function hasEle (ele, cls) {
var classname = ele.className
return classname === cls
}
</script>
</body>
</html>
38、字面量与构造式
// 构造式
let test = () => {
let obj = new Object();
obj.name = 'leo';
age: 18;
return obj
}
const str = new String('I\'m string')
// 字面量
let test = () => {
let obj = {
name: 'leo',
age: 18
}
return obj
}
const str = 'I\'m string'
39、减少循环体活动
在循环次数固定的情况下,循环体里做的事情越多意味着执行的效率越慢,反之执行效率越高。把每次循环都要去用操作的数据值不变的语句都可以抽离到循环体的外部,这个功能类似于数据缓存。
// 优化前
const arr = [1, 2, 3, 4, 5];
for (let i = 0; i < arr.length; i++) {
console.log(i)
}
// 优化后
const len = arr.length
for (let i = 0; i < len; i++) {
console.log(i)
}
// 改造后
let len = arr.length
while (len--) {
console.log(arr[len])
}
40、减少声明及语句数
<html>
<body>
<div id="box" style="width:100px;height:100px"></div>
<script>
// 减少声明优化前
var oBox = dovument.getElementById('box');
function test (ele) {
let w = ele.offsetWidth
let h = ele.offsetHeight
return w*h
}
// 减少声明优化后
function test (ele) {
return ele.offsetWidth * ele.offsetHeight
}
console.log(test(oBox))
// 减少语句数优化前 function test () {
var name = 'test';
var age = 18;
var slogen = 'DBHFHNF';
return name + age + solgen
}
// 减少语句数优化后
function test () {
var name = 'test', age = 18 , slogen = 'DBHFHNF'; return name + age + solgen
} </script>
</body>
</html>
41、采用事件委托
事件委托的本质就是利用事件冒泡的机制,把原本需要绑定在子元素上的响应事件委托给了父元素,使父元素完成事件的监听。这样做的好处是,可以大量减少内存的占用从而减少事件的注册。
<html>
<body>
<ul id="box">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<script>
// 优化前
const list = document.querySlectorAll('li')
function showText (ev) {
console.log(ev.target.innerHTML)
}
for (let item of list) {
item.onClick = showTxt
}
// 优化后
const oUl = document.getElementById('ul');
oUl.addEventListener('click', showTxt, true);
function showTxt (ev) {
const obj = ev.target
if (obj.nodeName.toLowerCase === 'l1') {
console.log(obj.innerHTML)
}
}
</script>
</body>
</html>