1 原始值与引用值
原始值就是最简单的数据,引用值则是由多个值构成的对象。在把一个值赋给变量时,JavaScript引擎必须要确定这个值是原始值还是引用值
原始值大小固定,保存在栈 内存上;引用值是对象,存储在堆内存上
它们存在以下不同:
- 动态属性
定义的方式很类似,都是创建一个变量再给它赋值,但在变量保存了这个值以后,对于引用值来说,可以随时添加、修改和删除其属性和方法;原始值不能有属性,尝试添加也不会报错
PS:原始类型的初始化可以只使用原始字面量形式,如果使用 new 关键字,JavaScript则会创建一个 Object 类型的实例
- 复制值
从一个变量到另一个变量复制原始值会创建该值的第二个副本,这两个变量可以独立使用,互不干扰
包含引用值的变量实际上只包含指向相应对象的一个指针 ,而不是对象本身;从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象
- 传递参数
ECMAScript中所有函数的参数都是按值传递的,函数外的值会被复制到函数内部的参数中,复制方式跟上面提到的规则一样
对象是按值传递的,比如下面这个例子:
如果 person 是按引用传递的,那么 person 应该自动将指针改为指向 name 为 Greg 的对象,而 name 值是 Nicholas,这表明函数中参数的值改变之后,原始的引用没有变。当 obj 在函数内部被重写时,它变成了一个指向本地对象的指针,而那个本地对象在函数执行结束时就被销毁了
PS:ECMAScript中函数的参数就是局部变量
- 确定类型
typeof操作符适合用来判断一个变量是否为原始类型,但值是对象或null,判断结果都是object
而对于引用值,我们通常不关心一个值是不是对象,而想知道是什么类型的对象,instanceof则来判断,如果变量是给定引用类型的实例,则返回true。按照定义,所有引用值都是object,因此检测任何引用值和 object 构造函数都会返回true
2 执行上下文与作用域
任何变量都存在于某个执行上下文中(也称为作用域),这个上下文决定了变量的生命周期,以及它们可以访问代码的哪些部分。每个上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数都存在这个对象上。执行上下文分全局上下文、函数上下文和块级上下文
1)全局上下文是最外层的上下文,在浏览器中,全局上下文就是 window 对象
2)每个函数调用都有自己的上下文,当代码执行流进入函数时,函数的上下文被推到一个上下文栈上,ECMAScript程序的执行流就是通过这个上下文栈进行控制的
代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜素变量和函数
如果上下文是函数,则其活动对象用作变量对象,它最初只有一个定义变量:arguments。作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文,以此类推直到全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象
此外,局部作用域中定义的变量可用于在局部上下文中替换全局变量,比如下面这个例子:
下图展示了上面例子的作用域链:
内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文的任何东西,上下文之间的连接是线性的、有序的
PS:函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则
2.1 作用域链增强
某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除,通常在下面这两种情况下会出现这个现象:
-
try/catch语句的 catch 块
- 会创建一个新的变量对象,它会包含要抛出的错误对象的声明
-
with语句
- 会向作用域链前端添加指定的对象
2.2 变量声明
除了 var 声明变量,ES6还增加了 let 和 const 关键字
- 使用 var 的函数作用域声明
在使用 var 声明变量时,变量会被自动添加到最接近的上下文,在函数中,最接近的上下文就是函数的局部上下文。如果变量未经声明就被初始化了,那它就会自动被添加到全局上下文
var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前,这叫做 "提升"
- 使用 let 的函数作用域声明
let 的作用域是块级的,块级作用域由最近的一对包含花括号{}界定。let 在同一作用域内不能声明两次,重复的 var 声明会被忽略,而重复的 let 声明会抛出SyntaxError
let 适合在循环中声明迭代变量,使用 var 声明的迭代变量会泄漏到循环外部
- 使用 const 的函数作用域声明
使用 const 声明的变量必须同时初始化为某个值,一经声明,在其生命周期的任何时候都不能再重新赋值,其他跟 let 声明一样
const 声明只应用到顶级原语或对象,换句话说,赋值为对象的 const 变量不能再被重新赋值为其他引用值,但对象的键则不受限制;如果想让整个对象都不能修改,可以使用Object.freeze() ,这样虽然不会报错但赋值会失败
由于 const 声明变量的值是单一类型且不可修改,JavaScript运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找,谷歌的 V8 引擎就执行这种优化
3 垃圾回收
JavaScript是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存,通过自动内存管理实现内存分配和闲置资源回收。基本思路:确定哪个变量不会再使用,就释放它占用的内存,这个过程是周期性的,即垃圾回收程序每隔一段时间就会自动执行
以函数中局部变量的正常生命周期为例:函数中的局部变量会在函数执行时存在,栈(或堆)内存会分配空间以保存相应的值;函数在内部使用了变量,然后退出;此时不再需要那个局部变量了,它占用的内存可以释放。这种情况下显然不需要局部变量了,但并不是所有时候都这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用。标记未使用的变量的实现方式主要有标记清理和引用计数这两种标记策略
3.1 标记清理
JavaScript最常用的垃圾回收策略是标记清理。当变量进入上下文,会被加上存在于上下文中的标记;当离开上下文时,也会被加上离开上下文的标记。给变量加标记的方式有很多种,比如反转某一位,或者维护"在上下文中"和"不在上下文中"两个变量列表。标记过程的实现并不重要,关键是策略
垃圾回收程序运行的时候,会标记内存中存储的所有变量,然后将所有在上下文中的变量,以及被在上下文中的变量引用的变量标记去掉。在此之后再被加上标记的变量就是待删除 的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回自己的内存
3.2 引用计数
另一种没那么常用的垃圾回收策略是引用计数,其思路是对每个值都记录它被引用的次数,当一个值的引用数为0时,就说明没办法再访问到这个值了,垃圾回收程序下次运行的时候就会释放引用数为0的值的内存
这种策略存在循环引用的问题,objectA 和 objectB 通过各自的属性相互引用,引用数永远不会是0,如果函数被多次调用,则会导致大量内存永远不会被释放
3.3 性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。开发时不知道什么时候运行时会收集垃圾,最好的办法是在写代码时要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作
现代垃圾回收程序会基于对 JavaScript 运行时环境的探测来决定何时运行,探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的
3.4 内存管理
JavaScript基于安全考虑,分配给浏览器的内存通常比分配给桌面软件的少很多,为了避免运行大量 JavaScript 的网页耗尽系统内存而导致操作系统崩溃。这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量
优化内存占用的最佳手段是保证在执行代码时只保存必要的数据,如果数据不再必要,就将其设置为null ,从而释放其引用,这也叫解除引用。这个建议最适合全局变量和全局对象的属性,局部变量在超出作用域后会被自动解除引用,如下面例子所示:
localPerson 在 createPerson() 执行完成超出上下文后会自动被解除引用,不需要显式处理。但 globalPerson 是一个全局变量,应该在不再需要时手动解除其引用,最后一行就是这么做的
PS:解除对一个值的引用并不会自动导致相关内存被回收,解除引用的关键在于确保相关的值已经不在上下文里了,因此在下次垃圾回收时会被回收
- 通过 let 和 const 提升性能
let 和 const都以块(而非函数)为作用域,所以相比于使用var,这两个可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存
- 隐藏类和删除操作
根据 JavaScript 所在的运行环境,有时需要根据浏览器使用的引擎来采取不同的性能优化策略。Chrome使用 V8 Javascript 引擎,V8在将解释后的 JavaScript 代码编译为实际的机器码时会利用"隐藏类" ,这对代码性能有一定的影响
运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征,能够共享相同隐藏类的对象性能会更好,比如下面这段代码:
V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。假如之后添加a2.auther = 'Jake'
这段代码,此时两个实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这可能对性能产生明显影响
解决方案就是避免 JavaScript 的 "先创建再补充"式的动态属性赋值,并在构造函数中一次性声明所有属性
这样两个实例基本上就一样了(不考虑 hasOwnProperty 的返回值),因此可以共享一个隐藏类,从而带来潜在的性能提升。不过,使用 delete 关键字会导致生成相同的隐藏类片段
在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性与动态添加属性导致的后果是一样,最佳的做法是把不想要的属性设置为null,这样既可以保持隐藏类不变和继续共享,也能达到删除引用值供垃圾回收程序回收的效果
- 内存泄漏
JavaScript 中的内存泄漏大部分是由不合理的引用导致的
1)意外声明全局变量
没有使用任何关键字声明变量,解释器会把变量当作 window 的属性来创建,只要 window 本身不被清理就不会消失。只需要在变量声明前加上 var、let 或 const 关键字即可,这样变量就会在函数执行完毕后离开作用域
2)定时器
只要定时器一直运行,函数引用的变量就会一直占用内存,需要及时清除定时器
3)闭包
调用 outer() 会导致分配给 name 的内存被泄漏,只要返回的函数存在就不能清理name,因为闭包一直在引用着它。如果 name 的内容很大,那可能就是个大问题了
- 静态分配与对象池
为了提升性能,一个关键问题就是如何减少浏览器执行垃圾回收的次数。我们无法直接控制什么时候收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能
浏览器决定何时运行垃圾回收程序的一个标准是对象更替的速度。如果有很多对象被初始化,然后又一下子超出了作用域,浏览器就会采用更激进的方式调度垃圾回收程序运行,这样就会影响性能
以下是个计算二维矢量加法的函数:
调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。如果这个矢量对象的生命周期很短,那就会很快失去所有对它的引用,成为可以被回收的值。如果该函数被频繁调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而就会更频繁安排垃圾回收
该问题的解决方案是不要动态创建矢量对象,比如修改上面的函数,让它使用一个已有的矢量对象:
这就需要在其他地方实例化矢量参数resultant,那在哪里创建矢量才不会让垃圾回收调度程序盯上呢?
一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替
如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择,不过需要留意不要招致额外的垃圾回收
由于 JavaScript 数组的大小是动态可变的,引擎会删除大小为 100 的数组,再创建一个新的大小为 200 的数组。垃圾回收程序会看到这个删除操作,说不定会跑来收一次垃圾。要避免这种动态分配操作,可以在初始化时就创建一个大小够用的数组,不过必须事先想好这个数组有多大
PS:静态分配是优化的一种极端形式,如果应用程序被垃圾回收严重拖了后腿,可以利用它提升性能,但这种情况并不多见