4.1 前言
JavaScript 变量是松散类型的,由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。本章主要内容包括变量、执行上下文与作用域,以及垃圾回收的的相关知识点。
4.2 MIND
4.3 原始值与引用值
ECMAScript 变量包含两种不同类型的数据:原始值和引用值
-
原始值(primitive value)
最简单的数据,保存原始值的变量是按值(by value)访问的。
-
引用值(reference value):由多个值构成的对象
引用值是保存在内存中的对象。操作对象时,实际上操作的是对该对象的引用(reference)而非实际的对象本身。
4.3.1 动态属性
- 原始值不能有属性,原始类型的初始化可以只使用原始字面量形式。
- 只有引用值可以动态添加后面可以使用的属性
4.3.2 复制值
- 原始值赋值到另一个变量时,原始值会被复制到新变量的位置。两个变量独立使用,互不干扰。
- 引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。
4.3.3 传递参数
- ECMAScript 中所有函数的参数都是按值传递。
- 对象也是按值传递,即使按引用访问对象。
ECMAScript 中函数的参数就是局部变量。
4.3.4 确定类型
- typeof 虽然对原始值很有用,但对引用值的用处不大。
- ECMAScript 提供了 instanceof 操作符确定对象类型。
- 如果变量是给定引用类型(由其原型链决定)的实例,则 instanceof 操作符返回 true。
- 所有引用值都是 Object 的实例,而原始值不是(原始值不是对象)。
ECMA-262 规定,任何实现内部 [[Call]] 方法的对象都应该在 typeof 检测时返回”function”。
4.4 执行上下文与作用域
- 变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。
- 全局上下文是最外层的上下文。
- 浏览器中,全局上下文是 window 对象,通过 var 定义的全局变量和函数都会成为 window 对象的属性和方法。
- 用 let 和 const 的顶级声明不会定义在全局上下文中。
- 每个函数调用都有自己的上下文,ECMAScript 通过执行上下文栈控制程序执行流。
当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。
- 上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。
代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符。
函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则。
4.4.1 作用域链增强
虽然执行上下文主要有全局上下文和函数上下文两种(eval() 调用内部存在第三种上下文),但有其他方式来增强作用域链。
某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。
通常情况:
- try / catch 语句的 catch 块
- with 语句
这两种情况下,都会在作用域链前端添加一个变量对象。对 with 语句来说,会向作用域链前端添加指定的对象;对 catch 语句而言,会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。
4.4.2 变量声明
-
使用 var 的函数作用域声明
- 使用 var 声明变量时,变量会被自动添加到最接近的上下文(函数的局部上下文)。
- 变量提升:var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”(hoisting)。
-
使用 let 的块级作用域声明
- let 关键字跟 var 很相似,但它的作用域是块级的。
- let 与 var 的另一个不同之处是在同一作用域内不能声明两次。
- let 非常适合在循环中声明迭代变量,var 声明的迭代变量会泄漏到循环外部。
- 暂时性死区:严格来讲,let 在 JavaScript 运行时中也会被提升,但由于“暂时性死区”(temporal dead zone)的缘故,实际上不能在声明之前使用 let 变量。
-
使用 const 的常量声明
- 使用 const 声明的变量必须同时初始化,且在其生命周期的任何时候都不能再重新赋予新值。
- const 声明只应用到顶级原语或者对象。
赋值为对象的 const 变量不能再被重新赋值 为其他引用值,但对象的键则不受限制。
- 让整个对象都不能修改可以使用 Object.freeze(),这样再给属性赋值时虽然不会报错,但会静默失败。
const o3 = Object.freeze({}); o3.name = 'Jake'; console.log(o3.name); // undefined 复制代码
-
标识符查找
- 标识符查找过程:
- 搜索开始于作用域链前端,以给定的名称搜索对应的标识符。
- 如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(搜索可能涉及每个对象的原型链)
- 这个过程一直持续到搜索至全局上下文的变量对象。
- 如果仍然没有找到标识符,则说明其未声明。
- 引用局部变量会让搜索自动停止,而不继续搜索下一级变量对象。
- 使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次。
4.5 垃圾回收
JavaScript 通过自动内存管理实现内存分配和闲置资源回收。
基本思路:确定哪个变量不会再使用,然后释放它占用的内存。
这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。
圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存,JavaScript 有两种标记策略来标记未使用的变量:标记清理 和 引用计数。
4.5.1 标记清理
JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。
当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。
垃圾回收程序运行的时候,会标记内存中存储的所有变量(标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
4.5.2 引用计数
另一种没那么常用的垃圾回收策略是引用计数(reference counting)。
其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。
引用计数问题:循环引用
避免循环应用的有效方法是解除引用,即不使用对象或者对象属性的时候将其设置为 null。
4.5.3 性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。
现代垃圾回收程序会基于对 JavaScript 运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。
根据 V8 团队 2016 年的一篇博文的说法: “在一次完整的垃圾回收之后,V8 的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃圾回收。”
4.5.4 内存管理
内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量。
将内存占用量保持在一个较小的值可以让页面性能更好,解除引用保证在执行代码时只保存必要的数据,不必要的数据将其设置为 null。
4.5.4.1 const 和 let 声明提升性能
const 和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。
4.5.4.2 隐藏类和删除操作
V8 在将解释后的 JavaScript 代码编译为实际的机器码时会利用“隐藏类”。
运行期间,V8 会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8 会针对这种情况进行优化,但不一定总能够做到。比如下面的代码:
function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band';
}
let a1 = new Article();
let a2 = new Article();
复制代码
V8 会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。假设之后又添加了下面这行代码:
a2.author = 'Jake'; 复制代码
此时两个 Article 实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有可能对性能产生明显影响。
动态删除属性与动态添加属性导致的后果一样(不再共享一个隐藏类)。最佳实践是把不想要的属性设置为 null。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。比如:
function Article() {
this.title = 'Inauguration Ceremony Features Kazoo Band';
this.author = 'Jake';
}
let a1 = new Article();
let a2 = new Article();
a1.author = null;
复制代码
4.5.4.3 内存泄漏
写得不好的 JavaScript 可能出现难以察觉且有害的内存泄漏问题。在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。JavaScript 中的内存泄漏大部分是由不合理的引用导致的。
建议:
- 函数内尽量使用关键字 const、let、var声明变量,函数执行完毕后离开作用域。
- 定时器导致的变量长时间占用内存,合理使用定时器开启和关闭,或者及时释放变量。
- 合理使用闭包,避免比包中对象长期占用内存资源。
4.5.4.4 静态分配与对象池
如何减少浏览器执行垃圾回收的次数?
无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。
对象池
在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。
如果对象池只按需分配(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。
避免动态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作。
4.6 小结
-
原始值和引用值特点
- 原始值大小固定,因此保存在栈内存上。
- 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
- 引用值是对象,存储在堆内存上。
- 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
- 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
- typeof 操作符可以确定值的原始类型,而 instanceof 操作符用于确保值的引用类型。
-
执行上下文
- 执行上下文分全局上下文、函数上下文和块级上下文。
- 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
- 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
- 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
- 变量的执行上下文用于确定什么时候释放内存。
-
垃圾回收
- 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
- 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。
- 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。(JavaScript 引擎不再使用这种算法,但某些旧版本的 IE 仍然会受这种算法的影响,原因是 JavaScript 会访问非原生 JavaScript 对象(如 DOM 元素))
- 引用计数在代码中存在循环引用时会出现问题。
- 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。