JavaScript第4 章 变量、作用域与内存

第4 章 变量、作用域与内存

本章内容
 通过变量使用原始值与引用值
 理解执行上下文
 理解垃圾回收
相比于其他语言,JavaScript 中的变量可谓独树一帜。正如ECMA-262 所规定的,JavaScript 变量是
松散类型的,而且变量不过就是特定时间点一个特定值的名称而已。由于没有规则定义变量必须包含什
么数据类型,变量的值和数据类型在脚本生命期内可以改变。这样的变量很有意思,很强大,当然也有
不少问题。本章会剖析错综复杂的变量。
4.1 原始值与引用值
ECMAScript 变量可以包含两种不同类型的数据:原始值和引用值。原始值(primitive value)就是
最简单的数据,引用值(reference value)则是由多个值构成的对象。
在把一个值赋给变量时,JavaScript 引擎必须确定这个值是原始值还是引用值。上一章讨论了6 种
原始值:Undefined、Null、Boolean、Number、String 和Symbol。保存原始值的变量是按值(by
value)访问的,因为我们操作的就是存储在变量中的实际值。
引用值是保存在内存中的对象。与其他语言不同,JavaScript 不允许直接访问内存位置,因此也就
不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用(reference)而非
实际的对象本身。为此,保存引用值的变量是按引用(by reference)访问的。
注意 在很多语言中,字符串是使用对象表示的,因此被认为是引用类型。ECMAScript
打破了这个惯例。
4.1.1 动态属性
原始值和引用值的定义方式很类似,都是创建一个变量,然后给它赋一个值。不过,在变量保存了
这个值之后,可以对这个值做什么,则大有不同。对于引用值而言,可以随时添加、修改和删除其属性
和方法。比如,看下面的例子:
这里,首先创建了一个对象,并把它保存在变量person 中。然后,给这个对象添加了一个名为name 的属性,并给这个属性赋值了一个字符串"Nicholas"。在此之后,就可以访问这个新属性,直到
对象被销毁或属性被显式地删除。
原始值不能有属性,尽管尝试给原始值添加属性不会报错。比如:
在此,代码想给字符串name 定义一个age 属性并给该属性赋值27。紧接着在下一行,属性不见
了。记住,只有引用值可以动态添加后面可以使用的属性。
注意,原始类型的初始化可以只使用原始字面量形式。如果使用的是new 关键字,则JavaScript 会
创建一个Object 类型的实例,但其行为类似原始值。下面来看看这两种初始化方式的差异:
4.1.2 复制值
除了存储方式不同,原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值
到另一个变量时,原始值会被复制到新变量的位置。请看下面的例子:
这里,num1 包含数值5。当把num2 初始化为num1 时,num2 也会得到数值5。这个值跟存储在
num1 中的5 是完全独立的,因为它是那个值的副本。
这两个变量可以独立使用,互不干扰。这个过程如图4-1 所示。

image.png


在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区
别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际
上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来,如下面的例子所示:
在这个例子中,变量obj1 保存了一个新对象的实例。然后,这个值被复制到obj2,此时两个变
量都指向了同一个对象。在给obj1 创建属性name 并赋值后,通过obj2 也可以访问这个属性,因为
它们都指向同一个对象。图4-2 展示了变量与堆内存中对象之间的关系。

image.png


