0%

JavaScript学习笔记(4)

《JavaScript高级程序设计》第四章,变量、作用域和内存问题。


基本类型和引用类型的值

ECMAScript变量可能包含两种不同数据类型的值:基本类型值(简单的数据段)、引用类型值(那些可能由多个值构成的对象)。基本数据类型按值访问,因为可以操作保存在变量中的实际的值;引用类型值按引用访问,因为Javascript不允许直接访问内存中的位置,即不能直接操作对象的内存空间,因此在操作对象时,实际上是在操作对象的引用而非实际的对象。基本类型值由于固定大小而被保存于栈内存中;引用类型保存于堆内存中。

动态的属性

引用类型的值可以动态地添加、改变、删除属性和方法,基本类型的值则不行。

1
2
3
4
5
6
7
8
9
// 可
var person = new Object();
person.name = "sponge";
alert(person.name); // "sponge"

// 不可
var name = "sponge";
name.age = 27;
alert(name.age); // undefined

复制变量值

一个变量向另一个变量复制基本类型的值时,复制的内容是值(二者互不干扰);复制引用类型的值时,复制的是指针(二者指向同一对象)。

传递参数

ECMAScript中所有参数都是按值传递的,因此都和上述变量值的复制一样。在传递对象时同样是传递值(内容为地址),也就是指向同一对象。但有一些表现会开发人员容易错误地认为:在局部作用域中修改地对象会在全局作用域反映出来,就说明参数是按引用传递的。但这是不对的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 局部修改,全局反映
function setName(obj) {
obj.name = "sponge";
}
var person = new Object();
setName(person);
alert(person.name); // "sponge"

// 证明为传递值
function setName(obj) {
obj.name = "sponge";
obj = new Object();
obj.name = "grey";
}
var person = new Object();
setName(person);
alert(person.name); // "sponge"

如果person是按引用传递的,那么person就会自动被修改为指向name属性值为”grey”的新对象,person.name就会是”grey”。

检测类型

对于基本数据类型的检测,可以用之前讲过的typeof操作符;对于引用类型的检测,typeof则用处不大,ECMAScript也因此提供了instanceof操作符。

1
2
3
4
5
6
7
// 用法
resule = variable instanceof constructor; // 返回true or false

// 举例
alert(person instanceof Object);
alert(person instanceof Array);
alert(person instanceof RegExp);

一般有以下规定:

  • 基本类型 instanceof Object => false
  • 引用类型 instanceof Object => true

* 补充typeof
以前typeof用于函数和正则表达式时会返回”function”,后规定任何在内部实现[[call]]方法的对象都应在使用typeof操作符时返回”function”。

执行环境及作用域

执行环境

执行环境(execution context)定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都与一个变量对象(variable object)关联,环境里所有的变量和函数都存在这个对象中。我们无法在代码里访问这个对象,但解析器处理数据的时候会在后台用它。

全局

最外围的执行环境是全局执行环境。不同宿主环境下的执行环境的对象不太一样,在Web浏览器中全局执行环境是window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。

局部/函数

每个函数都有自己的执行环境,其活动对象(activation object)被作为变量对象。活动对象在最开始时只包含一个变量——arguments对象(全局环境无arguments对象)。执行流进出函数会导致函数的环境被压入和弹出环境栈。

作用域链

代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain),也就是一个变量对象串。它是从内往外一层层的,最内层的也即当前环境下的变量对象为作用域的前端,最外层的也即全局环境下的变量对象为作用域链的最后一个对象。标识符的解析就是沿着作用域链来的。内部环境可通过作用域链访问所有外部环境,反之不行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var a = "blue";

function func1() {
var b = "red";

function func2() {
var c = b;
b = a;
a = c;
// 这里可访问a、b、c
}

// 这里可访问a、b,不能访问c
func2();
}

// 这里可访问a,不能访问b、c
func1();

