JS高级程序设计 4
第四章 变量,作用域与内存
原始值与引用值
- 原始值:最简单的数据
- 引用值:由多个值构成的对象.保存在内存中的对象,JavaScript不允许直接访问内存位置.操作对象时,实际操作的是对该对象的引用而非实际的对象本身.保存引用值的变量是按引用访问的
动态属性
引用值可以随时添加,修改和删除其属性和方法.原始值不能有属性,尽管尝试给原始值添加属性不会报错
let person = new Object()
person.name = 'Nicholas'
console.log(person.name) //'Nicholas'
let name='Nicholas'
name.age=27
console.log(name.age) //undefined
//原始类型的初始化可以只使用原始字面量形式.如果使用的是new关键字,则JavaScript会创建一个Object类型的实例,但其行为类似原始值
let name1 = 'Nicholas'
let name2 = new String('Matt')
name1.age = 27
name2.age = 26
console.log(name1.age) //undefined
console.log(name2.age) //26
console.log(typeof name1) //string
console.log(typeof name2) //object
复制值
除了存储方式不同(原始类型是存储在栈,引用类型指向的实体存储在堆),原始值和引用值在通过变量复制时也有所不同
- 在通过变量把一个原始值赋值到另一个变量时,原始值会被赋值到新变量的位置。新变量的值跟原本变量的值是完全独立的。两个值独立使用,互不干扰
- 在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置,区别在于这里复制的值是一个指针,它指向存储在堆内存中的对象。因此一个对象上面的变化会在另一个对象上反映出来。
传递参数
ECMAScript中所有函数的参数都是按值传递的。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。
- 按值传递参数时,值会被复制到一个局部变量(即一个命名参数,用ECMAScript的话说就是arguments对象中的一个槽位)。
- 按引用传递参数时,值在内存中的位置会被保存在一个局部变量,这意味着对本地变量的修改会反映到函数外部(这在ECMAScirpt中是不可能的)。
传递原始值很容易理解,但如果变量中传递的是对象,可能就不是那么清楚。
function setName(obj) {
obj.name = 'Nicholas';
obj = new Object();
obj.name = 'Greg';
}
let person = new Object();
setName(person);
console.log(person.name); //'Nicholas'
//可见传递对象仍然是以值传递的方式
确定类型
typeof是判断一个变量是否为字符串、数值、布尔值或undefined的最好方式,如果值是对象或null,那么typeof返回"object"。可见其对引用值的用处不大。我们通常不关心一个值是不是对象,而是想知道它是什么类型的对象。为解决这个问题,ECMAScript提供了instanceof操作符,语法如下
result = variable instanceof constuctor.
通过instanceof操作符检测任何引用值和Object构造函数都会返回true。类似地,如果用instanceof检测原始值,则始终会返回false,因为原始值不是对象。
执行上下文和作用域
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。
在浏览器中,全局上下文就是我们常说的
window对象,因此所有通过var定义的全局变量和函数都会称为window对象的属性和方法。上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。
上下文中的代码在执行的时候,会创建变量对象的一个作用域链。
这个作用域决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。作用域链中的下一个变量对象来自包含上下文,再一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象。
代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。
搜索过程始终从作用域的最前端开始,然后逐级往后,直到找到标识符(如果没有找到标识符,那么通常会报错)
内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西。每个上下文都可以到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。
注意:函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则
作用域链增强
某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象
- try/catch语句的catch块
- with语句
这两种情况下,都会在作用域链前端添加一个变量对象。对with语句来说,会向作用域链前端添加指定的对象;对catch语句来说,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。
变量声明
使用var的函数作用域声明
在使用var声明变量时,变量会被自动添加到最接近的上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文。(严格模式下会报错)
var声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前,这个现象叫做提升,声明的提升意味着会输出undefined而不是Reference Error(引用错误)
使用let的块级作用域声明
let的作用域与var不同,它是块级的。块级也是JavaScript中的新概念。块级作用域由最近的一对包含花括号{}界定。let与var另一个不同之处是在同一作用域内不能声明两次。重复的var声明会被忽略,而重复的let声明会抛出SyntaxError
严格来讲,let在JavaScript运行时也会被提升,但由于"暂时性死区"的缘故,实际不能在声明之前使用let变量。
使用const的常量声明
const声明只应用到顶级原语或者对象。赋值为对象的const变量不能再被重新赋值引用为其它值,但对象的键不受限制
如果想让整个对象都不能修改,可以使用
Object.freeze(),这样再给属性赋值时虽然不会报错,但会静默失败。 由于const声明暗示变量的值是单一类型且不可修改,JavaScript运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找。谷歌的V8引擎就执行这种优化。(因此开发中应该多用const,需要改变再换为let)
标识符查找
当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。
搜索开始于作用域链前端,以给定的名称搜索对应的标识符,如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域搜索。(作用域链中的对象也有一个原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。如果仍然没有找到标识符,则说明其未声明。
标识符查找并非没有代价。访问局部变量比访问全局变量要快,因为不用切换作用域。
垃圾回收
在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数
标记清理
JavaScript最常用的垃圾回收策略是标记清理。
当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。 当变量离开上下文时,也会被加上离开上下文的标记
具体操作:
垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后在被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了,随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
引用计数
另一种没那么常用的垃圾回收策略是引用计数。
声明变量并给它赋一个引用值时,这个值的引用数为1.如果同一个值又被赋给了另一个变量,那么引用值加1.类似地,如果保存该值引用的变量被其他值给覆盖了,那么引用数减1.当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。
遇到的问题:循环引用,即对象A有一个指针指向对象B,对象B也引用了对象A
这意味着它们的引用数都是2,在函数结束后还会存在,导致内存泄漏。
在IE8及更早版本的IE中,并非所有对象都是原生JavaScript对象。BOM和DOM中的对象是C++实现的组件对象模型对象模型(COM),而COM对象使用引用技术实现垃圾回收。
故只要设计COM对象,就无法避开循环引用问题。由于存在循环引用,DOM元素的内存可能永远不会被回收,即使它已经被从页面上删除了也是如此。但可以将其值设置为null以清除建立的循环引用
性能
垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。
开发者并不知道什么时候运行会收集垃圾,因此最好的办法就是在写代码时就做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
现代垃圾回收程序会基于对JavaScript运行时环境的探测来决定何时运行,基本上都是根据已分配对象的大小和数量来判断。
内存管理
在使用垃圾回收的编程环境中,开发者通常无需关心内存管理。但出于安全考虑,即为了避免运行大量JavaScript的网页耗尽系统内存而导致操作系统崩溃。
将内存占用量保持在一个较小的值可以让页面性能更好。如果数据不再必要,那么把它设置为null接触占用。不过解除一个值的引用并不会自动导致相关内存被回收。接触引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。
通过const和let声明提升性能
ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。
因为const和let都是以块为作用域,相比于使用var,使用这两个新关键字可能会更快地让垃圾回收程序接入,尽早回收应该回收的内存。
隐藏类和删除操作
V8引擎在将解释后的JavaScript代码编译为实际的机器码时会利用“隐藏类”。
运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪他们的属性特征。能够共享相同隐藏类的对象性能会更好。但给另外一个对象动态添加属性或是动态删除属性都会使其不再共享同一个隐藏类。
解决方法:
- 先创建再补充
- 把不想要的属性设置为null
内存泄漏
JavaScript中的内存泄漏大部分是由不合理的引用导致的。
意外声明全局变量是最常见但也最容易修复的内存泄漏问题。在window对象上创建的属性,只要window本身不被清理就不会消失。
定时器也可能会悄悄地导致内存泄漏,下面是一个定时器回调通过闭包引用外部变量的例子
let name = 'Jack';
setInterval(()=>{
console.log(name);
},100)
//只要定时器一直运行,回调函数中引用的name就会一直占用内存。
使用JavaScript闭包很容易在不知不觉中造成内存泄漏
let outer = function(){
let name = 'Jake';
return function() {
return name;
}
};
//调用outer()会导致分配给name的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理name,因为闭包一直引用着它。假如name的内容很大,那可能就是个大问题
静态分配和对象池
关键问题:如何减少浏览器执行垃圾回收的次数?
开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度
如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行。
假如一个函数里有创建新对象的操作,new Object,且其Object生命周期很短,并且该函数频繁被调用,那么垃圾回收调度程序会发现这里的对象更替速度很快,从而会更频繁地安排垃圾回收
该问题的解决方案是让该函数使用一个已有的对象,不要动态创建对象
使用对象池
在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象,设置其属性、使用它,然后再操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。
小结
JavaScript 变量可以保存两种类型的值:原始值和引用值。原始值可能是以下6种原始数据类型之Undefined、Null、Boolean、Number、String 和Symbol。原始值和引用值有以下特点。□原始值大小固定,因此保存在栈内存上。
-
从一个变量到另一个变量复制原始值会创建该值的第二个副本。
-
引用值是对象,存储在堆内存上。
-
包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
-
从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
-
typeof 操作符可以确定值的原始类型,而instanceof 操作符用于确保值的引用类型。任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。执行上下文可以总结如下。
-
执行上下文分全局上下文、函数上下文和块级上下文。
-
代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
-
函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃
至全局上下文中的变量。
-
全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
-
变量的执行上下文用于确定什么时候释放内存。
JavaScript 是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。JavaScript的垃圾回收
程序可以总结如下。
-
离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
-
主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。
-
引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript引擎不再使用这种算法,但某些旧版本的IE仍然会受这种算法的影响,原因是JavaScript会访问非原生JavaScript对象(如DOM元素)
-
引用计数再代码中存在循环引用时会出现问题
-
接触变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用