JavaScript 变量是松散的,变量不过是特定时间点的一个特定值的名称。由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。这样的变量很有意思,很强大,当然也有不少问题。
一、原始值与引用值
ECMAScript 变量包含两种不同类型的数据:原始值和引用值。原始值就是最简单的数据,引用值则是由多个值构成的对象。
保存原始值的变量是按值访问的,因为我们操作的就是存储在变量中的实际值,而引用值是保存在内存中的对象。
由于 js 不允许直接访问内存位置,也就不能直接操作对象所在的内存空间,所以在操作对象时,实际上操作的时该对象的引用而非实际对象本身。
1、动态属性
我们可以为引用值添加、修改和删除其属性和方法。原始值不能由属性,尽管尝试给原始值添加属性也不会报错滴。
注意,原始类型的初始化使用原始字面量就可以了,如果使用的是 new 关键字,则会创建一个 object 类型的实例,其行为类似原始值。
2、复制值
原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置,也就是一个副本。
而把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置,这里实际上复制的是一个指针,它指向存储在堆内存中的对象,这两个变量指向同一个对象。
3、传递参数
函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。
在按值传递参数时,值会被复制到一个局部变量(argument)。在按引用传递参数时,值在内存中的位置会被保存在一个局部变量,这意味着对本地变量的修改会反映到函数外部。
4、确定类型
typeof 可以判断一个变量是否为字符串、数值、布尔值或 undefined。如果值是对象或 null,那么 typeof 返回’object’。
instanceof 用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。
通过 instanceof 操作符检测任何引用值和 Object 构造函数都会返回 true。类似地,如果用 instanceof 检测原始值,则始终会返回 false,因为原始值不是对象。
二、执行上下文与作用域
1、作用域链增强
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。
全局上下文是最外层的上下文,在浏览器中就是 window 对象。因此,通过 var 定义的变量和方法都会成为 window 对象的属性和方法。
上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端,而全局上下文的变量对象始终是作用域链的最后一个变量对象。
上下文之间的连接是线性的、有序的,如果访问某个变量在当前作用域没有找到,会向上寻找,直至全局上下文。
2、变量声明
目前 var,let 和 const 可以用来声明一个变量。
var 声明
在使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函
数的局部上下文。
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 报错: sum 在这里不是有效变量
复制代码
如果 sum 没有使用 var 声明,就会添加到全局上下文中,上面的代码就不会报错了。
var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作”提升”。
var name = "Jake";
// 等价于:
name = "Jake";
var name;
// ⬆----------------⬇
function fn1() {
var name = "Jake";
}
// 等价于:
function fn2() {
var name;
name = "Jake";
}
复制代码
注意,是变量声明的提升!声明的提升意味着会输出 undefined 而不是 Reference Error。
let 声明
let 是块级作用域,块级作用域由最近的一对包含花括号{}界定。
let 与 var 的另一个不同之处是在同一作用域内不能声明两次。重复的 var 声明会被忽略,而重
复的 let 声明会抛出 SyntaxError。
注意,我们不能在声明之前使用 let 变量,禁止在 for 循环使用 var 声明迭代变量。
const 声明
使用 const 声明的变量必须同时初始化为某个值。一经声明,在其生命周期的任何时候都不能再重新赋予新值,但是对象的键不受影响。
如果要让整个对象都不能被修改,可以使用 Object.freeze()。
const o3 = Object.freeze({});
o3.name = "Jake";
console.log(o3.name); // undefined
复制代码
三、垃圾回收
JavaScript 通过自动内存管理实现内存分配和闲置资源回收。
基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。
我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。此时,栈 (或堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用。
垃圾回收主要有两种标记策略:标记清理和引用计数:
1、标记清理
JavaScript 最常用的垃圾回收策略是标记清理。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。
垃圾回收程序运行的时候,会标记内存中存储的所有变量(标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。
2、引用计数
引用计数策略不那么常用。它的思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。
这种策略的一个严重问题是:循环引用,就是对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A。
function problem() {
let objectA = new Object();
let objectB = new Object();
objectA.someOtherObject = objectB;
objectB.anotherObject = objectA;
}
复制代码
在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,objectA 和 objectB 在函数结束后还会存在,因为它们的引用数永远不会变成 0。如果函数被多次调用,则会导致大量内存永远不会被释放。
所以,我们在编码时,对于那些不需要使用的变量要清除与引用值之间的关系,当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。
myObject.element = null;
element.someObject = null;
复制代码
内存管理
优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用。这也可以叫作解除引用。
不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。
我们尽量使用 let 和 const 声明变量有利于改进垃圾回收的过程。
内存泄漏
意外声明全局变量是最常见但也最容易修复的内存泄漏问题,如下:
function setName() {
name = "Jake";
}
复制代码
解释器会把变量 name 当作 window 的属性来创建,只要 window 本身不被清理就不会消失。
此外定时器也会导致内存泄漏:
let name = "Jake";
setInterval(() => {
console.log(name);
}, 100);
复制代码
只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。
使用闭包也很容易造成内存泄漏:
let outer = function () {
let name = "Jake";
return function () {
return name;
};
};
复制代码
调用 outer()会导致分配给 name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符串),那可能就是个大问题了。
总结
- 原始值大小固定,因此保存在栈内存上。
- 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
- 引用值是对象,存储在堆内存上。
- 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
- 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
- typeof 操作符可以确定值的原始类型,而 instanceof 操作符用于确保值的引用类型。
- 执行上下文分全局上下文、函数上下文和块级上下文。
- 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
- 变量的执行上下文用于确定什么时候释放内存。
- 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
- 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。
- 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。
- 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。