JS高级程序设计——阅读笔记二


本系列博客主要是面向自己创作,实属自己的读书笔记,注重记录一些重点,并摘录引用一些大佬对于部分知识点的解释,帮助自己翻阅和理解,一定要配合原著食用。

开始之前首先对第三章的一个知识点进行探讨

第三章描述插值字面量的时候有这样一段代码:

let foo = {toString: () => 'World'}
console.log(`Hello,${foo}!`)

上述代码会输出:Hello,World!
简单的两行代码却存在很多基础的知识点,我们来梳理一下这段代码出现的几个知识点:

  • JS中的对象字面量声明的原型是Object,并且有大量的原型函数,可以通过Prototype调用。
  • 如上图所示,toString是原型方法之一,所以代码中的toString: () => 'World'实际上是对原型方法的toString的重写,让其返回了'World'
  • 箭头函数直接返回的时候可以不写rerturn

第四章 变量、作用域与内存

4.1 原始值与引用值

4.1.2 复制值

原始值的复制较为简单,主要需要注意的是引用值的复制,分为深拷贝和浅拷贝
理解原理就知道,对于引用值的浅拷贝只是不同的指针指向统一存储空间,深拷贝的做法是新开辟内存空间来对原来的引用值内容进行存储。

4.1.3 传递参数

注意原始值和引用值的区别即可,原始值在传递给函数赋值形参的时候实际上是开辟了新空间存储,而引用值在作为形参传递的时候传递的是一个指针,指向原来的引用值,所以即使在函数作用域内修改形参也会使得外部作用域的引用值发生改变,很多封装的操作数组的方法一定要注意该方法返回的到底是新数组还是原数组。

4.1.4 确定类型

以往在使用中经常忘记instanceof的用法,书上有一个注释记忆比较方便。

person instanceof Object//变量person是Object吗?返回true或false

4.2 执行上下文与作用域

这部分内容是我认为较为重要的,因为能够很大程度上提高个人对JS的理解。
当代码运行时,会产生一个对应的执行环境,在这个环境中,所有变量会被事先提出来(变量提升),有的直接赋值,有的为默认值 undefined,代码从上往下开始执行,就叫做执行上下文。

  • 全局执行上下文 — 这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的 window对象(浏览器的情况下),并且设置 this 的值等于这个全局对象。一个程序中只会有一个全局执行上下文。
  • 函数执行上下文 — 每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
4.2.1 作用域链

一般情况下,变量取值到 创建 这个变量 的函数的作用域中取值。但是如果在当前作用域中没有查到值,就会向上级作用域去查,直到查到全局作用域,这么一个查找过程形成的链条就叫做作用域链。

var color = 'blue';

function changeColor(){
	let anotherColor = 'red';
	function swapColors(){
		let tempColor = anotherColor;
		anotherColor = color;
		color = tempColor;
	}
	swapColors();
}
changeColor();

在这里插入图片描述
书上的图结合代码就很容易理解这个作用域链了。

4.2.2 变量声明

关键是要记得letvar都是有函数作用域的,不同之处在于使用var的情况下if这种带花括号的不属于块级作用域的声明,花括号内部声明的变量外部依然可以访问,但是let进行声明的话,花括号内部的变量就不能由外部访问,因为花括号内部再ES6中来说属于块级作用域。

4.3 垃圾回收

JS的垃圾回收机制是我之前都没有仔细进行了解的部分,大概了解函数作用域一旦完成任务就会被清除。
主要有两种方式:引用计数和标记清除。

4.3.1 标记清理

这是 JavaScript 中最常见的垃圾回收方式。为什么说这是种最常见的方法,因为从 2012 年起,所有现代浏览器都使用了标记-清除的垃圾回收方法,除了低版本 IE采用的是引用计数方法。

那什么叫标记清除呢?JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象…对这些活着的对象进行标记,这是标记阶段。清除阶段就是清除那些没有被标记的对象。

标记-清除法的一个问题就是不那么有效率,因为在标记-清除阶段,整个程序将会等待,所以如果程序出现卡顿的情况,那有可能是收集垃圾的过程。

