变量、作用域与内存
4.1 原始值与引用值
ECMAScript 变量可以包含两种不同类型的数据:原始值和引用值。
- 原始值就是 最简单的数据(基本路线),引用值则是由多个值构成的对象。
- 在把一个值赋给变量时,JavaScript 引擎必须确定这个值是原始值还是引用值。
- 6 种 原始值:Undefined、Null、Boolean、Number、String 和 Symbol。保存原始值的变量是按值访问的,因为我们操作的就是存储在变量中的实际值。
- 引用值是保存在内存中的对象。JavaScript 不允许直接访问内存位置,因此也就 不能直接操作对象所在的内存空间。
- 在操作对象时,实际上操作的是对该对象的引用而非 实际的对象本身。为此,保存引用值的变量是按引用访问的。
复制值
原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值 到另一个变量时,原始值会被复制到新变量的位置。
把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。
如果一个值是引用类型的,那么它的存储空间将从堆中分配。由于引用值的大小会改变,所以不能把它放在栈中,否则会降低变量查寻的速度。相反,放在变量的栈空间中的值是该对象存储在堆中的地址。地址的大小是固定的,所以把它存储在栈中对变量性能无任何负面影响。如下图所示:

原始值
存储在栈(stack)中的简单数据段,也就是说,它们的值直接存储在变量访问的位置。
引用值
存储在堆(heap)中的对象,也就是说,存储在变量处的值是一个指针(point),指向存储对象的内存处。
传递参数
- ECMAScript 中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数 中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是 引用值,那么就跟引用值变量的复制一样。
- 在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用 ECMAScript 的话说, 就是 arguments 对象中的一个槽位)。
- 在按引用传递参数时,值在内存中的位置会被保存在一个局部变 量,这意味着对本地变量的修改会反映到函数外部。(这在 ECMAScript 中是不可能的。)
function addTen(num) {
num += 10;
return num;
}
let count = 20;
let result = addTen(count);
console.log(count); // 20,没有变化
console.log(result); // 30
函数 addTen()有一个参数 num,它其实是一个局部变量。在调用时,变量 count 作为参数 传入。count 的值是 20,这个值被复制到参数 num 以便在 addTen()内部使用。在函数内部,参数 num 的值被加上了 10,但这不会影响函数外部的原始变量 count。参数 num 和变量 count 互不干扰,它们 只不过碰巧保存了一样的值。如果 num 是按引用传递的,那么 count 的值也会被修改为 30。
- ECMAScript 提供了 instanceof 操作符判断它是什么类型的对象。
4.2 执行上下文与作用域
- 变量或函数的上下文决定 了它们可以访问哪些数据,以及它们的行为。
- 每个上下文都有一个关联的变量对象, 而这个上下文中定义的所有变量和函数都存在于这个对象上。
- 在浏览器中,全局上下文就是我们常说的 window 对象,因此所有通过 var 定 义的全局变量和函数都会成为 window 对象的属性和方法。
- 使用 let 和 const 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。
- 上下文在其所有代码都执行完毕后会被销毁,包括定义 在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
- 每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。 在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。
- 上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定 了各级上下文中的代码在访问变量和函数时的顺序。
- 代码正在执行的上下文的变量对象始终位于作用域 链的最前端。如果上下文是函数,则其活动对象用作变量对象。活动对象最初只有 一个定义变量:arguments。(全局上下文中没有这个变量。)
- 作用域链中的下一个变量对象来自包含上 下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终 是作用域链的最后一个变量对象。
- 代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链 的最前端开始,然后逐级往后,直到找到标识符。(如果没有找到标识符,那么通常会报错。)
执行上下文的生命周期包括三个阶段:创建阶段→执行阶段→回收阶段
创建阶段(当函数被调用,但未执行任何其内部代码之前)会做以下三件事:
- 创建变量对象:首先初始化函数的参数arguments,提升函数声明和变量声明。
- 创建作用域链
- 确定this指向

