4、变量、作用域 与 内存

108 阅读22分钟

变量、作用域 与 内存

1 原始值 与 引用值

ECMAScript 的变量包含两种不同类型的数据:原始值 和 引用值。

  • 原始值:就是最简单的数据类型。string、number、boolean、null、undefined、symbol。
  • 引用值:由多个值构成的对象。

在把一个值赋值给变量时,JavaScript 引擎必须要确定这个值是原始值还是引用值。

动态属性

原始值不能有属性,哪怕尝试给原始值添加属性不会报错。

let name = "Nicko";
name.age = 23;
console.log(name.age);	// undefined

只有引用值可以动态添加后面可以使用的属性。

注意:原类类型的初始化只使用原始字面量形式。

let name1 = 'Nicko';
let name2 = new String('Matt');
name1.age = 23;
name2.age = 26;
console.log(name1.age);	// undefined
console.log(name2.age);	// 26
console.log(typeof name1);	// String
console.log(typeof name2);	// Object

复制值

原始值:

把一个原始值复制给另一个变量时,原始值会被复制到新的变量位置。两个变量可以独立使用,互不干扰。

引用值:

把一个引用值复制给另一个变量时,存储在变量中的值也会被复制到新的变量所在的位置。区别在于,这里复制的值实际上时一个指针,它指向存储在堆内存中的对象。

传递参数

ECMAScript 中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。

function addTen(num) {
    num += 10;
    return num;
}
let count = 20;
let result = addTen(count);
console.log(count);	// 20,没有变化
console.log(resule);	// 30


// 不会影响外部的原始变量count,参数num和变量count互不干扰。他们只是保存了一样的值而已。
// 如果num是按引用类型传递的,那么count的值也会被修改为30。
function setName(obj) {
    obj.name = 'Nichoias';
}

let preson = new Object();
setName(preson);
console.log(preson.name);	// Nichoias

// 函数内部,obj 和 preson 都指向同一个对象。即使对象是按值传进函数的,obj也会通过引用访问对象。当函数内部给obj设置了name属性时,函数外部的对象也会反映这个变化,因为 obj 指向的对象保存在全局作用域的堆内存上。

确定类型

typeof 虽然对原始类型很有用,但是对引用类型用处不大。ECMAScript 提供了 instanceof 操作符。

语法:

result = variable instanceof constructor

例子:

console.log(preson instanceof Object);	// 变量 preson 是 Object ?
console.log(colors instanceof Array);	// 变量 colors 是 Array ?
console.log(pattern instanceof RegExp);	// 变量 pattern 是 RegExp ?

按照定义,所有 引用值都是 Object 的实例,所以使用 instanceof 操作符检测任何引用值和 Object 都会返回 true。

如果用 instanceof 检测原始值,则始终会返回 false。因为原始值不是对象。

2 执行上下文与作用域

执行上下文的概念在在 JavaScript 中是颇为重要的。

变量或函数的上下文决定了它可以访问哪些数据,以及它们的行为。

每个上下问都有一个关联的变量对象。(无法访问这个变量对象,但后台处理数据会用到它)

全局上下文是最外层的上下文。

根据ECMAScript 实现的宿主环境,表示全局上下文的对象可能不一样。在浏览器中,全局上下文就是我们常说的 window 对象的属性和方法。因此所有通过 var 定义的全局变量和函数都会成为 window 对象的属性和方法。使用 let 和 const 的顶级声明不会定义在全局上下文,但在作用域链的解析上效果一样。

每个函数都有自己的上下文。当代码流进入到函数时,函数的上下文被推倒一个上下文栈上。当函数执行结束后。上下文栈会弹出该函数的上下文,控制权重新返还给之前的执行上下文。

上下文中的代码在执行的时候,会创建变量对象的一个作用域链。这个作用域链决定各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象用作变量对象。活动对象最初只有一个定义变量 arguments(全局上下文中没有这个变量)。作用域链中的下一个变量对象来自包含上下文,在下一个对象来自在下一个包含上下文,以此类推直到全局上下文。全局上下文的变量对象始终是作用域链的最后一个变量对象。

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。

代码执行时,标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域的最前端开始,然后逐级往后,直到找到标识符。如何没有直到标识符,通常会抛出错误。

每个上下文都可去到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。

函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则。

作用域链增强

