【变量、作用域与内存】- 我一字一行地重读红宝书(四) | 创作者训练营第二期
引言
历经两个多月断断续续地读完了JavaScript的经典书籍红宝书 -《JavaScript高级程序设计(第4版)
详细地读完一遍后发觉整本书知识点全而泛,乍一想每一章的细节,还是略显模糊。
于是督促自己计划编写每一章的着重点再次加深印象和理解,顺便记录自己的所学所想所悟。方便自身利用电脑的快速搜索关键词来进行快速定位和学习,也希望能帮助到有需要的同学们哈。
若是想要系统仔细的学习,当然还是看原书比较好,我也是强烈推荐的噢!这里内容只当个人复习和总结。
提示: 一些个人主观认为不重要或不流行的章节将进行删减
4. 变量、作用域与内存
ECMA-262 规定,JavaScript 变量是松散型的,变量不过就是特定时间点一个特定值的名称而已。因此变量的值和数据类型在脚本生命周期内可以改变。这样的变量很有意思,很强大,当然也有不少问题。如下是本章重点内容
- 通过变量使用原始值与引用值
- 理解执行上下文
- 理解垃圾回收
4.1 原始值与引用值
ECMAScript 变量包含两种不同类型的数据:原始值和引用值。
- 原始值:最简单的数据。
- 上一章讨论了 ES6 中 6 种原始值:Undefined、Null、Boolean、Number、String 和 Symbol。
- 保存原始值的变量是按值访问的,我们操作的就是存储在变量中的实际值。
- 引用值:由多个值构成的对象。
- 与其他语言不通,JavaScript 不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。
- 操作对象时,实际上操作的是对该对象的引用而非实际的对象本身。
- 保存引用值是变量是按引用访问的
在很多语言中,字符串是使用对象表示的,因此被认为是引用类型。ECMAScript 打破了一个惯例。
4.1.1 动态属性
原始值和引用值的定义方式很类似,创建一个变量,然后给它赋一个值。但引用值可以随时添加、修改和删除其属性和方法。而原始值不能有属性和方法,但不会因此而报错。
// 引用值可以动态操作属性和方法
let person = new Object();
person.name = "lindada";
console.log(person.name); // "lindada"
// 原始值无法动态操作属性和方法
let name = "lindada";
name.age = 27;
console.log(name.age); // undefined
原始类型的初始化可以只使用原始字面量形式。如果使用的是 new 关键字,则 JavaScript 会创建一个 Object 类型的实例,但其行为类似原始值。如下是这两种初始化方式的差异:
let name1 = "lindada";
let name2 = new String("lindada2");
name1.age = 21;
name2.age = 22;
console.log(name1.age); // undefined
console.log(name2.age); // 22
console.log(typeof name1); // string
console.log(typeof name2); // object
4.1.2 复制值
原始值和引用值在通过变量赋值时也有所不同。
-
原始值会被复制到新变量的位置,两个变量独立使用,互不干扰
let num1 = 5; let num2 = num1; num1 = 6; console.log(num2); // 5
-
引用值从一个变量赋给另一个变量时,存储的值也会被复制到新变量所在的位置。区别在于实际上复制的值是一个指针,它指向存储在堆内存中的对象。复制完成后,两个变量实际上指向同一个对象。
let obj1 = {}; let obj2 = obj1; obj1.name = "lindada"; // 反映到 obj2 中。 console.log(obj2.name); // "lindada"
4.1.3 传递参数
ECMAScript 中所有函数的参数(原始值、引用值)都是按值传递的。操作与它们各自复制值规则一致。
-
原始值参数
function addTen(num) { num += 10; return num; } let count = 20; let result = addTen(count); console.log(count); // 仍是 20,并非引用传递随之改变。 console.log(result); // 30
-
引用值参数
function setName(obj) { obj.name = "lindada"; obj = new Object(); obj.name = "lindada2"; } let person = new Object(); setName(person); console.log(person.name); // "lindada" // 意味着没有改变引用值的引用,仍是指向 person 这个引用值的指针。故引用值参数也是按值传递的。
ECMAScript 中函数的参数就是局部变量。
4.1.4 确定类型
ECMAScript 有多种方法来确定变量是什么类型。该如何使用,看自身需求操作。
-
typeof:判断原始值中的数据类型或判断是原始值还是引用值。
let s = "lindada"; let b = true; let i = 22; let u; let n = null; let o = {}; console.log(typeof s); // string console.log(typeof b); // boolean console.log(typeof i); // number console.log(typeof u); // undefined console.log(typeof n); // object console.log(typeof o); // object
-
instanceof:如果已知变量为引用值,用于判断其原型链上是否为给定的引用类,也可用于判断自定义类。
const person = {}; const color = []; const pattern = new RegExp('/*/'); // 自定义类 function Person(name, age) { this.name = name; this.age = age; } var person = new Person("lindada", 18); console.log(person instanceof Object); // true console.log(color instanceof Array); // true console.log(pattern instanceof RegExp); // true console.log(person instanceof Person); // true // instanceof 检测原始值,则始终会返回 false。 console.log(123 instanceof Number); // false
-
Object.prototype.toString.call(): 判断某个对象之属于哪种内置类型
// 函数类型 Function fn(){ console.log(“test”); } Object.prototype.toString.call(fn); // "[object Function]" // 日期类型 var date = new Date(); Object.prototype.toString.call(date); // "[object Date]" // 数组类型 var arr = [1,2,3]; Object.prototype.toString.call(arr); // "[object Array]" // 正则表达式 var reg = /[hbc]at/gi; Object.prototype.toString.call(reg); // "[object RegExp]"
4.2 执行上下文与作用域
-
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。
- 全局上下文是最外层的上下文,在浏览器中也就是 window 对象。
- 因此所有通过 var 定义的全局变量和函数都会成为 window 对象的属性和方法。
- 使用 let 和 const 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。
-
上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会销毁)。
-
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下栈上。在函数执行完之后,上下栈会弹出该函数的上下文,将控制权返还给之前的执行上下文。
-
ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。
-
上下文中的代码在执行时,会创建变量对象的一个作用域链。
- 这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。
- 代码正在执行的上下文的变量对象始终位于作用域链的最前端。
- 如果上下文是函数,则其活动对象用作变量对象
- 活动对象最初只有一个定义变量:arguments(全局上下文中没有这个变量)。
- 作用域链的下一个变量对象是包含该变量对象的上下文,以此类推至全局上下文(始终是作用域链的最后一个变量对象)。
-
如下例子展示上述规则。
var color = "blue"; function changeColor() { let anotherColor = "red"; function swapColors() { // 该上下文可以访问 color、anotherColor 和 tempColor。 let tempColor = anotherColor; anotherColor = color; color = tempColor; } // 这里可以访问 color、anotherColor。 swapColor(); } // 这里只能访问 color。 changeColor()
上下文之间的连接是线性的、有序的。每个上下文都可以到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。
函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同访问规则。
4.2.2 变量声明
ES6 之后,新增的 let 和 const 变量声明关键字成为了变量声明的首选。
4.2.2.1 使用 var 的函数作用域声明
-
在使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。
-
如果变量未经声明就被初始化了,那么会被自动添加到全局上下文中。
function add(num1, num2) { var sum = num1 + num2; sum2 = num1 - num2; } let result = add(20, 10); // 30 console.log(sum); // 报错,该变量不在当前上下文中。 console.log(sum2); // 10,sum2 在调用 add() 之后被添加到了全局上下文中
未经声明而初始化变量在 JavaScript 中会导致很多问题,为此在初始化变量之前一定要先声明变量。在严格模式下,未经声明就初始化变量会报错。
-
var 声明会被拿到函数或全局作用域的顶部,这个现象叫做提升。在实践中,提升会导致合法却奇怪的现象。
// 全局作用域 console.log(name); // undefined var name = "lindada"; // 变量提升 // 函数作用域 function() { console.log(name); // undefined var name = "lindada"; // 变量提升 }
4.2.2.2 使用 let 的块级作用域声明
-
let 变量声明与 var 变量声明很相似,但它的作用域时块级的,由最近的一对包含花括号 {} 来界定,这也是 JavaScript 中的新概念。
- 如 if 块、while 块、function 块,甚至单独的 {} 块作用域。
-
let 不能在同一作用域内声明两次(var 则会覆盖声明)。
-
let 不会被提升。
// 块级作用域 if (true) { // 该变量声明在块级作用域上 let a; } console.log(a); // ReferenceError // 重复声明 var a = 3; var a = 4; console.log(a); { let a = 3; let a = 4; console.log(a); // SyntaxError } // 变量提升 console.log(b); let b = 4; // ReferenceError
严格来说,let 在 JavaScript 运行时中也会被提升,但由于暂时性死区的缘故,实际上不能在声明之前使用 let 变量。因此,从写 JavaScript 代码的角度来说,let 的提升跟 var 是不一样的。
4.2.2.3 使用 const 的常量声明
-
使用 const 声明的变量必须同时初始化为某个值。
-
一经声明,在其生命周期的任何时候都不能再重新赋予新值(但对象的键则不受限制)。
- 如果想要让整个对象都不能修改,可以使用 Object.freeze(),来静默对象修改失败。
-
其余规则与 let 变量声明类似。
const a; // SyntaxError,常量声明时没有初始化。 const b = 3; console.log(b); // 3 b = 4; // TypeError,不能给常量变量赋值。 const o1 = {}; o1.name = 'lindada'; console.log(o1.name); // 'lindada' o1 = {}; // TypeError,不能给常量变量重新赋值。 const o2 = Object.freeze({}); o2.name = 'lindada'; console.log(o1.name); // undefined
谷歌的 V8 引擎会执行优化常量声明,故若仅一次赋值的变量,应尽可能多的使用 const 声明。
4.3 垃圾回收
JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。不像 C 和 C++ 等语言中需要跟踪内存使用,JavaScript 为开发者卸下了这个负担,通过自动内存管理实现内存分配和闲置资源回收。
简单思路来说:周期性地确定哪个变量不会再使用,然后释放占用的内存。
不过判断某个变量是否还有用,属于不可判定的问题。但在浏览器的发展史上,用过两种主要的标记策略:标记清理和引用计数。
4.3.1 标记清理
这是 JavaScript 最常用的垃圾回收策略。当变量进入上下文时,该变量会被加上存在于上下文中的标记。当变量离开上下文时,也会被加上离开上下文的标记。
给变量加标记的方式的有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护 在上下文中 和 不在上下文中 两个变量列表,可以把变量从一个列表转移到另一个列表来进行标记。(但标记的过程并不重要,关键的是策略。)
垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了。(原因是任何在上下文中的变量都访问不到它们了)随后垃圾回收程序做一次 内存清理,销毁带标记的所有值并收回它们的内存。
到 2008 年,IE、Firefox、Opera、Chrome 和 Safari 都在自己的 JavaScript 实现中采用标记清理(或其变体),只是在运行垃圾回收的频率上有所差异。
4.3.2 引用计数
另一种没那么常用的垃圾回收策略是引用计数,其思路是对每个值都记录它被引用的次数。
一开始声明变量并给它赋一个引用值时,这个值的引用数为 1。若再次被赋给另一个变量,那么再加 1。
如果保存对该值引用的变量被其他值给覆盖了,那么引用值减 1.
故当垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。
引用计数最早由 Netscape Navigator 3.0 采用,很快遇到了严重问题:循环引用
function porblem() {
let obj1 = new Object();
let obj2 = new Object();
obj1.someOtherObject = obj2;
obj2.anotherObject = obj1;
}
// 在函数结束后,虽然这两个对象都不在作用域中,但在引用计数策略下引用数总是 2。
需要显示地将循环引用的对象设置为 null(会切断变量与其之前引用值之间的关系),才能清除循环引用。
4.3.3 性能
垃圾回收程序会周期性运行,频繁的调用会造成性能损失,因此垃圾回收的时间调度很重要。因此最好的方法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
V8 团队在 2016 年的一篇博文的说法:“在一次完整的垃圾回收之后,V8 的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃圾回收”
类似于到达一定的阀值则进行调用回收。如 IE7 起始阀值与 IE6 相同,但如果垃圾回收程序回收的内存不到已分配的 15%,这些变量、字面量或者数组槽位的阀值就会翻倍(说明整个生命周期内变量很多)。如果有一次回收的内存达到已分配的 85%,则阀值重置为默认值
在某些浏览器中是有可能(但不推荐)主动触发垃圾回收的。在 IE 中,window.collectGarbage() 方法会立即触发垃圾回收。在 Opera7 及更高版本中,调用 window.opera.collect() 也会启动垃圾回收程序。
4.3.4 内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。但将内存占用量保持在一个较小的值可以让页面性能更好。因此优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。
4.3.4.1 解除引用
局部变量在超出作用域后就会被自动解除引用,但全局作用域的值不会,这个建议最适合全局变量和全局对象的属性。
function createPerson(name) {
let localPerson = new Object();
localPerson.name = name;
return localPerson
}
// 在全局下创建的对象实例
let globalPerson = createPerson("lindada");
// 解除 globalPerson 对值的引用
globalPerson = null;
不过值得注意的值,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下一次垃圾回收时会被回收。
4.3.4.2 通过 const 和 let 声明提升性能
ES6 增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为 const 和 let 都以块为作用域,相比于函数作用域或全局作用域更早地让垃圾回收程序释放内存空间。
4.3.4.3 隐藏类和删除操作
截止 2017 年,Chrome 是最流行的浏览器,采用的是 V8 JavaScript 引擎。V8 在将解释后的 JavaScript 代码编译成为实际的机器码会利用隐藏类。如果你的代码非常注重性能,那么这一点可能对你很重要。
在运行代码期间,V8 会将创建的对象与隐藏类关联起来,以跟踪它们的属性特性,进而能够共享相同隐藏类的对象性能会更好。
function Article() {
this.title = "msg";
}
let a1 = new Article();
let a2 = new Article();
// V8 会在后台配置,让这两个类实例共享相同的隐藏类
// 共享同一个构造函数和原型
a2.author = "lindada";
delete a2.author;
// 但增加或删除属性的操作,则两个实例就会对应两个不同的隐藏类
a2.author = null;
// 最佳的实例是把不需要的属性设置为 null。这样可以保持隐藏类不变和继续共享,且删除引用值供垃圾回收程序回收的效果。
4.3.4.4 内存泄漏
JavaScript 中的内存泄漏大部分是由不合理的引用导致的。
-
意外声明全局变量
-
function setName() { name = 'lin' // 等同于 window.name = 'lin',应使用变量声明。 }
-
因此只要 window 本身不被清理就不和消失,造成内存泄漏
-
-
定时器问题
-
function name = 'Jake' setInterval(() => { console.log(name) }, 100) // 若没有及时清除定时器
-
只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。
-
-
闭包问题
-
let outer = function() { let name = 'lin' return function() { return name } }
-
以上闭包实例中,只要 outer 函数存在就不能清理 name。若存在的数据较大,内存泄漏就是个大问题了。
-
4.3.4.5 对象池
为了提升 JavaScript 性能,最后要考虑的一点就是"压榨"浏览器了。也就是如何减少浏览器执行垃圾回收的次数,因此开发者可以间接控制触发垃圾回收的条件。
理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。
-
已有对象
-
function addVector(a, b) { let resultant = new Vector() resultant.x = a.x + b.x resultant.y = a.y + b.y return resultant }
-
调用这个函数的时候会在堆上创建一个新对象,然后修改它,最后返回给调用者。当这个对象生命周期很短时,那么很快就会被垃圾回收掉,假如这个函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁地安排垃圾回收。
-
function addVector(a, b, resultant) { resultant.x = a.x + b.x resultant.y = a.y + b.y return resultant }
-
可以使用一个已有的对象来避免重复创建新对象。
-
-
对象池
-
在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。开发者可以向这个对象池请求一个对象并且操作使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序也就不会频繁地运行。
-
// 对象池的伪实现 // vectorPool 是已有的对象池 let v1 = vectorPool.allocate() let v2 = vectorPool.allocate() // 修改 v1.x = 10 v1.y = 5 v2.x = -3 v2.y = -6 // 调用 addVector(v1, v2) // 主动释放 // 如果对象有属性引用了其他对象,则这里也需要把这些属性设置为 null v1 = null v2 = null
-
如果对象池按需分配矢量(在对象不存在时创建新的,在对象存在时则复用),那么这个实现本质是一种贪婪算法,有单调增长但为静态的内存。对象池必须使用某种结构维护所有对象,因此数组是比较好的选择。不过使用数组必须留意不要招致额外的垃圾回收。
-
注意:静态配置是优化的一种极端形式,如果你的应用程序被垃圾回收严重地拖了后腿,可以考虑利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不用考虑。
小结
JavaScript 变量可以保存两种类型的值:原始值和引用值。它们具有以下特点:
- 原始值
- 原始值大小固定,因此保存在栈内存上。
- 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
- 引用值
- 引用值是对象,存储在堆内存上。
- 包含引用值的变量实际上只包含指向对应对象的一个指针,而不是对象本身。
- 从一个变量到另一个变量复制引用值只会复制指针,因此两个变量都指向同一个对象。
- typeof 操作符可以确定值的原始类型,而 instanceof 操作符用于确保值的引用类型(也就是引用值的原型)。
- Object.prototype.toString.call() 返回数据类型的内置对象,也可以返回自定义的数据类型
任何变量都存在于某个执行上下文中(称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分,执行上下文可以总结如下。
- 执行上下文分为全局上下文、函数上下文和块级上下文。
- 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
- 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
- 只能往上找,不能往下找。
- 全局上下文只能访问全局上下文中的变量和函数,不能访问局部上下文的任何数据。
- 局部上下文可以访问本身上下文数据,也可以通过作用域链来访问全局上下文中的变量和函数。
- 变量的执行上下文用于确定什么时候释放内存。
JavaScript 是使用垃圾回收的编程语言,开发者不需要操作内存分配和回收。JavaScript 的垃圾回收程序可以总结如下:
- 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
- 主流的垃圾回收算法是标记清理,即先给当前不适用的值加上标记,再回来回收它们的内存。
- 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。
- 因引用计数在代码中存在循环引用时会出现问题,JavaScript 引用不再使用这种算法。
- 某些旧版本的 IE 仍然会受这种算法的影响,原因是 JavaScript 会访问非原生 JavaScript 对象。
- 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。