4.1.3 传递参数
ECMAScript 中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数
中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是
引用值,那么就跟引用值变量的复制一样。对很多开发者来说,这一块可能会不好理解,毕竟变量有按
值和按引用访问,而传参则只有按值传递。
在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用ECMAScript 的话说,
就是arguments 对象中的一个槽位)。在按引用传递参数时,值在内存中的位置会被保存在一个局部变
量,这意味着对本地变量的修改会反映到函数外部。(这在ECMAScript 中是不可能的。)来看下面这个
例子:
这里,函数addTen()有一个参数num,它其实是一个局部变量。在调用时,变量count 作为参数
传入。count 的值是20,这个值被复制到参数num 以便在addTen()内部使用。在函数内部,参数num
的值被加上了10,但这不会影响函数外部的原始变量count。参数num 和变量count 互不干扰,它们
只不过碰巧保存了一样的值。如果num 是按引用传递的,那么count 的值也会被修改为30。这个事实
在使用数值这样的原始值时是非常明显的。但是,如果变量中传递的是对象,就没那么清楚了。比如,
再看这个例子:
这一次,我们创建了一个对象并把它保存在变量person 中。然后,这个对象被传给setName()
方法,并被复制到参数obj 中。在函数内部,obj 和person 都指向同一个对象。结果就是,即使对象
是按值传进函数的,obj 也会通过引用访问对象。当函数内部给obj 设置了name 属性时,函数外部的
对象也会反映这个变化,因为obj 指向的对象保存在全局作用域的堆内存上。很多开发者错误地认为,
当在局部作用域中修改对象而变化反映到全局时,就意味着参数是按引用传递的。为证明对象是按值传
递的,我们再来看看下面这个修改后的例子:
这个例子前后唯一的变化就是setName()中多了两行代码,将obj 重新定义为一个有着不同name
的新对象。当person 传入setName()时,其name 属性被设置为"Nicholas"。然后变量obj 被设置
为一个新对象且name 属性被设置为"Greg"。如果person 是按引用传递的,那么person 应该自动将
指针改为指向name 为"Greg"的对象。可是,当我们再次访问person.name 时,它的值是"Nicholas",
这表明函数中参数的值改变之后,原始的引用仍然没变。当obj 在函数内部被重写时,它变成了一个指
向本地对象的指针。而那个本地对象在函数执行结束时就被销毁了。
注意 ECMAScript 中函数的参数就是局部变量。
4.1.4 确定类型
前一章提到的typeof 操作符最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一
个变量是否为字符串、数值、布尔值或undefined 的最好方式。如果值是对象或null,那么返回"object",如下面的例子所示:
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"。
4.2 执行上下文与作用域
执行上下文(以下简称“上下文”)的概念在JavaScript 中是颇为重要的。变量或函数的上下文决定
了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),
而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台
处理数据会用到它。
全局上下文是最外层的上下文。根据ECMAScript 实现的宿主环境,表示全局上下文的对象可能不一
样。在浏览器中,全局上下文就是我们常说的window 对象(第12 章会详细介绍),因此所有通过var 定
义的全局变量和函数都会成为window 对象的属性和方法。使用let 和const 的顶级声明不会定义在全
局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义
在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。
在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript
程序的执行流就是通过这个上下文栈进行控制的。
上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定
了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域
链的最前端。如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有
一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上
下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终
是作用域链的最后一个变量对象。
代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链
的最前端开始,然后逐级往后,直到找到标识符。(如果没有找到标识符,那么通常会报错。)
看一看下面这个例子:

对这个例子而言,函数changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就
是定义arguments 对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量
color,就是因为可以在作用域链中找到它。
此外,局部作用域中定义的变量可用于在局部上下文中替换全局变量。看一看下面这个例子:
以上代码涉及3 个上下文:全局上下文、changeColor()的局部上下文和swapColors()的局部
上下文。全局上下文中有一个变量color 和一个函数changeColor()。changeColor()的局部上下文中
有一个变量anotherColor 和一个函数swapColors(),但在这里可以访问全局上下文中的变量color。
swapColors()的局部上下文中有一个变量tempColor,只能在这个上下文中访问到。全局上下文和
changeColor()的局部上下文都无法访问到tempColor。而在swapColors()中则可以访问另外两个
上下文中的变量,因为它们都是父上下文。图4-3 展示了前面这个例子的作用域链。

image.png


图4-3 中的矩形表示不同的上下文。内部上下文可以通过作用域链访问外部上下文中的一切,但外
部上下文无法访问内部上下文中的任何东西。上下文之间的连接是线性的、有序的。每个上下文都可以
到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。swapColors()
局部上下文的作用域链中有3 个对象:swapColors()的变量对象、changeColor()的变量对象和全局
变量对象。swapColors()的局部上下文首先从自己的变量对象开始搜索变量和函数,搜不到就去搜索
上一级变量对象。changeColor()上下文的作用域链中只有2 个对象:它自己的变量对象和全局变量
对象。因此,它不能访问swapColors()的上下文。