一些语句会导致在作用域链前端临时添加上一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面的任何一种情况时:

  • try/catch 语句的 catch 块
  • with 语句 【将代码作用域设置为特定的对象】

这两种情况下,都会在作用域链前端添加一个变量对象。对 with 语句来说,会向作用域链前端添加指定的对象;对 catch 语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。

fucntion buildUrl() {
    let qs = '?debug=ture';
    with (location) {
        let url = href + qs;
    }
}

// 这里,with 语句将 location 对象作为上下文,因此 lcoation 会被添加到作用域链的最前端,buildUrl() 函数中定义了一个变量 qs 。当 with 语句中的代码引用变量 href 时,实际上引用的是 location.href,也就是自己的变量对象的属性。在引用 qs 时,引用的则是定义在 buildUrl() 中的那个变量,它定义在函数上下文的变量对象上。而在 with 语句中使用 var 声明的变量 url 会成为函数上下文的一部分,可以作为函数的值被返回;但像这里使用 let 声明的变量 url,因为被限制在块租用,所以在 with 块之外没有定义。

变量声明

使用 var 的函数作用域声明

在使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,做接近的上下文就是函数上下文。在 with 语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文。

注意:未经声明而初始化变量是 javascript 编程非常常见的错误。在严格模式下,未经声明会导致报错。

var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前,这个现象叫作 “提升”。

使用 let 的块级作用域声明

ES6 新增的 let 关键字跟 var 很相似,但它的作用域是块级的,这也是 javascript 中的新概念。

块级作用域由最近的一对花括号{}界定。

换句话说,if块、while块、fcuntion块,甚至连单独的块也是 let 声明变量的作用域。

let 和 var 的不同之处是在同一个作用域不能声明两次。重复的 var 声明会被忽略,而重复的 let 声明会抛出 SyntaxError 。

使用 var 声明的迭代变量会泄漏到循环外部,这种情况应该避免。而 let 不会。

使用 const 的常量声明

ES6 同时还新增了 const 关键字。使用 const 关键字声明变量必须同时初始化某个值。一经声明,在其生命周期的任何时候都不能再重新赋予新值。

const 出了要遵循以上规则,其他方面与 let 声明是一样的。

const 声明只应用到顶级原语或者对象。换句话说,赋值为对象的 const 对象不能再被重新赋值为其他引用值,但对象的键则不受限制。

如果想让整个对象都不能修改,可以使用 Object.freeze()。这样在给属性赋值时虽然不会报错,但会静默失败。

const o3 = Object.freeze({});
o3.name = "jake";
console.log(o3.name);	// undefined

注意:开发实践中,如果开发流程并不会因此而受很大影响,就应该尽量多的去使用 const 声明,除非确实需要一个将来重新赋值的变量。这样可以从根本上保证提前发现重新赋值导致的 bug。

标识符查找

当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个表示符表示什么。搜索开始于作用域链前端,以给定的名称搜索对应的标识符。

如果再局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿着作用域链搜索。(注意,作用域链中的对象也有一个原型链,因此搜索可能涉及每个对象的原型链)。这个过程一直持续到搜索至全局上下文的变量对象,如果仍然没有找到标识符,则说明其未声明。

对这个搜索过程而言,引用局部变量会让搜索自动停止,而不继续搜索下一级变量对象。

如果局部上下文中有一个同名的标识符,那就不能再上下文中引用父上下文的同名标识符:

let color = 'blue';

funciton getColor() {
    let color = 'red';
    return color;
}

console.log(getColor());	// red

使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次:

let color = 'blue';

funciton getColor() {
    let color = 'red';
    {
        let color = 'green';
        return color;
    }
    
}

console.log(getColor());	// green

// 在局部变量 color 声明之后的任何代码无法访问全局变量 color,除非使用完全限定的写法 window.color

3 垃圾回收

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。

JavaScript 通过自动内存管理实现内存分配和闲置资源回收。

基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。

这个垃圾回收过程是周期性的。

垃圾回收过程是一个近似且不完美的方案,因为某些块内存是否还有用,属于“不可判定的”。

垃圾回收标记标记策略分为两种:标记清理 和 引用计数。

标记清理

JavaScript 最常用的垃圾回收策略就是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数内部声明变量时,这个变量就会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。

给变量灰机的方式有很多种:

  • 当变量进入上下文时,反转某一位
  • 可以维护 “在上下文中” 和 “不在上下文中” 两个变量列表,可以把变量从一个列表转移到另一个列表。

