目录
注:本文适合有基础并有系统复习需求的同学
4.1 原始值与引用值
4.1.1 动态属性
4.1.2 复制值
4.1.3 传递参数
4.1.4 确定类型
4.2 执行上下文与作用域
4.2.1 作用域链增强
4.2.2 变量声明
4.3 垃圾回收
4.3.1 标记清理
4.3.2 引用计数
4.3.3 性能
4.3.4 内存管理
4.4 小结
ECMAScript 变量可以包含两种不同类型的数据:原始值和引用值。原始值(primitive value)就是 最简单的数据,引用值(reference value)则是由多个值构成的对象。
保存原始值的变量是按值(by value)访问的,因为我们操作的就是存储在变量中的实际值。 引用值是保存在内存中的对象。与其他语言不同,JavaScript 不允许直接访问内存位置,因此也就 不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用(reference)而非 实际的对象本身。
原始值和引用值的定义方式很类似,都是创建一个变量,然后给它赋一个值。不过,在变量保存了 这个值之后,可以对这个值做什么,则大有不同。对于引用值而言,可以随时添加、修改和删除其属性 和方法。
除了存储方式不同,原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值 到另一个变量时,原始值会被复制到新变量的位置。
let num1 = 5;
let num2 = num1;
这里,num1 包含数值 5。当把 num2 初始化为 num1 时,num2 也会得到数值 5。这个值跟存储在 num1 中的 5 是完全独立的,因为它是那个值的副本。 这两个变量可以独立使用,互不干扰。
在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区 别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际 上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来。
在这个例子中,变量 obj1 保存了一个新对象的实例。然后,这个值被复制到 obj2,此时两个变 量都指向了同一个对象。在给 obj1 创建属性 name 并赋值后,通过 obj2 也可以访问这个属性,因为 它们都指向同一个对象。
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 互不干扰。
例:变量中传递的是对象,
function setName(obj) { obj.name = "Nicholas"; } let person = new Object(); setName(person); console.log(person.name); // "Nicholas"
这一次,我们创建了一个对象并把它保存在变量 person 中。然后,这个对象被传给 setName() 方法,并被复制到参数 obj 中。在函数内部,obj 和 person 都指向同一个对象。结果就是,即使对象 是按值传进函数的,obj 也会通过引用访问对象。当函数内部给 obj 设置了 name 属性时,函数外部的 对象也会反映这个变化,因为 obj 指向的对象保存在全局作用域的堆内存上。很多开发者错误地认为, 当在局部作用域中修改对象而变化反映到全局时,就意味着参数是按引用传递的。为证明对象是按值传 递的,我们再来看看下面这个修改后的例子:
function setName(obj) { obj.name = "Nicholas"; obj = new Object(); obj.name = "Greg"; } let person = new Object(); setName(person); console.log(person.name); // "Nicholas"
这个例子前后唯一的变化就是 setName()中多了两行代码,将 obj 重新定义为一个有着不同 name 的新对象。当 person 传入 setName()时,其 name 属性被设置为"Nicholas"。然后变量 obj 被设置 为一个新对象且 name 属性被设置为"Greg"。如果 person 是按引用传递的,那么 person 应该自动将 指针改为指向 name 为"Greg"的对象。可是,当我们再次访问 person.name 时,它的值是"Nicholas", 这表明函数中参数的值改变之后,原始的引用仍然没变。当 obj 在函数内部被重写时,它变成了一个指 向本地对象的指针。而那个本地对象在函数执行结束时就被销毁了。
注: ECMAScript 中函数的参数就是局部变量。
typeof 操作符最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一 个变量是否为字符串、数值、布尔值或 undefined 的最好方式。
typeof 虽然对原始值很有用,但它对引用值的用处不大。我们通常不关心一个值是不是对象, 而是想知道它是什么类型的对象。为了解决这个问题,ECMAScript 提供了 instanceof 操作符。
如果变量是给定引用类型(由其原型链决定,将在第 8 章详细介绍)的实例,则 instanceof 操作 符返回 true。
按照定义,所有引用值都是 Object 的实例,因此通过 instanceof 操作符检测任何引用值和 Object 构造函数都会返回 true。类似地,如果用 instanceof 检测原始值,则始终会返回 false, 因为原始值不是对象。
注: typeof 操作符在用于检测函数时也会返回"function"。当在 Safari(直到 Safari 5) 和 Chrome(直到 Chrome 7)中用于检测正则表达式时,由于实现细节的原因,typeof 也会返回"function"。ECMA-262 规定,任何实现内部[[Call]]方法的对象都应该在 typeof 检测时返回"function"。因为上述浏览器中的正则表达式实现了这个方法,所 以 typeof 对正则表达式也返回"function"。在 IE 和 Firefox 中,typeof 对正则表达式 返回"object"。
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object), 而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台 处理数据会用到它。
全局上下文是最外层的上下文。根据 ECMAScript 实现的宿主环境,表示全局上下文的对象可能不一 样。在浏览器中,全局上下文就是我们常说的 window 对象(第 12 章会详细介绍),因此所有通过 var 定 义的全局变量和函数都会成为 window 对象的属性和方法。使用 let 和 const 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义 在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。 在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。
ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。 上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定 了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域 链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有 一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上 下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终 是作用域链的最后一个变量对象。
例:
var color = "blue"; function changeColor() { if (color === "blue") { color = "red"; } else { color = "blue"; } } changeColor();
对这个例子而言,函数 changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就 是定义 arguments 对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量 color,就是因为可以在作用域链中找到它。
此外,局部作用域中定义的变量可用于在局部上下文中替换全局变量。
例:
var color = "blue"; function changeColor() { let anotherColor = "red"; function swapColors() { let tempColor = anotherColor; anotherColor = color; color = tempColor; // 这里可以访问 color、anotherColor 和 tempColor } // 这里可以访问 color 和 anotherColor,但访问不到 tempColor swapColors(); } // 这里只能访问 color changeColor();
以上代码涉及 3 个上下文:全局上下文、changeColor()的局部上下文和 swapColors()的局部 上下文。全局上下文中有一个变量 color 和一个函数 changeColor()。changeColor()的局部上下文中 有一个变量 anotherColor 和一个函数 swapColors(),但在这里可以访问全局上下文中的变量 color。 swapColors()的局部上下文中有一个变量 tempColor,只能在这个上下文中访问到。全局上下文和 changeColor()的局部上下文都无法访问到 tempColor。而在 swapColors()中则可以访问另外两个 上下文中的变量,因为它们都是父上下文。 下图是作用域链:
图中的矩形表示不同的上下文。内部上下文可以通过作用域链访问外部上下文中的一切,但外 部上下文无法访问内部上下文中的任何东西。上下文之间的连接是线性的、有序的。每个上下文都可以 到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。swapColors() 局部上下文的作用域链中有 3 个对象:swapColors()的变量对象、changeColor()的变量对象和全局 变量对象。swapColors()的局部上下文首先从自己的变量对象开始搜索变量和函数,搜不到就去搜索 上一级变量对象。changeColor()上下文的作用域链中只有 2 个对象:它自己的变量对象和全局变量 对象。因此,它不能访问 swapColors()的上下文。
虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有 其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执 行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:
try/catch 语句的 catch 块
with 语句
1. 使用 var 的函数作用域声明
在使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函 数的局部上下文。
var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升” (hoisting)。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。
注: 未经声明而初始化变量是 JavaScript 编程中一个非常常见的错误,会导致很多问题。 为此,读者在初始化变量之前一定要先声明变量。在严格模式下,未经声明就初始化变量 会报错。
2. 使用 let 的块级作用域声明
ES6 新增的 let 关键字跟 var 很相似,但它的作用域是块级的,这也是 JavaScript 中的新概念。块 级作用域由最近的一对包含花括号{}界定。换句话说,if 块、while 块、function 块,甚至连单独 的块也是 let 声明变量的作用域。
let 与 var 的另一个不同之处是在同一作用域内不能声明两次。重复的 var 声明会被忽略,而重复的 let 声明会抛出 SyntaxError。
3. 使用 const 的常量声明
使用 const 声明的变量必须同时初始化为某个值。 一经声明,在其生命周期的任何时候都不能再重新赋予新值。
const 声明只应用到顶级原语或者对象。换句话说,赋值为对象的 const 变量不能再被重新赋值 为其他引用值,但对象的键则不受限制。
如果想让整个对象都不能修改,可以使用 Object.freeze(),这样再给属性赋值时虽然不会报错, 但会静默失败:
4.标识符查找
如果在局部上下文中找到该标识符,则搜索 停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个 原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。 如果仍然没有找到标识符,则说明其未声明。
例子:
var color = 'blue'; function getColor() { return color; } console.log(getColor()); // 'blue'
var color = 'blue'; function getColor() { let color = 'red'; return color; } console.log(getColor()); // 'red'
var color = 'blue'; function getColor() { let color = 'red'; { let color = 'green'; return color; } } console.log(getColor()); // 'green'
JavaScript 为开发者卸下 了这个负担,通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再 使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执 行过程中某个预定的收集时间)就会自动运行。
我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。此时,栈(或 堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部 变量了,它占用的内存可以释放,供后面使用。这种情况下显然不再需要局部变量了,但并不是所有时 候都会这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收 内存。如何标记未使用的变量也许有不同的实现方式。不过,在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数。
JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数 内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时, 也会被加上离开上下文的标记。
垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它 会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记 的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
另一种没那么常用的垃圾回收策略是引用计数(reference counting)。其思路是对每个值都记录它被 引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变 量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一 个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序 下次运行的时候就会释放引用数为 0 的值的内存。
引用计数存在一个严重的问题:循环引用。所谓循环引 用,就是对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A。
在这个例子中,objectA 和 objectB 通过各自的属性相互引用,意味着它们的引用数都是 2。在 标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,objectA 和 objectB 在函数结束后还会存在,因为它们的引用数永远不会变成 0。如果函数被多次调用,则会导致大量内存永远不会被释放。
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的 时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行 代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null,从而释放其引用。这也可以叫 作解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用。
例:
function createPerson(name){ let localPerson = new Object(); localPerson.name = name; return localPerson; } let globalPerson = createPerson("Nicholas"); // 解除 globalPerson 对值的引用 globalPerson = null;
在上面的代码中,变量 globalPerson 保存着 createPerson()函数调用返回的值。在 createPerson() 内部,localPerson 创建了一个对象并给它添加了一个 name 属性。然后,localPerson 作为函数值 被返回,并被赋值给 globalPerson。localPerson 在 createPerson()执行完成超出上下文后会自 动被解除引用,不需要显式处理。但 globalPerson 是一个全局变量,应该在不再需要时手动解除其引用,最后一行就是这么做的。
1. 通过 const 和 let 声明提升性能
ES6 增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为 const和 let 都以块(而非函数)为作用域,所以相比于使用 var,使用这两个新关键字可能会更早地让垃圾回 收程序介入,尽早回收应该回收的内存。
2. 隐藏类和删除操作
3. 内存泄漏
JavaScript 中的内存泄漏大部分是由不合理的 引用导致的。意外声明全局变量是最常见但也最容易修复的内存泄漏问题。
function setName() { name = 'Jake'; }
解释器会把变量 name 当作 window 的属性来创建(相当于 window.name = 'Jake')。 可想而知,在 window 对象上创建的属性,只要 window 本身不被清理就不会消失。这个问题很容易解决,只要在变量声明前头加上 var、let 或 const 关键字即可,这样变量就会在函数执行完毕后离 开作用域。
定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:
let name = 'Jake'; setInterval(() => { console.log(name); }, 100);
只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。垃圾回收程序当然知道这一点, 因而就不会清理外部变量。 使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏。
4. 静态分配与对象池
为了提升 JavaScript 性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如 何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发 垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因 释放内存而损失的性能。
对象池:
一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。 应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。 由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。
由于 JavaScript 数组的大小是动态可变的,引擎会删除大小为 100 的数组,再创建一个新的大小为 200 的数组。垃圾回收程序会看到这个删除操作,说不定因此很快就会跑来收一次垃圾。要避免这种动 态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作。不过, 必须事先想好这个数组有多大。
注: 静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿, 可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不 用考虑。
JavaScript 变量可以保存两种类型的值:原始值和引用值。原始值可能是以下 6 种原始数据类型之 一:Undefined、Null、Boolean、Number、String 和 Symbol。
原始值和引用值有以下特点:
执行上下文可以总结 如下:
JavaScript 的垃圾回收 程序可以总结如下: