愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

《Javascript高级程序设计》学习笔记第四章(变量类型、作用域、内存管理)

一、基本类型和引用类型

ECMAScript中包含有两种不同数据类型的值:

  • 基本类型值:简单的数据段。基本数据类型有:number、string、boolean、null、undefined
  • 引用类型值:可能由多个值构成的对象

对于引用类型,我们可以为引用类型动态地添加属性。但是对于基本类型,是不能添加属性的(尽管这么做不会报错,但是没有任何效果),如:

var person = new Object;
person.name = 'RuphiLau';
person.name; // RuphiLau

var name = 'Ruphi';
name.age = 18;
name.age; // undefined

1、变量值的复制

对于基本类型的变量,我们在执行复制操作的时候。会在内存空间中复制一块新的区域,所以基本类型的变量进行复制后,所得到的是一个副本。被复制变量和复制出的变量,是彼此独立的,不会相互影响。如:

var a = 1;
var b = a;

这个过程如:
js4-1.png
而对于引用类型,那么情况就不一样了。对于引用类型,在执行复制的时候,复制的也是一个值,只不过,这个值不是对象的内容。而是一个指向对象的指针,对象的内容,实际上是存储在堆内存中的,复制对象,只是创建了一个指向同一块堆内存的新的指针。所以,两个变量的操作,会互相影响。这个过程如:

var a = new Object;
var b = a;

b.name = 'Ruphi';
a.name; // Ruphi

js4-2.png

2、传递参数

在JS中,所有函数的参数,都是按值传递的。即,当把函数外部的值复制给函数内部的参数的时候,和发生在变量之间的复制是一样的:基本类型值的传递和基本类型变量的复制一样,引用类型值的传递,和引用类型变量的复制一样。
有以下例子:

function fn(x) {
    x += 10;
    return x;
}

var count = 20;
var result = fn(count);
count;  // 还是20,没变化
result; // 30

这说明,对于基本类型,变量值的传递是独立的,互不相影响的。再看如下例子:

function fn(o) {
    o.name = 'Ruphi';
}

var obj = new Object;
fn(obj);
obj.name; // Ruphi

这难道是说明,引用类型的值,是按引用传递的呢?其实不是的。
在这里,当我们传入实参obj的时候,会创建一个新的内存单元,然后参数o的值,就会拷贝obj的值(即对同一块对象内存单元的指针),所以本质上,还是按值传递(只不过,这个值是一个指针),如:
js4-3.png
其实,我们可以通过以下例子来佐证:

function fn(object) {
    object = new Object;
    object.name = 'Ruphi';
}

var obj = new Object;
fn(obj);
obj.name; // undefined

在函数内,我们把实参object指向了另一个新创立的对象,然后对这个对象添加name属性。假如对引用类型的数据,是引用传递的话,那么,应该是发生如图所示的情况:
js4-4.png
也就是object和obj是同一块内存单元,object只是obj的别名。那么,这时候我们让一个新创建的对象赋给object的时候,obj的内容也应该发生改变,那么执行object.name = 'Ruphi'后,obj也应该获得name这一属性。但是实际上,并没有,obj.name的执行结果是undefined。这就说明,引用类型在函数内也是按值传递的。当我们新建一个对象赋给object后,object这个指针就断开了原来对obj所指向的那一块堆内存的引用,而指向了新建的对象的堆内存,所以自然的,两个对象是独立的,就不会互相影响。


二、执行环境与作用域

1、理解作用域链

执行环境,是JavaScript中最为重要的一个概念。

  • 每一个执行环境,都有一个与之关联的 变量对象(VO,Variable Object),VO中保存着环境中所有的变量和函数。我们无法直接访问VO,但是解析器需要使用到它
    全局执行环境,是最外围的一个执行环境。全局执行环境,会因ECMAScript实现时的宿主环境不同而不同。如在浏览器环境中,全局执行环境是window对象,而在NodeJS中,全局执行环境是global对象,所有全局的变量、函数都是作为全局对象的属性和方法所创建的。
  • 执行环境中的代码执行完毕后,该执行环境就会被销毁,执行环境中保存的变量、函数定义也随之销毁
  • 每个函数都拥有一个执行环境,当执行流进入一个环境后,函数的环境就会被推入环境栈中,而函数执行完毕后,环境栈就会弹出其环境,把控制权交给先前的执行环境
  • 每一个执行环境,都会创建一个作用域链(SC,Scope Chain)。作用域链的作用是:保证对执行环境有权访问的变量、函数的访问是有序的,只能向上访问,而不能向下访问。SC的最前端,保存的是当前执行代码所对应的环境的VO。如果执行环境是一个函数,那么就将该函数的活动对象(AO,Activation Object)作为VO。对AO而言,它一开始只包含一个变量(arguments对象)
  • SC中的下一个VO来自于包含环境,再下一个VO来自于下一个包含环境,如此类推一直延续到全局执行环境(全局执行环境是SC的最后一个对象)