上例有3个执行环境,每个环境有各自的变量和函数,能访问不同的内容:

  • 全局环境
    • 有变量a、函数func1()
  • func1()的局部环境
    • 有变量b、函数func2(),还可访问上层的变量a
  • func2()的局部环境
    • 有变量c,还可访问其他两个环境的所有变量

延长作用域链

两种情况下可以在作用域链的前端增加一个变量对象:

  • try-catch语句的catch块
  • with语句

catch语句创建新的变量对象,其中包含被抛出的错误对象的声明;with语句将指定的对象添加到作用域链中。新添加的变量对象会在代码执行后被移除。

1
2
3
4
5
6
7
8
// 作用域链延长
function buildUrl() {
var qs = "?debug=true";
with(location) {
var url = href + qs;
}
return url; // 是可以成功返回的
}

with语句接收的是location对象,location对象的所有属性和方法都被添加到了作用域链的前端。with语句中引用的变量href(即location.href)可在当前执行环境的变量对象中找到,with语句中引用的变量qs可在buildUrl()函数的执行环境的变量对象中找到,二者得到的url变量也成了函数执行环境的一部分,所以可以作为函数的值被返回。
我的疑问点在于:为什么with的外面可以访问到with内的url?
目前搜索得到的解释:

1
2
3
4
5
6
7
8
9
// 新添加的变量对象在执行后被移除
var x = 10, y = 10;
with ({x: 20}) {
var x = 30, y = 30;
alert(x); // 30
alert(y); // 30
}
alert(x); // 10
alert(y); // 30

过程:

  • x=10,y=10
  • 对象{x: 20}被添加到作用域链的前端
  • with语句内的var行并没有创造任何东西,因为所有的变量在进入执行环境时已经被解析和添加
  • “x”的值被修改了,正在处理的这个”x”正是第二步添加到作用域链前端的对象,这个”x”的值由20变为30
  • 上述变量对象中的”y”的值也被修改了,”y”的值由10变为30
  • 当with语句结束时,之前添加到作用域链前端的对象被移除(更改后的值”x”——30也被移除),作用域链又回到了with语句之前的状态
  • 至此,目前变量对象中”x”的值保持不变,”y”的值在with语句内的修改下变为30

参考:ECMA-262-3 in detail. Chapter 4. Scope chain.

没有块级作用域

JavaScript没有块级作用域,容易导致理解上的困惑。

1
2
3
4
5
6
7
8
9
if(true) {
var color = "blue";
}
alert(color); // "blue"

for(var i = 0; i < 10; i++) {
doSomething();
}
alert(i); // 10

声明变量

使用var声明的变量会自动被添加到最近的环境中。在函数内部,最接近的环境就是函数的局部环境;在with语句中,最接近的环境是函数环境。如果初始化变量时没有用var声明,该变量会自动被添加到全局环境。

1
2
3
4
5
6
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
var result = add(10, 20); // 30
alert(sum); // 会导致错误

注意这个和上面的区别,不是所有花括号都是一个性质的。

1
2
3
4
5
6
function add(num1, num2) {
sum = num1 + num2;
return sum;
}
var result = add(10, 20); // 30
alert(sum); // 30

查询标识符

标识符的查询是从作用域前端开始逐级向上直到全局环境的,当然如果在局部环境中找到了该标识符,搜索便可以停止,变量就绪;如果到全局环境也未找到,说明该变量尚未声明。

垃圾收集

JavaScript具有自动垃圾收集机制,其原理也很简单:找出那些不再继续使用的变量,然后释放其占用的内存。为此,垃圾收集器会按照固定的时间间隔周期性地执行这一操作。
对于函数中的局部变量,它只在函数执行的过程中存在,执行结束就可以释放内存以供将来使用。而有些情况不太容易判断变量是否还需要,垃圾收集器就必须进行跟踪,将无用的变量打上标记来进行标识。针对标识的策略通常有下面两种。

标记清除

JavaScript中最常用的垃圾收集方式是标记清除(mark-and-sweep)。变量进入环境时被标记为”进入环境”,变量离开环境时被标记为”离开环境”。
可以使用任何方式标记变量。如使用一个特殊的位进行标记,或使用”进入环境的”列表和”离开环境的”列表来进行变量跟踪等。
垃圾收集器运行过程如下:

  • 给存储在内存的所有变量打上标记
  • 给环境中的变量以及被环境中的变量引用的变量去掉标记
  • 被加上标记的变量被视为准备删除的变量,因为环境中已经无法访问它们了
  • 销毁带标记的值并回收其内存空间,完成内存清除工作

引用计数

引用计数(reference counting)跟踪记录每个值被引用的次数。规则如下:

  • 声明一个变量并赋予一个引用类型值,该值引用次数为1
  • 同一值又被赋给另一变量,该值引用次数加1
  • 包含该值引用的变量取了另外一个值,该值引用次数减1
  • 如果引用次数为0,意味着没有办法再访问到该值,它会在垃圾收集器运行时被释放

Netscape Navigator 3.0浏览器在最早使用引用计数时发现,引用计数策略存在一个严重的问题——循环引用:对象A中包含一个指向对象B的指针,而对象B中也包含一个指向对象A的引用。

1
2
3
4
5
6
function problem() {
var objA = new Object();
var objB = new Object();
objA.someOtherObject = objB;
objB.anotherObject = objA;
}

上述例子如果使用标记清除,会在函数执行结束后被清除,但如果使用引用计数,由于两个对象的引用计数都为2并且永远不会为0,因此即使以后不会再使用,这两个对象也无法被清除。而且如果这个函数被调用多次,就会导致大量的内存得不到回收。而后Netscape Navigator 4.0就放弃了引用计数方式,但这并不能完全回避开问题。

IE中有部分对象并非JavaScript原生对象,如使用C++以COM(Component Object Model)对象的形式实现的BOM和DOM,而COM对象的垃圾回收机制采用了引用计数策略。因此即使IE的JavaScript引擎使用标记清除,JavaScript访问到COM对象也依然是基于引用计数的,也依然存在循环引用问题。

1
2
3
4
var element = document.getElementById("id");
var object = new Object();
object.someElement = element;
element.someObject = object;

上述例子在DOM元素(element)和JavaScript原生对象(object)中创建了循环引用。这种情况下即使将例子中的DOM从页面中移除,它也永远不会被回收。
解决策略:

  • 在不使用它们时手动断开二者连接
    1
    2
    object.someElement = null;
    element.someObject = null;
  • IE9将BOM和DOM对象转换为了真正的JavaScript对象

性能问题

垃圾收集器周期性运行,回收工作量有时候也很大,因此确定垃圾收集的时间间隔很关键。举一个不太聪明的例子,IE曾经按照内存分配量来运行垃圾收集器,具体来说为256*变量、4096*对象(或数组)字面量和数组元素、64KB字符串,达到上述任一临界值,垃圾收集就会运行。之所以说不太聪明,是因为如果一个脚本真的有那么多变量,它很可能整个生命周期都一直要有那么多变量,这样垃圾收集器就会频繁运行,导致性能问题。针对这个问题,IE7将其优化为临界值动态调整:临界值初始与IE6一样,当回收率低于15%就将临界值加倍;当回收率到达85%,将临界值设为默认值。

管理内存

why?

通常来说,当使用具有垃圾收集机制的语言,开发者无需担心内存管理问题。但JavaScript为何需要呢?因为分配给Web浏览器的可用内存通常比分配给桌面应用程序的少,这样做是为了安全方面的考量,目的是防止运行JavaScript的网页耗尽全部系统内存而导致系统崩溃。

how?

因此需要确保占用最少的内存可以让页面获得更好的性能。而优化内存占用的最佳方式,是只保存必要的数据,一旦数据不再有用,最好将其设置为null来释放其引用——又称解除引用(dereferencing)。该做法适用于大多数全局变量和全局对象的属性。局部变量在离开执行环境时会自动被解除引用。