注意 函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的
访问规则。
4.2.1 作用域链增强
虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有
其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执
行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:
 try/catch 语句的catch 块
 with 语句
这两种情况下,都会在作用域链前端添加一个变量对象。对with 语句来说,会向作用域链前端添
加指定的对象;对catch 语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误
对象的声明。看下面的例子:
这里,with 语句将location 对象作为上下文,因此location 会被添加到作用域链前端。
buildUrl()函数中定义了一个变量qs。当with 语句中的代码引用变量href 时,实际上引用的是
location.href,也就是自己变量对象的属性。在引用qs 时,引用的则是定义在buildUrl()中的那
个变量,它定义在函数上下文的变量对象上。而在with 语句中使用var 声明的变量url 会成为函数
上下文的一部分,可以作为函数的值被返回;但像这里使用let 声明的变量url,因为被限制在块级作
用域(稍后介绍),所以在with 块之外没有定义。
注意 IE 的实现在IE8 之前是有偏差的,即它们会将catch 语句中捕获的错误添加到执
行上下文的变量对象上,而不是catch 语句的变量对象上,导致在catch 块外部都可以
访问到错误。IE9 纠正了这个问题。
4.2.2 变量声明
ES6 之后,JavaScript 的变量声明经历了翻天覆地的变化。直到ECMAScript 5.1,var 都是声明变量
的唯一关键字。ES6 不仅增加了let 和const 两个关键字,而且还让这两个关键字压倒性地超越var
成为首选。
1. 使用var 的函数作用域声明
在使用var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函
数的局部上下文。在with 语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,
那么它就会自动被添加到全局上下文,如下面的例子所示:
这里,函数add()定义了一个局部变量sum,保存加法操作的结果。这个值作为函数的值被返回,
但变量sum 在函数外部是访问不到的。如果省略上面例子中的关键字var,那么sum 在add()被调用
之后就变成可以访问的了,如下所示:
这一次,变量sum 被用加法操作的结果初始化时并没有使用var 声明。在调用add()之后,sum
被添加到了全局上下文,在函数退出之后依然存在,从而在后面可以访问到。
注意 未经声明而初始化变量是JavaScript 编程中一个非常常见的错误,会导致很多问题。
为此,读者在初始化变量之前一定要先声明变量。在严格模式下,未经声明就初始化变量
会报错。
var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”
(hoisting)。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。可是在实践中,提
升也会导致合法却奇怪的现象,即在变量声明之前使用变量。下面的例子展示了在全局作用域中两段等
价的代码:
下面是两个等价的函数:
通过在声明之前打印变量,可以验证变量会被提升。声明的提升意味着会输出undefined 而不是
Reference Error:
2. 使用let 的块级作用域声明
ES6 新增的let 关键字跟var 很相似,但它的作用域是块级的,这也是JavaScript 中的新概念。块
级作用域由最近的一对包含花括号{}界定。换句话说,if 块、while 块、function 块,甚至连单独
的块也是let 声明变量的作用域。
let 与var 的另一个不同之处是在同一作用域内不能声明两次。重复的var 声明会被忽略,而重
复的let 声明会抛出SyntaxError。
let 的行为非常适合在循环中声明迭代变量。使用var 声明的迭代变量会泄漏到循环外部,这种情
况应该避免。来看下面两个例子:
严格来讲,let 在JavaScript 运行时中也会被提升,但由于“暂时性死区”(temporal dead zone)的
缘故,实际上不能在声明之前使用let 变量。因此,从写JavaScript 代码的角度说,let 的提升跟var
是不一样的。
3. 使用const 的常量声明
除了let,ES6 同时还增加了const 关键字。使用const 声明的变量必须同时初始化为某个值。
一经声明,在其生命周期的任何时候都不能再重新赋予新值。
为其他引用值,但对象的键则不受限制。
如果想让整个对象都不能修改,可以使用Object.freeze(),这样再给属性赋值时虽然不会报错,
但会静默失败:
由于const 声明暗示变量的值是单一类型且不可修改,JavaScript 运行时编译器可以将其所有实例
都替换成实际的值,而不会通过查询表进行变量查找。谷歌的V8 引擎就执行这种优化。
注意 开发实践表明,如果开发流程并不会因此而受很大影响,就应该尽可能地多使用
const 声明,除非确实需要一个将来会重新赋值的变量。这样可以从根本上保证提前发现
重新赋值导致的bug。
4. 标识符查找
当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。搜
索开始于作用域链前端,以给定的名称搜索对应的标识符。如果在局部上下文中找到该标识符,则搜索
停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个
原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。
如果仍然没有找到标识符,则说明其未声明。
为更好地说明标识符查找,我们来看一个例子:
在这个例子中,调用函数getColor()时会引用变量color。为确定color 的值会进行两步搜索。
第一步,搜索getColor()的变量对象,查找名为color 的标识符。结果没找到,于是继续搜索下一
个变量对象(来自全局上下文),然后就找到了名为color 的标识符。因为全局变量对象上有color
的定义,所以搜索结束。
对这个搜索过程而言,引用局部变量会让搜索自动停止,而不继续搜索下一级变量对象。也就是说,
如果局部上下文中有一个同名的标识符,那就不能在该上下文中引用父上下文中的同名标识符,如下面
的例子所示:
使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次:
在这个修改后的例子中,getColor()内部声明了一个名为color 的局部变量。在调用这个函数时,
变量会被声明。在执行到函数返回语句时,代码引用了变量color。于是开始在局部上下文中搜索这个
标识符,结果找到了值为'green'的变量color。因为变量已找到,搜索随即停止,所以就使用这个局
部变量。这意味着函数会返回'green'。在局部变量color 声明之后的任何代码都无法访问全局变量
color,除非使用完全限定的写法window.color。
注意 标识符查找并非没有代价。访问局部变量比访问全局变量要快,因为不用切换作用
域。不过,JavaScript 引擎在优化标识符查找上做了很多工作,将来这个差异可能就微不
足道了。
4.3 垃圾回收
JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在C 和C++等
语言中,跟踪内存使用对开发者来说是个很大的负担,也是很多问题的来源。JavaScript 为开发者卸下
了这个负担,通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再
使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执
行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。