2012 年起,所有现代浏览器都使用了这个方法,所有的改进也都是基于这个方法,比如标记-整理方法。

标记清除有一个问题,就是在清除之后,内存空间是不连续的,即出现了内存碎片。如果后面需要一个比较大的连续的内存空间时,那将不能满足要求。而标记-整理方法可以有效地解决这个问题。标记阶段没有什么不同,只是标记结束后,标记-整理方法会将活着的对象向内存的一边移动,最后清理掉边界的内存。不过可以想象,这种做法的效率没有标记-清除高。

4.3.2 引用计数

引用计数是不常用的垃圾回收方式,现在基本上已经被摒弃了。

4.3.3 性能

浏览器是可以主动触发垃圾回收机制的。不讨论IE浏览器,opera7可以使用window.opera.collect()可以主动进行垃圾回收。
但是这都是不推荐的,而且我在chrome浏览器使用这段代码的时候会报错:
在这里插入图片描述
由于V8引擎的垃圾回收机制,不允许人为手动触发,无法对内存管理进行任何干预;
所以实际工作中,大家都是通过手动设置null来进行内存优化的。

4.3.4 内存管理

在工作过程中,解除引用的方式是最常用的,实际上就是当数据不再必要,就将其设置为null,进而释放引用,尤其是在一些较为常用的上下文作用域中。
const和let对性能也是有提升的,块级作用域的意义实际上并不只是存在于代码更不容易出错,更加存在于垃圾回收机制之中。当块级作用域比函数作用域更早终止的情况下,就有可能更早的进行垃圾回收程序的介入。

隐藏类

V8在将解释后的JS代码编译为实际的机器码时会利用“隐藏类”。

这句话是十分难以理解的,尤其是在读到第二句时:

如果你的代码非常注重性能,那么这一点可能对你非常重要。

其实我对自己的代码性能是十分注重的,所以我要认真的学习一下这部分内容。

会造成出现两个隐藏类的情况:
动态增加或者删除某个类内属性,同成会造成出现多个隐藏类的情况,如下:
动态添加:

    function a() {
        this.title = 'pdd yyds';
    }
    let a1 = new a();
    let a2 = new a();
    //这个时候,两个实例就共用了同样的一个隐藏类
    a2.author = 'Jake';
    //这个时候,两个实例就产生了两个隐藏类

动态删除:

    function a() {
        this.title = 'pdd yyds';
        this.author = 'Jake';
    }
    let a1 = new a();
    let a2 = new a();
    //这个时候,两个实例就共用了同样的一个隐藏类
    delete a1.author;
    //这个时候,两个实例就产生了两个隐藏类

实际上的解决方式是避免JS的“先创建再补充”方式的动态属性赋值,并在构造函数中一次性声明所有属性。
一次性声明:

    function a(opt_author) {
        this.title = 'pdd yyds';
        this.author = opt_author;
    }
    let a1 = new a();
    let a2 = new a('Jake');
    //这个时候,两个实例就共用了同样的一个隐藏类

由于动态删除属性会导致多个隐藏类的问题,所以当想要删除某个属性的时候可以将其直接赋值为null

    function a() {
        this.title = 'pdd yyds';
        this.author = 'Jake';
    }
    let a1 = new a();
    let a2 = new a();
    //这个时候,两个实例就共用了同样的一个隐藏类
    a2.author = null;
    //这个时候,两个实例仍然在使用同一隐藏类
内存泄露

书中举例的三种内存泄漏demo除了第三个都比较容易理解。我们可以结合之前提到的标记清理垃圾回收机制了解。
首先看一个标记清理的垃圾回收机制:

当运行addTen()这个函数的时候,就是当变量进入环境时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

function addTen(num){  
    var sum += num;  //垃圾收集已将这个变量标记为“进入环境”。
    return sum;      //垃圾收集已将这个变量标记为“离开环境”。
}
addTen(10);  //输出20

接下来我们看书上举得三个例子就更容易理解了:
①意外声明全局变量:

    function setName() {
        name = 'Jake';
    }