垃圾回收程序运行的时候,会标记内存中存储的所有变量。然后,它会将所有上下文中的变量,以及被上下文中的变量引用的变量标记去掉。在此之后,再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并回收它们的内存。

引用计数

引用计数(reference counting)【没那么常用】。声明变量并给他赋一个引用值,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。如果保存对该值引用的变量被其他值给覆盖了,则引用数减1。当一个值的引用数为0的时候,就可以安全的回收了。

引用计数有一个很严重的问题:循环引用

function fn() {
    let objectA = new Object();
    let objectB = new Object();
    objectA.someOtherObject = objectB;
    objectB.anotherObject = objectA;
}

// 此时 objectA 和 objectB 的引用值都是 2。当函数结束后,这两个对象都不在作用域中了。而引用计数策略下 objectA 和 objectB 在函数结束后还会存在。如果调用 n 次这个函数,那么就有 n对 objectA 和 objectB 存在内存中不会被回收。

为了避免循环引用的问题,把变量设置为 null 这样会切断变量与其值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。

性能

垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的实际调度很重要,尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码的时就要做到无论什么时候开始手机垃圾,都能让它尽快结束工作。

内存管理

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不在必要,可以把它设置为 null ,从而释放器引用。这也可以叫做 解除引用。 这个建议最适合全局变量 和 全局对象 的属性。局部变量在超出作用域后会被自动接触引用。

function createPerson(name) {
    let localPerson = new Object();
    localPerson.name = name;
    return localPerson;
}

let globalPerson = createPerson('Jack');

// 解除引用
globalPerson = null;

解除引用的关键在于确保相关的值不在上下文里了,因此它在下次垃圾回收时会被回收。

通过 const 和 let 声明提升性能

ES6 新增的这两个关键字,不仅有助于改善代码风格,同样有助于改进垃圾回收的过程。因为 const 和 let 都以块(而非函数)为作用域。所以相比 var,使用这里两个新关键字可能会更早的让垃圾回收程序介入,今早回收应该回收的内存。

隐藏类 和 删除属性

V8 JavaScript 引擎,在将解析收的 JavaScript 代码编译为实际的机器码时会利用 “隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很重要。

运行期间 V8 会将创建的对象与隐藏类关联起来,已跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8 会针对这种情况进行优化,但不一定能够做到。比如下面的代码:

function Article() {
    this.tithle = "Inauguration Ceremony Features Kazoo Band";
}

let a1 = new Article();
let a2 = new Article();

V8 会在后台配置,让两个类实例共享一个隐藏类,因为这两个实例共享同一个构造函数和原型,假设之后又添加了下面的代码:

a2.name = "Jake";

此时,两个 Article 的实例就不在共享同一个隐藏类了,而是两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有可能对性能造成影响。

解决办法:

避免 JavaScript 的 先创建再补充式的动态属性赋值,并在构造函数中一次性声明所有属性,如下所示:

function Article(op_author) {
    this.tithle = "Inauguration Ceremony Features Kazoo Band";
    this.author = op_author;
}

let a1 = new Article();
let a2 = new Article('Jake');

这样两个实例共享的还是同一个隐藏类,从而带来潜在的性能提升。

不过要记住,使用 delete 关键字删除某一个实例的属性,会造成生成一个相同的隐藏类片段。

function Article(op_author) {
    this.tithle = "Inauguration Ceremony Features Kazoo Band";
    this.author = op_author;
}

let a1 = new Article();
let a2 = new Article('Jake');

delete a2.author;

使用了 delete关键字 虽然两个实例使用了相同的构造函数,但是也不再共享同一个隐藏类了。动态属性删除 与 动态属性添加 一样会导致使用相同构造函数的实例分别对应各自的隐藏类。最佳实践的解决办法就是把属性设置为 null,这样即保持了隐藏类不变和继续共享,又达到了删除引用值供垃圾回收程序回收效果。比如下面的代码:

function Article(op_author) {
    this.tithle = "Inauguration Ceremony Features Kazoo Band";
    this.author = op_author;
}

let a1 = new Article();
let a2 = new Article('Jake');

a2.author = null;

内存泄露

概念:不在需要使用的内存对象,没有被垃圾回收机制清理回收掉,就叫 “内存泄露”。

意外声明全局变量是最常见但也是做容易修复的内存泄露问题:

