大前端进阶-JS性能优化

内存管理
内存由可读写单元组成,表示一片连续可操作的空间。在编程时,可以通过 主动操作 来申请,使用和释放可操作空间。 内存管理 指的就是主动操作过程,也就是申请内存,使用内存和释放内存。

// 申请内存
let str
// 使用内存
str = 'foo'
// 释放内存
str = null // 不再引用,垃圾回收会自动回收内存

垃圾回收
当内存不再被使用时,其会被视为垃圾,然后被释放(回收)。

在JavaScript中,垃圾回收是自动进行的。

如何判断垃圾内存?

1.对象不再被引用。
2.对象不能从根上访问到。

“根”在js中,可以将根看作全局对象。不能从根上访问到指的就是不能从全局对象上通过某条路径找到,可以是直接挂载在全局对象上,也可以是间接挂载在全局对象上。
function fn(obj1, obj2) {
    obj1['next'] = obj2
    obj2['pre'] = obj1
    return {
        o1: obj1,
        o2: obj2
    }
}
const obj = fn()

上述代码的关系如下图所示,此时obj,obj1,obj2都可以从全局对象上找到,因此不能当作垃圾被回收。


可达对象
可到对象指的是能访问到的对象,访问的方式可以是引用,也可以是通过作用域链查找到。

判断一个对象是否是可达对象的标准就是从根出发是否可以被找到。

GC算法
GC可以理解为是垃圾回收机制的简写。算法也就指的是查找垃圾,回收垃圾的规则。

常用的GC算法包含以下几个:

1.引用计数
2.标记清除
3.标记整理
4.分代回收

引用计数算法
通过引用计数器设置内存的引用数,当内存的引用关系发生改变的时候修改引用数,当引用数为0的时候内存立即被回收。

// {name: 'zs'}所在的空间是一块内存
// 此时obj1引用这块内存,所以引用计数器上记为1
let obj1= {name: 'zs'}
// obj2 同样引用了这块内存,所以引用计数器为2
let obj2 = obj1
// obj1 不再引用这块内存,所以计数器变为1
obj1 = null
// obj2也不再引用这块内存,此时计数器为0.这块内存会被当作垃圾回收
obj2 = null

算法优点:

发现垃圾时立即回收。
最大程度减少程序暂停(垃圾回收时程序会被暂停,如果回收的速度快,那么暂停的时间也就越少)。
算法缺点:

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

function fn() {
    const obj1 = { name: 'zs' }
    const obj2 = { name: 'ls' }
    // 在方法执行完毕以后,obj1和obj2应该被当作垃圾被回收,但是由于其相互引用,此时引用计数器上不为0, 所以无法回收
    obj1['friend'] = obj2
    obj2['friend'] = obj1
}
fn()

时间开销大(由于需要引用计数器,当引用计数器对象越大,每次修改引用数的时间越长)。
标记清除算法
标记清除算法将垃圾回收分为标记和删除阶段,其算法步骤如下:

1.遍历所有对象,找到活动对象进行标记。
3.遍历所有对象,找到所有没有标记的对象并清除。
如下图所示,第一不找到所有活动对象,由于ABCDE可以通过全局对象找到,所以被标记,a1和b1不能通过全局对象找到,所以不会被标记。第二步,找到没有被标记的a1和b1,将其当作垃圾回收。

与引用计数算法相比。

优点:

可以回收循环引用的对象
缺点:

回收后内存地址可能不再连续,造成碎片化。
假设内存中有一段连续的内存空间ABCDEF,如果BCDE被标记为活动对象,AB和F没有被标记,那么AB,F会被当作垃圾回收。回收完成后,造成存在AB和F两个碎片内存可以被使用,其只能放入对应长度的数据。

标记整理算法
标记整理算法和标记清除算法类似,只是多了整理内存步骤。

遍历所有对象,找到活动对象进行标记。
遍历所有对象,整理标记的内存,然后找到所有没有标记的对象并清除。
通过整理,可以解决标记清除算法造成内存碎片化的问题。

V8引擎
V8是一款主流的JavaScript执行引擎,采用即时编译,内存有限制(64位1.5G,32位800M)。

垃圾回收策略
js中的数据分为原始数据和对象引用数据两种,其中原始数据是由语言本身去处理,所以此处的垃圾回收策略主要针对栈上的对象引用数据。

V8采取分代回收的策略,由于v8对内存大小有限制,所以其将内存分成新生代和老生代两种,不同的生代采取不同的垃圾回收策略。

V8主要采取的GC算法有如下:

1.分代回收
2.空间复制
3.标记清除
4.标记整理
5.标记增量

新生代
V8将内存分为两块,其中小的空间称为 新生代 (64位32M/32位16M),其主要存储存活时间较短的对象。新生代内部同样分为两个等大小的空间From和To,通过空间复制和标记整理两个算法完成垃圾回收。

From为使用空间,To为空闲空间,活动对象存储在From。
标记整理后从From拷贝到To。
清理From,将From和To交换空间。
From到To的拷贝过程可能出发晋升,也就是从新生代拷贝到老生代,下面两种情况将出发晋升。

一轮GC之后还存活的新生代。
To空间的使用率超过25%。