显然,没有在函数作用域内声明的变量,解释的时候会去上一层的作用域中查找,查找后发现name变量没有任何使用关键字进行声明的地方,此时,解释器会把变量name当作window的属性来创建,相当于window.name = 'Jake'。又由于前面垃圾回收机制的例子我们得知,当window作用域下声明了一个变量后,如果该作用域不结束,作用域内的变量是不会被标记为“离开环境”的,故这种全局声明的变量不会被回收,解决方式很简单,就是在函数作用内声明时使用let等声明关键字就行了。

②定时器内存泄漏:

let name = 'Jake';
setInterval(() => {
    console.log(name);
},100);

我们在控制台输出一下window对象可以发现,定时器是被作为全局对象的方法进行使用的。
在这里插入图片描述
那么就可以将这段代码理解为,声明name变量后,这个变量已经被标记为“进入环境”,但是在全局作用域下被定时器最为回调使用,引用了外部变量产生了闭包,因此这个变量不会在当前作用域结束时被标记为“离开环境”。只要定时器一直运行,回调函数中引用的name变量就会一直占用内存,所以会造成内存泄漏。

③闭包

let outer = function() {
    let name = 'Jake';
    return function() {
        return name;
    };
};

这个是我一开始最不能理解的内存泄漏,但是对照了标记清理机制的demo就变得容易理解了,只需要一层层的抽丝剥茧即可。
首先,对name变量的声明表示name变量被标记“进入环境”,但是在return的时候发现,name变量还没完成使命,就像之前所提的:

垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象…对这些活着的对象进行标记

那么这个name变量就暂时无法被标记为“离开环境”,只要该函数返回的函数引用了这个name,那么name就永远无法被标记为“离开环境”,因为闭包一直在引用这个变量且outer函数一直存在,不被标记就意味着垃圾回收机制无法对其进行清楚,这相当于卡bug了,所以如果说name的内容很大,那么就会对代码的性能产生不小的影响。

思考: 由于书中4.1.3小节中提到,函数接收形参的时候,对于原始值,会重新开辟一块空间用来存储原始值,而对于引用值,如果传递的是某个引用值,错误的理解是:当在局部作用域中修改对象而变化反应到全局时,就意味着参数是按引用传递的。 如何理解呢?书中给出了一个例子,说明为何大家会出现误解:

function setName(obj) {
    obj.name = 'Nick';
}
let person = new Object();
setName(person);
console.log(person.name);//输出了'Nick'

这时,大家会误认为传递给函数的形参实际上只是对象实例的一个引用指针,该指针指向对象实例的存储空间,然而真相是,当你传递某个实例对象给函数并重写时,函数内部自行开辟了一部分空间,重新创建了一个对象实例,通过以下例子可以明显的得出这个结论:

function setName(obj) {
    obj.name = 'Nick';
    obj = new Object();
    obj.name = 'Vone';
}

let person = new Object();
setName(person);
console.log(person.name);//这里输出的依旧是'Nick'

这说明,函数执行完毕后,原始的引用依然没变,函数内部对于形参obj的改动并没有影响到原始的person实例对象。可以得到结论:obj在函数内部被重写时,它变成了一个指向本地对象的指针。而这个本地对象在函数指执行结束时就被销毁了!
那么问题来了!!如果产生了上述闭包,使得一个复杂的实例对象被作为回调的形参传递给了一个定时器,并且在回调内部对实例对象进行了部分重写,那这时候,就会产生一个无法被垃圾回收机制标记清除的实例对象,毫无疑问,这对于代码的性能会造成不小的影响,工作中一定要细心注意。

静态分配与对象池

首先,使用静态分配和对象池对性能进行优化的主要思路是通过减少不必要的垃圾回收次数来保护由于多次释放内存而损失的性能。
书中给出了两个demo来说明,第一个是计算二维矢量加法的函数:

function addVector(a,b) {
    let resultant = new Vector();
    resultant.x = a.x + b.x;
    resultant.y = a.y + b.y;
    return resultant;
}