从上面的流程图,我们需要记住几个关键点:
- JavaScript执行在单线程上,所有的代码都是排队执行。
- 一开始浏览器执行全局的代码时,首先创建全局的执行上下文,压入执行栈的顶部。
- 每当进入一个函数的执行就会创建函数的执行上下文,并且把它压入执行栈的顶部。当前函数执行完成后,当前函数的执行上下文出栈,并等待垃圾回收。
- 浏览器的JS执行引擎总是访问栈顶的执行上下文。
- 全局上下文只有唯一的一个,它在浏览器关闭时出栈。
作用域链增强
虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有 其他方式来增强作用域链。
某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执 行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:
- try/catch 语句的 catch 块
- with 语句(由于大量使用with语句会导致性能下降,同时也会给调试代码造成困难,因此在开发大型应用程序时,不建议使用with语句)
这两种情况下,都会在作用域链前端添加一个变量对象。对 with 语句来说,会向作用域链前端添 加指定的对象;
对 catch 语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误 对象的声明。
变量声明
- 使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函 数的局部上下文。在 with 语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了, 那么它就会自动被添加到全局上下文。
- let 关键字跟 var 很相似,但它的作用域是块级的,这也是 JavaScript 中的新概念。块 级作用域由最近的一对包含花括号{}界定。换句话说,if 块、while 块、function 块,甚至连单独 的块也是 let 声明变量的作用域。
- 使用 const 声明的变量必须同时初始化为某个值。 一经声明,在其生命周期的任何时候都不能再重新赋予新值。
- 如果想让整个对象都不能修改,可以使用 Object.freeze(),这样再给属性赋值时虽然不会报错, 但会静默失败。
- 标识符查找:
- 当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。
- 搜 索开始于作用域链前端,以给定的名称搜索对应的标识符。如果在局部上下文中找到该标识符,则搜索 停止,变量确定;
- 如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个 原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。 如果仍然没有找到标识符,则说明其未声明。
4.3 垃圾回收(执行环境负责在代码执行时管理内存)
- JavaScript通过自动内存管理实现内存分配和闲置资源回收。
- 垃圾回收程序每隔一定时间(或者说在代码执 行过程中某个预定的收集时间)就会自动运行。
- 函数中的局部变量会在函数执行时存在。此时,栈(或 堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部 变量了,它占用的内存可以释放,供后面使用。
两种主要的 标记策略:标记清理和引用计数。
标记清理
- JavaScript 最常用的垃圾回收策略是标记清理。
- 当变量进入上下文,比如在函数 内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永 远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时, 也会被加上离开上下文的标记。
- 垃圾回收程序运行的时候,会标记内存中存储的所有变量(标记方法有很多种)。然后,它 会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记 的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内 存清理,销毁带标记的所有值并收回它们的内存。
引用计数
- 没那么常用的垃圾回收策略是引用计数。
- 对每个值都记录它被 引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变 量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一 个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序 下次运行的时候就会释放引用数为 0 的值的内存。
- 容易遇到了严重的问题:循环引用。
内存管理
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行 代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用。这也可以叫 作解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用。
不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关 的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。
建议:
通过 const 和 let 声明提升性能
ES6 增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为 const和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回 收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。
隐藏类和删除操作
根据 JavaScript 所在的运行环境,有时候需要根据浏览器使用的 JavaScript 引擎来采取不同的性能优 化策略。
内存泄漏
写得不好的 JavaScript 可能出现难以察觉且有害的内存泄漏问题。
在内存有限的设备上,或者在函 数会被调用很多次的情况下,内存泄漏可能是个大问题。
JavaScript 中的内存泄漏大部分是由不合理的 引用导致的。
意外声明全局变量是最常见但也最容易修复的内存泄漏问题。
定时器也可能会悄悄地导致内存泄漏。
定时器的回调通过闭包引用了外部变量:
let name = 'Jake';
setInterval(
() => { console.log(name); }, 100);只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。垃圾回收程序当然知道这一点, 因而就不会清理外部变量。使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏。
let outer = function() { let name = 'Jake'; return function() { return name; }; }; 调用 outer()会导致分配给 name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回 的函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符 串),那可能就是个大问题了。
静态分配与对象池
动态分配对象:就是使用运算符new来创建一个类的对象,在堆上分配内存。
静态分配对象:就是直接定义,在栈上分配内存。
动态:将构造函数和析构函数定义为protected对象。
- 在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。 应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。 由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运 行。
// vectorPool 是已有的对象池 let v1 = vectorPool.allocate(); let v2 = vectorPool.allocate(); let v3 = vectorPool.allocate(); v1.x = 10; v1.y = 5; v2.x = -3; v2.y = -6; addVector(v1, v2, v3); console.log([v3.x, v3.y]); // [7, -1] vectorPool.free(v1); vectorPool.free(v2); vectorPool.free(v3); // 如果对象有属性引用了其他对象 // 则这里也需要把这些属性设置为 null v1 = null; v2 = null; v3 = null;
如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个 实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对 象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。