我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。此时,栈(或
堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部
变量了,它占用的内存可以释放,供后面使用。这种情况下显然不再需要局部变量了,但并不是所有时
候都会这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收
内存。如何标记未使用的变量也许有不同的实现方式。不过,在浏览器的发展史上,用到过两种主要的
标记策略:标记清理和引用计数。

4.3.1 标记清理
JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数
内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永
远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,
也会被加上离开上下文的标记。
给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下
文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现
并不重要,关键是策略。
垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它
会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记
的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内
存清理,销毁带标记的所有值并收回它们的内存。
到了2008 年,IE、Firefox、Opera、Chrome 和Safari 都在自己的JavaScript 实现中采用标记清理(或
其变体),只是在运行垃圾回收的频率上有所差异。
4.3.2 引用计数
另一种没那么常用的垃圾回收策略是引用计数(reference counting)。其思路是对每个值都记录它被
引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变
量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一
个值的引用数为0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序
下次运行的时候就会释放引用数为0 的值的内存。
引用计数最早由Netscape Navigator 3.0 采用,但很快就遇到了严重的问题:循环引用。所谓循环引
用,就是对象A 有一个指针指向对象B,而对象B 也引用了对象A。比如:
在这个例子中,objectA 和objectB 通过各自的属性相互引用,意味着它们的引用数都是2。在
标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,objectA 和objectB 在函数结束后还会存在,因为它们的引用数永远不会变成0。如果函数被多次调用,则会导致大量内存永远不会被释放。为此,Netscape 在4.0 版放弃了引用计数,转而采用标记清理。事实上,引用计数策略的问题还不止于此。在IE8 及更早版本的IE 中,并非所有对象都是原生JavaScript 对象。BOM 和DOM中的对象是C++实现的组件对象模型(COM,Component Object Model)对象,而COM 对象使用引用计数实现垃圾回收。因此,即使这些版本IE 的JavaScript 引擎使用标记清理,JavaScript 存取的COM对象依旧使用引用计数。换句话说,只要涉及COM 对象,就无法避开循环引用问题。下面这个简单的例子展示了涉及COM对象的循环引用问题:
这个例子在一个DOM 对象(element)和一个原生JavaScript 对象(myObject)之间制造了循环引用。myObject 变量有一个名为element 的属性指向DOM 对象element,而element 对象有一个someObject 属性指回myObject 对象。由于存在循环引用,因此DOM元素的内存永远不会被回收,即使它已经被从页面上删除了也是如此。
为避免类似的循环引用问题,应该在确保不使用的情况下切断原生JavaScript 对象与DOM 元素之间的连接。比如,通过以下代码可以清除前面的例子中建立的循环引用:
myObject.element = null;
element.someObject = null;
把变量设置为null 实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。
为了补救这一点,IE9 把BOM和DOM对象都改成了JavaScript 对象,这同时也避免了由于存在两套垃圾回收算法而导致的问题,还消除了常见的内存泄漏现象。注意 还有其他一些可能导致循环引用的情形,本书后面会介绍到。
4.3.3 性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
现代垃圾回收程序会基于对JavaScript 运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。比如,根据V8 团队2016 年的一篇博文的说法:“在一次完整的垃圾回收之后,V8 的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃圾回收。”
由于调度垃圾回收程序方面的问题会导致性能下降,IE 曾饱受诟病。它的策略是根据分配数,比如分配了256 个变量、4096 个对象/数组字面量和数组槽位(slot),或者64KB 字符串。只要满足其中某个条件,垃圾回收程序就会运行。这样实现的问题在于,分配那么多变量的脚本,很可能在其整个生命周期内始终需要那么多变量,结果就会导致垃圾回收程序过于频繁地运行。由于对性能的严重影响,IE7最终更新了垃圾回收程序。
IE7 发布后,JavaScript 引擎的垃圾回收程序被调优为动态改变分配变量、字面量或数组槽位等会触
发垃圾回收的阈值。IE7 的起始阈值都与IE6 的相同。如果垃圾回收程序回收的内存不到已分配的15%,
这些变量、字面量或数组槽位的阈值就会翻倍。如果有一次回收的内存达到已分配的85%,则阈值重置
为默认值。这么一个简单的修改,极大地提升了重度依赖JavaScript 的网页在浏览器中的性能。警告 在某些浏览器中是有可能(但不推荐)主动触发垃圾回收的。在IE 中,window.CollectGarbage()方法会立即触发垃圾回收。在Opera 7 及更高版本中,调用window.opera.collect()也会启动垃圾回收程序。
4.3.4 内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。不过,JavaScript 运行在一个内存
管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动
浏览器的就更少了。这更多出于安全考虑而不是别的,就是为了避免运行大量JavaScript 的网页耗尽系
统内存而导致操作系统崩溃。这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程
中执行的语句数量。
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行
代码时只保存必要的数据。如果数据不再必要,那么把它设置为null,从而释放其引用。这也可以叫
作解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用,
如下面的例子所示:
在上面的代码中,变量globalPerson 保存着createPerson()函数调用返回的值。在createPerson()
内部,localPerson 创建了一个对象并给它添加了一个name 属性。然后,localPerson 作为函数值
被返回,并被赋值给globalPerson。localPerson 在createPerson()执行完成超出上下文后会自
动被解除引用,不需要显式处理。但globalPerson 是一个全局变量,应该在不再需要时手动解除其
引用,最后一行就是这么做的。
不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关
的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。
1. 通过const 和let 声明提升性能
ES6 增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为const和let 都以块(而非函数)为作用域,所以相比于使用var,使用这两个新关键字可能会更早地让垃圾回
收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。
2. 隐藏类和删除操作
根据JavaScript 所在的运行环境,有时候需要根据浏览器使用的JavaScript 引擎来采取不同的性能优
化策略。截至2017 年,Chrome 是最流行的浏览器,使用V8 JavaScript 引擎。V8 在将解释后的JavaScript
代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很
重要。
运行期间,V8 会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类
的对象性能会更好,V8 会针对这种情况进行优化,但不一定总能够做到。比如下面的代码:
V8 会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原
型。假设之后又添加了下面这行代码:
a2.author = 'Jake';
此时两个Article 实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有
可能对性能产生明显影响。
当然,解决方案就是避免JavaScript 的“先创建再补充”(ready-fire-aim)式的动态属性赋值,并在
构造函数中一次性声明所有属性,如下所示:
这样,两个实例基本上就一样了(不考虑hasOwnProperty 的返回值),因此可以共享一个隐藏类,
从而带来潜在的性能提升。不过要记住,使用delete 关键字会导致生成相同的隐藏类片段。看一下这
个例子:
在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性
与动态添加属性导致的后果一样。最佳实践是把不想要的属性设置为null。这样可以保持隐藏类不变
和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。比如:
3. 内存泄漏
写得不好的JavaScript 可能出现难以察觉且有害的内存泄漏问题。在内存有限的设备上,或者在函
数会被调用很多次的情况下,内存泄漏可能是个大问题。JavaScript 中的内存泄漏大部分是由不合理的
引用导致的。
意外声明全局变量是最常见但也最容易修复的内存泄漏问题。下面的代码没有使用任何关键字声明
变量:
此时,解释器会把变量name 当作window 的属性来创建(相当于window.name = 'Jake')。
可想而知,在window 对象上创建的属性,只要window 本身不被清理就不会消失。这个问题很容易
解决,只要在变量声明前头加上var、let 或const 关键字即可,这样变量就会在函数执行完毕后离
开作用域。
定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:
只要定时器一直运行,回调函数中引用的name 就会一直占用内存。垃圾回收程序当然知道这一点,
因而就不会清理外部变量。
使用JavaScript 闭包很容易在不知不觉间造成内存泄漏。请看下面的例子:
调用outer()会导致分配给name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回
的函数存在就不能清理name,因为闭包一直在引用着它。假如name 的内容很大(不止是一个小字符
串),那可能就是个大问题了。
4. 静态分配与对象池
为了提升JavaScript 性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如
何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发
垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因
释放内存而损失的性能。
浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。如果有很多对象被初始化,然
后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然会影
响性能。看一看下面的例子,这是一个计算二维矢量加法的函数:
调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。如果这个
矢量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。假如这个矢量
加法函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁地安排
垃圾回收。
该问题的解决方案是不要动态创建矢量对象,比如可以修改上面的函数,让它使用一个已有的矢量
对象:
当然,这需要在其他地方实例化矢量参数resultant,但这个函数的行为没有变。那么在哪里创
建矢量可以不让垃圾回收调度程序盯上呢?
一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。
应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。
由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运
行。下面是一个对象池的伪实现:
如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个
实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对
象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。比如下面这
个例子:
let vectorList = new Array(100);
let vector = new Vector();
vectorList.push(vector);
由于JavaScript 数组的大小是动态可变的,引擎会删除大小为100 的数组,再创建一个新的大小为
200 的数组。垃圾回收程序会看到这个删除操作,说不定因此很快就会跑来收一次垃圾。要避免这种动
态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作。不过,
必须事先想好这个数组有多大。
注意 静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,
可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不
用考虑。
4.4 小结
JavaScript 变量可以保存两种类型的值:原始值和引用值。原始值可能是以下6 种原始数据类型之
一:Undefined、Null、Boolean、Number、String 和Symbol。原始值和引用值有以下特点。
 原始值大小固定,因此保存在栈内存上。
 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
 引用值是对象,存储在堆内存上。
 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
 typeof 操作符可以确定值的原始类型,而instanceof 操作符用于确保值的引用类型。
任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个
上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。执行上下文可以总结
如下。
 执行上下文分全局上下文、函数上下文和块级上下文。
 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃
至全局上下文中的变量。
 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
 变量的执行上下文用于确定什么时候释放内存。
JavaScript 是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。JavaScript 的垃圾回收
程序可以总结如下。
 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。
 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript 引擎不再使用这种算
法,但某些旧版本的IE 仍然会受这种算法的影响,原因是JavaScript 会访问非原生JavaScript 对
象(如DOM元素)。
 引用计数在代码中存在循环引用时会出现问题。
 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对
象、全局对象的属性和循环引用都应该在不需要时解除引用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值