function setName() {
    name = 'Jake';	// 不知觉的情况下,给全局变量声明一个name变量,并赋值 Jake
}

此时,解析器会把变量 name 当做 window 的属性来创建(相当于 window.name = 'Jake')。可想而知,在 window 对象上创建的属性,只要 window 本身不被清理就不会消失。这个问题很容易解决,只要在变量声明前面加上 const、let 或 var 关键字即可,这样变量就会在函数执行完毕后离开作用域。

定时器也会悄悄地导致内存泄露。下面代码中,定时器的回调通过闭包引用了外部变量:

let name = 'Jake';
setInterval( () => {
    console.log(name);
}, 100);

定时器一直运行,回调函数中引用的 name 就会一直存在占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。

使用 JavaScript 闭包很容易在不知不觉间造成内存泄露。请看下面的例子:

let outer = function() {
    let name = 'Jake';
    return function() {
        return name;
    };
};

调用 outer( ) 会导致分配给 name 的内存被泄露。上面代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符串)那可能就是大问题了。

静态分配与对象池

为了提升性能,最后要考虑的一点就是压榨浏览器了。此时一个关键的问题就是,如何减少浏览器执行垃圾回收的次数,开发者无法控制什么时候开始清理垃圾,但可以间距控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。

浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。

如果有很多对象被初始化,然后一下子又超出了作用域,那么浏览器就会采取更激进的方式调度垃圾回收程序运行,这样当人会影响性能。下面的代码,这是一个计算二维矢量的函数:

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;
}

当然,这就需要在其他地方实例化矢量参数 resultant,但这个函数的行为没有变,那么在哪里创建矢量可以避免不让垃圾回收调度盯上呢?

一个策略就是使用 对象池,在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池申请一个对象,然后设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现又对象更替,因此垃圾回收程序就不会那么频繁的执运行。下面是一个对象池的伪实现:

// vectorpool 是已有的对象池
let v1 = vectorpool.allocate();
let v2 = vectorpool.allocate();
let v3 = vectorpool.allocate();

v1.x = 10;
v1.y = 5;
v2.x = -3;
v3.y = -6;

function addVector(v1, v2, v3) {
    v3.x = v1.x - v2.x;
    v3.y = v1.y - v2.y;
    return v3;
}

addVector(v1, v2, v3);

console.log([v3.x, v3.y]);	// [7, -1] 

vectorpool.free(v1);
vectorpool.free(v2);
vectorpool.free(v2);

// 如果对象有属性引用了其他对象
// 则这里也需要把这些属性设置为 null
v1 = null;
v2 = null;
v3 = null;

如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。比如下面这个例子:

let vectorpool = new Array(100);
let vector = new Vector();
vectorpool.push(vector);

由于 JavaScript 数组的大小是动态可变的,引擎会删除大小为 100 的数组,在创建一个新的大小为 200 的数组。垃圾回收程序看到这个删除操作,说不定很快就要来跑来清理一次垃圾。要避免这种动态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上面的先删除在创建的操作。不过必须事先想好这个数组有多大。

注意:

静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不用考虑。

4 小结

JavaScript 变量可以保存两种类型:原始值 和 引用值。

原始值有以下6中:String、Number、Boolean、Undefiend、Null、Symbol。

引用值:比如 let arr = new Array(); let s = new String('Dong');

原始值 和 引用值有以下特点:

  • 原始值大小固定,因此保存在 栈内存 上。
  • 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
  • 引用值是毒性,存储在 堆内存 上。
  • 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
  • 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
  • typeof 操作符可以确定原始值类型。而 instanceof 操作符用于确保引用值类型。

任何变量(不管包含的是原始值还是引用值)都存在与某个执行上下文(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。执行上下文可以总结如下。

  • 执行上下文分全局上下文、函数上下文、块级上下文。
  • 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
  • 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
  • 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
  • 变量的执行上下文用于确定什么时候释放内存。

JavaScript 是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。JavaScript 的垃圾回收程序可以总结如下:

  • 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
  • 主流的垃圾回收算法是标记处理,即先给当前不使用的值加上标记,再回来回收它们的内存。
  • 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript 引擎不再使用这种算法,但某些旧版的IE仍然会受这种算法的影响。原因是 JavaScript 会访问非原生 JavaScript 对象(如 DOM 元素)。
  • 引用计数在代码中存在循环引用时会出现问题。
  • 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。