在有了SC后,标识符解析就可以沿着这个SC一级一级地搜索。标识符的查找,从SC的前端开始,一直逐级地向后查找,直到找到标识符为止(如果找不到,就会报错)。如:

var color = 'blue';
function fn() {
    color = 'red';
}
fn();
color;

例子中,fn()的SC包含了:fn的AO(保存着arguments对象)、全局环境的VO。在查找color这一标识符的时候,首先在AO中查找,因为没有找到,所以就沿着SC读取到了全局环境的VO,在VO就中找到了color这一标识符。

再如以下的例子:

var a = 1;

function fn() {
    var b = 2;
    function foo() {
        var c = 3;
        b = a;
        a = c;
    }
    foo();
}

fn();

以上代码,涉及了三个执行环境:全局环境、fn的局部环境、foo的局部环境。每一个执行环境,都有自己的VO。

  • 在全局环境中,有一个变量a和一个函数fn,它的SC为:全局环境的VO
  • fn的局部环境中,有一个变量b和一个函数foo。但是它可以访问到全局环境的a变量。因为fn的SC为:fn的AO -> 全局环境的VO
  • foo的局部环境中,有一个变量c。但是它可以访问到fn的b变量、全局的a变量。此时,foo的SC为:foo的AO -> fn的VO -> 全局环境的VO

在搜索的时候,总是会沿着SC搜索,当在当前VO中找不到对应的标识符的时候,就会寻找下一个VO。但是,在作用域链中,只能是往父级VO查找,不能往子级VO查找(在SC中是往后查找,而不是往前查找)

2、延长作用域链

在JS中,有两个语句,可以在SC的前端临时加入一个VO,该VO会在代码执行后被移除。这两个语句就是:

  • try-catch中的catch
  • with语句

with语句为例子,如:

function buildUrl() {
    var qs = '?debug=true';
    with(location) {
        var url = href + qs;
    }

    return url;
}

在这里,with接收了location对象,于是就创建了一个VO,VO中包含了location对象中的所有属性和方法,所以在访问href的时候,就可以在当前VO中找到。而在访问qs的时候,当前VO中找不到,就需要在SC中往后查找,于是就在buildUrl的VO中找到了。然后,with语句内部又定义了一个url变量,url变量因而成为了函数执行环境的一部分,被添加到了AO中,所以可以作为函数的返回值而返回。

3、没有块级作用域

在ES6之前,是没有块级作用域的说法的。能够创建作用域的,就只有函数。所以,会有以下的情况发生:

if(true) {
    var color = 'blue';
}
color; // blue,因为没有块级作用域

for(var i=0; i<5; ++i) {}
i; // 5


三、垃圾回收机制

在JavaScript中,无需开发人员手动管理内存。执行环境便会自己负责追踪并管理内存,而这种管理内存机制的实现,主要是通过垃圾回收机制来实现的。垃圾回收机制的基本原理为:找出不再使用的变量,回收其内存。
在JavaScript中,主要有两种垃圾回收机制:

1、标记清除

标记清除,是目前JavaScript实现中广泛采用的方法。基本原理为:当一个变量进入环境的时候,就将该变量标为“进入环境”,而当变量离开环境的时候,就把变量标为“离开环境”,具体而言,可以一开始就对所有的变量打一个标记,然后去除掉环境中的变量以及被环境引用的变量,最后那些还存在标记的变量,就可以被回收了。
目前,IE、Firefox、Opera、Chrome、Safari的JavaScript实现所采用的垃圾回收机制,都是标记清除。

2、引用计数

引用计数,就是当一个变量声明了并将一个引用类型赋给该变量时,引用次数便为1。当同一个值又赋给其他变量,那么引用次数就+1,如果包含这个值引用的变量指向了其他的值,那么引用次数-1。如此一来,当引用次数变为0的时候,就可以被垃圾回收机制给回收。但是引用计数,会存在循环引用问题,从而导致内存泄漏。如:

function foo() {
    var a = {};
    var b = {};
    a.x = b;
    b.y = a;
}

在IE中,BOM和DOM并不是原生的JavaScript对象,而是使用COM对象的形式实现的,而COM对象采用的垃圾收集机制,便是引用计数。所以,在IE中设计COM对象的时候,就存在循环引用问题。解决办法:使用完成后,手动断开引用:

function foo() {
    var a = {};
    var b = {};
    a.x = b;
    b.y = a;
    // ...
    a.x = null;
    b.y = null;
}

四、管理内存

优化内存占用,有一个很好的方式,即为解除引用。所谓解除引用,是为:会执行中的代码只保留必要的数据,一旦数据不再可用,最好将值设为null来释放引用。
但是,解除引用并不是意味着马上执行GC,而是让值脱离执行环境。下一次GC触发时,得以回收