老生代
老生代指的是空间较大的内存块(64位1.4G,32位700M),其内部存储存活时间长的对象,采用标记清除,标记整理和增量标记三种算法实现垃圾回收。

首先采用标记清除进行垃圾回收(会遗留空间碎片)。
新生代向老生代拷贝并且老生代存储区不足的时候进行空间优化(标记整理)。
采用增量标记进行效率优化(js代码执行和垃圾回收互斥,执行垃圾回收时无法执行js代码,增量标记指的时将遍历对象进行标记的过程拆分成多个小的执行段,这样js代码执行和标记过程可交叉进行)。

内存问题
js代码在浏览器中执行的时候,可能出现的和内存相关的问题如下:

内存泄露: 内存使用持续增加。
内存膨胀: 内存使用短时间内暴涨,超过内存限制。
分离Dom: Dom节点没有在Dom树上,被变量引用导致无法回收。
频繁GC: GC操作会暂停代码执行,频繁GC会使得页面卡顿。

代码优化
慎用全局变量
全局变量会导致的问题如下:

全局变量存在于全局上下文,全局上下文是作用域链的顶端,当通过作用域链进行变量查找的时候,会延长查找时间。
全局执行上下文会一直存在于上下文执行栈,直到程序推出,这样会影响GC垃圾回收。
如果局部作用域中定义了同名变量,会遮蔽或者污染全局。

缓存全局变量
将不可避免的全局变量缓存到局部作用域中,减少查找时间,优化性能。适用于在局部作用域中 频繁 使用某个全局变量。

function query() {
    // 在局部作用域中直接使用全局的document变量,在执行时,局部作用域找不到该变量,会沿着作用域链向上查找直到在全局中找到
    return document.getElementsByTagName('input')
}

function query1() {
    // 通过将全局变量赋值给局部变量,那么查找时直接在局部作用域找到,不用再向上查找
    let dom = document
    return dom.getElementsByTagName('input')
}

通过原型新增方法
在为所有的实例对象添加共享方法的时候,通过原型定义比在构造函数中通过this定义性能更好。这是由于构造函数中this定义的方法在每个实例中都会保存一份单独的引用,而通过原型定义,所有的实例会指向同一个引用。

function Person() {
    // 每个实例对象都会保存一份say的引用,10个就会有10个内存引用
    this.say = function () {
        console.log(1)
    }
}
const zs = new Person()

function Person1() { }
// 所有实例的原型都指向一个内存引用,减少内存开销
Person1.prototype.say = function () {
    console.log(1)
}
const ls = new Person1()

避开闭包陷井

闭包是指在外部作用域中可以使用内部作用域中的变量。

function foo() {
    let str = 'foo'
    return function () {
        console.log(str)
    }
}

let f = foo()
// f在外部执行的时候依然能够访问foo作用域中的str变量
f()

闭包是一种常见写法,可以解决js编程中的很多问题,但是由于内部作用域中的变量被外部引用,所以此变量不能被垃圾回收,如果使用不当很容易造成内存泄露,因此在编程中不能 **为了闭包而闭包 **。

避免属性访问方法使用
js在编写类的时候,很容易的出现在类上提供一个方法,该方法用于访问类内部的一个属性。

function Person() {
    this.name = 'foo'
    // 为了便于控制,在属性的访问上添加了一层
    this.getName = function () {
        return this.name
    }
}
const zs = new Person()
console.log(zs.getName)

function Person1() {
    this.name = 'foo'
}
const ls = new Person1()
// 直接访问属性
console.log(ls.name)

通过jsperf测试,发现直接访问会比包装访问要快的多。因此抛开代码编写规范,单从执行速度上来讲,直接访问更快。
for循环优化

let arr = Array(100).fill('foo')
// 每次循环都要获取数组长度
for (let i = 0; i < arr.length; i++) {
    console.log(i)
}
// 缓存数组长度,
for (let i = 0, len = arr.length; i < len; i++) {
    console.log(i)
}

缓存数组长度for循环执行速度要更快,特别适合非常大或者非常复杂的数组遍历。

选择最优的循环方式

let arr = Array(100).fill('foo')
arr.forEach(function (item) {
    console.log(item)
})

for (let i = 0, len = arr.length; i < len; i++) {
    console.log(i)
}

for (let i in arr) {
    console.log(arr[i])
}

通过jsperf工具发现,forEach的执行速度最快,因此在不影响功能的前提下,尽量使用forEach可加快代码的执行速度。

节点添加优化
在平常的js代码编写过程中,常常伴有Dom节点的添加,由于Dom节点添加操作常常伴有回流和重绘,这两个操作比较耗时,可以使用文档碎片优化这种耗时操作。

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

let fraEls = document.createDocumentFragment()
for (let i = 0; i < 10; i++) {
    let p = document.createElement('p')
    fraEls.append(p)
}
document.body.append(fraEls)

我目前是在职前端开发,如果你现在也想学习前端开发技术,在入门学习前端的过程当中有遇见任何关于学习方法,学习路线,学习效率等方面的问题,你都可以申请加我的Q群链接里面看一下,希望能够对你们有所帮助。有我做前端技术这段时间整理的一些前端学习手册,前端面试题,前端开发工具,PDF文档书籍教程,需要的话可以私聊我获取。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值