这个函数在调用时内部会在堆上创建一个新对象,并在函数结束后被销毁,那么当这个函数被频繁调用的时候,就会很快的叠加垃圾回收机制的标准——对象更迭速度。一旦达到阈值,就会进行垃圾回收,所以频繁的调用该函数等于频繁的触发垃圾回收!

书中给出的解决方案是不要在函数内部动态的创建矢量对象,例如:

function addVector(a,b,resultant) {
    resultant.x = a.x + b.x;
    resultant.y = a.y + b.y;
    return resultant;
}

通过传递实例化的矢量参数resultant来达到不去频繁创建和销毁对象的作用,但是这和我之前的认知产生悖论了。。。。
问题:
之前提到,当实例对象被作为形参传递时,实际上也是按值传递的!当你传递某个实例对象给函数并重写时,函数内部自行开辟了一部分空间,重新创建了一个对象实例,该实例会在函数执行完毕后被销毁,那这不还是进行了一变对象的创建和销毁吗? 这算哪门子解决方案。。。

那我就不得不亲自去验证一下书中的这个说法了,C语言中可以通过输出指针变量的十六进制来查看变量的存储位置,虽然是虚拟的,但是在值传递和引用传递的概念上,我们只需要对比两个存储位置是否相同就能进行判断,显然,相通的存储位置就说明是引用传递,不同的存储位置就是值传递。

问题是JS没有这类方式查看变量的存储位置。。。我真是服了。经过查找资料发现可以使用chrome自带的heap snapshot查看一个等价地址,虽然并不是真实地址,但是也能够帮助我们进行判断。

我们先用下面这段代码测试使用函数传递实例对象并改变其属性,并观察改变前后属性的地址是否发生变化:

function setName(obj) {
    obj.name = 'Vone';
}
let person = new Object();
person.name = 'Nick';
setName(person);
console.log(person.name);

在这里插入图片描述
我们将断点打在如图所示,观察Nick的位置:
在这里插入图片描述
我们再将断点搭载如图所示,观察Vone的位置:
在这里插入图片描述
ps.具体操作是点击memory tab,然后take heap snapshot 在下面的Object用Ctrl+F呼出输入想要查找的内容就可以了。

我们发现person对象的内存标记从 Context@412375 变成了Context@440421
我认为这可以说明对象在作为作为参数被函数重写以后,原始的那个对象就被销毁了,原始的指向变成了重写后的对象。

也就是说,即使把对象作为参数进行传递,只要对其属性进行了重写,那就存在着对象的创建和销毁,不过传递过程可能没有对象的创建,但是一定有对象的销毁

所以本小节紧接着提出了对象池的概念用来减少垃圾回收机制的触发频率。

实验:
结合我师傅的提议,决定使用console.time()进行实验探究使用函数传参的方式改变对象属性和直接修改对象属性的效率区别,测试代码如下:

function addCount(obj) {
    obj.count++;
}
let person = new Object();
person.count = 1;
console.time();
for (i = 0; i < 100000; i++) {
    addCount(person);
}
console.timeEnd();
console.time();
for (i = 0; i < 100000; i++) {
    person.count++;
}
console.timeEnd();
函数传参直接修改
1.567138671875 ms1.080810546875 ms
1.80419921875 ms1.150146484375 ms
1.652099609375 ms1.096923828125 ms
1.65087890625 ms1.088134765625 ms
1.639892578125 ms1.088134765625 ms

通过五组对比可以看出,确实通过函数传参的方式效率要远低于直接修改的方式,这也证明了函数传参方式也频繁的触发了垃圾回收机制。

对象池是解决这类问题的方案之一,通过创建对象池统一管理可回收的对象,使得垃圾回收机制不会频繁检测到对象的更替,对象池demo可见传送门

最后需要注意的一点是JS的数组大小动态可变,但是实际上超过初始化时指定的大小,引擎会删除原先的数组,进而重新创建一个新的大数组,所以如果频繁的使用超过初始化大小的数组也会触发垃圾回收机制进而影响性能,所以一定要在初始化时就创建大小够用的数组。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值