JavaScript学习笔记(2):值、作用域与内存

原始值和引用值

动态属性

对于引用值而言,可以随时添加、修改和删除其属性和方法。

原始值不能有属性,尽管尝试给原始值添加属性不会报错。不会报错是因为原始值包装对象的存在,添加的属性添加在了包装对象上,但是添加给原始值的属性后续无法访问,因为包装对象没有被名称引用导致直接被垃圾回收了。

如果使用的是new关键字,则JavaScript会创建一个Object类型的实例,但其行为类似原始值,这其实就类似于原始值的包装对象了。

关于原始值包装对象在后续章节介绍。

复制值

在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置,两个变量独立使用互不干扰。

而引用值复制的值实际上是一个指针,它指向存储在堆内存中的对象,因此两个变量指向了同一个对象,通过两个变量对对象的修改会相互干扰。即浅拷贝。

对于JS来说,没有能够深拷贝的方法,所有对对象的拷贝默认都是浅拷贝。

传递参数

在传参时,如果参数是原始值,那么就跟原始值变量的复制一样,传递的是参数的值;如果是引用值,那么就跟引用值变量的复制一样,传递的也是“值”,但是指针的值。

类型的确定

如果要判断的是原始值的类型,那么就使用typeof操作符,它能区分出各种原始值的类型,但是如果判断的是对象。typeof操作符见“语言基础”章节。

但typeof操作符对于null和对象都返回"object",而我们更希望能够判断出某个对象属于哪个类时,使用instanceof操作符。instanceof操作符的具体原理和使用见“面向对象”章节。

有以下例子:

1
2
3
4
5
6
7
8
// 原始值
let name = "Dasen";
console.log(typeof name === "number"); // false
console.log(typeof name === "string"); // true
// 引用值
let arr = [];
console.log(typeof arr === "object"); // true
console.log(arr instanceof Array); // true

注意:对于正则表达式的检测,不同的浏览器可能结果不同,有的会返回"function",有的会返回"object"

上下文与作用域

上下文

变量或函数的上下文决定了它们可以访问哪些数据和函数(方法)。

每个上下文都有一个关联的变量对象,一般情况下我们无法通过代码访问变量对象,这个上下文中定义的所有变量和函数都存在于这个对象上。

全局上下文是最外层的上下文。根据ECMAScript实现的宿主环境,表示全局上下文的对象可能不一样。在浏览器中,全局上下文就是我们常说的window对象,因此所有通过var定义的全局变量和函数都会成为window对象的属性和方法。window对象在后续章节介绍。

使用let和const的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。

上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数都会一起销毁。

每个函数调用都有自己的上下文,函数调用栈的实现是通过上下文栈来实现的。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。

作用域链

上下文中的代码在执行的时候,会创建一个作用域链。

代码正在执行的上下文的变量对象始终位于作用域链的最前端。作用域链中的下一个变量对象为包含当前上下文的上一级上下文的变量对象,再下一个对象来自再下一个包含上下文,以此类推直至全局上下文。全局上下文的变量对象始终是作用域链的最后一个变量对象,即作用域链起于当前上下文的变量对象,终于全局上下文的变量对象。

如果上下文是函数,则其变量对象中包含了arguments变量和函数参数。

在代码中访问某个名称时,就先从当前上下文中搜索是否有该名称,如果没有,就沿作用域链向上搜索,直至搜索到全局上下文依然找不到该名称,则抛出错误。

增强上下文

with语句和try-catch语句的catch块会对上下文进行增强,在原有的上下文最前端加入一个变量对象,使得执行的代码最先在该变量对象中进行搜索名称。

对with语句来说,加入作用域链最前端的变量对象是指定的对象。关于with语句的内容见“语言基础”章节。

try-catch语句的catch块则会创建一个新的变量对象,这个变量对象包含了抛出的错误对象。关于错误处理的内容见后续章节。

变量声明

在使用var声明变量时,变量会被自动添加到最接近的可以约束到它的上下文(函数和全局)。因此在with语句中,最接近的上下文也是函数上下文。

let和const声明的作用域为块作用域。严格来讲,let在JavaScript运行时中也会被提升,但由于“暂时性死区”的缘故,不能在声明之前使用let变量,所以从编码的角度来说,可以认为它不能提升。

在使用无关键字声明的变量时,无论在哪里使用,变量都将会被加到全局上下文上。

标识符查找需要查找作用域链的每一次变量对象,然而每个变量对象还有其原型链,因此其原型链上的内容也会被查找到

垃圾回收

JS使用的垃圾回收机制主要有标记清理引用计数

标记清理

标记清理是最常用的垃圾回收策略。

当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。

而在实际中,垃圾回收程序运行的时候会标记内存中存储的所有变量,然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉,在此之后还有标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。

而给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。

引用计数

引用计数策略的思路是对每个值都记录它被引用的次数。

声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。

但是引用计数策略一般使用较少,因为它存在很大的弊端:无法解决循环引用的问题,对于循环引用的一系列变量,在它们互相的引用断开之前永远也不会被回收。

内存管理

垃圾回收程序会周期性运行。因此我们可以通过一些编码技巧来辅助提升程序的性能

变量

解除引用:将内存占用量保持在一个较小的值可以让页面性能更好,优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据,如果数据不再必要,那么把它设置为null,从而释放其引用。这最适合全局变量和全局对象的属性。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。

使用let和const声明:ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为const和let都以块(而非函数)为作用域,所以相比于使用var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。

隐藏类

V8引擎会进行“隐藏类”的优化。

运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定总能够做到。

V8会在后台配置,让这两个共享同一个构造函数和原型的类实例共享相同的隐藏类。

会使隐藏类优化失效的情况是动态地添加或删除了对象属性。解决方案就是避免JavaScript的“先创建再补充”式的动态属性赋值,也不使用delete操作符删除对象属性,从而在构造函数中一次性声明所有属性,可以让实例共享一个隐藏类,带来潜在的性能提升。

内存泄漏

JavaScript中的内存泄漏大部分是由不合理的引用导致的。常见的内存泄漏的情况:

(1)意外声明全局变量:

1
2
3
function setName() {
name = "Dasen";
}

要防止一个变量意外地成为全局变量,注意this的使用和let、const声明符的使用,并且最好永远不要使用无声明的变量。

(2)定时器造成的内存泄漏:

1
2
3
4
let name = "Dasen";
setInterval(() => {
console.log(name);
}, 1000);

只要定时器一直运行,回调函数中引用的name就会一直占用内存。

(3)闭包造成的内存泄漏:

1
2
3
4
5
6
let outer = function () {
let name = "Dasen";
return function () {
return name;
};
};

调用outer()会导致分配给name的内存被泄漏,代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理name,闭包一直在引用着它。如果这里的数据不仅仅是一个字符串,而是一个占用了更大内存的对象,就可能产生大问题。

静态分配与对象池

如果其他能使用的优化手段已经全部使用了,最后就只能想办法压榨浏览器的性能了,考虑减少浏览器垃圾回收的次数来优化性能。

浏览器发现某部分代码的变量更替速度很快(即变量很快被创建又很快被销毁)时,就会加快这里的垃圾回收程序的运行速度。优化的办法是复用对象,使用对象池,不频繁地创建和销毁对象。

但是注意在使用数组作为对象池时,要从一开始就确定好一个容量足够的对象池,否则之后再扩容反而导致了额外的垃圾回收(创建扩容后的数组,回收之前的数组)。




* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *