本文深入解析 ECMA-262-3 规范中的变量对象 (VO) 机制。详细阐述全局上下文与函数上下文中 VO 的创建过程,区分变量声明、函数声明与函数表达式的处理差异,并解析进入上下文与执行代码两个阶段对变量对象的具体影响。
本文通过具体实例深入解析 JavaScript 中 ECMA-262-3 规范的变量对象机制,旨在帮助开发者理解其工作原理并提供参考。
介绍
在开发应用程序时,声明变量和函数是常规操作。我们需要探究的是:当程序运行需要调用这些数据(函数或变量)时,解释器是如何定位并获取这些数据的?这一查找过程内部究竟发生了什么?
大多数 ECMAScript 开发者都清楚,变量的行为与执行上下文紧密相关。
var a = 10; // variable of the global context
(function () {
var b = 20; // local variable of the function context
})();
alert(a); // 10
alert(b); // "b" is not defined
此外,根据当前版本的规范,只有“函数(function)”类型的代码才能创建独立的作用域。这意味着,与 C/C++ 不同,ECMAScript 中的for 循环并不会创建局部作用域(译者注:即局部上下文)。
for (var k in {a: 1, b: 2}) {
alert(k);
}
alert(k); // variable "k" still in scope even the loop is finished
接下来,我们将深入探讨声明数据时的内部实现细节。
数据声明
既然变量与执行上下文相关联,那么它必须知晓自身数据的存储位置及访问方式。这种机制被称为变量对象(variable object)。
变量对象(简称 VO)是与执行上下文关联的对象(此处“对象”指代某种数据结构),它存储以下内容:
- 变量声明 (var, VariableDeclaration);
- 函数声明 (FunctionDeclaration, 缩写为 FD);
- 以及函数的形参
以上元素均在上下文中进行声明。
为了便于理解,一个简单的例子显示,变量对象完全可能以标准的 ECMAScript 对象形式呈现:
VO = {};
正如前文所述,VO 是执行上下文的属性(property):
activeExecutionContext = {
VO: {
// context data (var, FD, function arguments)
}
};
只有在全局上下文中,变量对象才允许通过属性名称间接访问(因为在全局上下文中,全局对象自身即为变量对象,后文将详述)。在其他上下文中,无法直接访问 VO,因为变量对象完全是实现机制内部的细节。
当我们声明一个变量或函数时,系统会在 VO 中创建一个新属性,其名称为变量名,值为初始值。
例如:
var a = 10;
function test(x) {
var b = 20;
};
test(30);
对应的变量对象结构如下:
// Variable object of the global context
VO(globalContext) = {
a: 10,
test:
};
// Variable object of the "test" function context
VO(test functionContext) = {
x: 30,
b: 20
};
在具体实现层面(以及规范定义中),变量对象只是一个抽象概念(译者注:此句翻译略显生硬,欢迎提供优化译文)。本质上,在不同的具体执行上下文中,VO 的名称和初始结构各不相同。
不同执行上下文中的变量对象
对于所有类型的执行上下文,变量对象的一些操作(如变量初始化)和行为是共通的。从这个角度看,将变量对象视为抽象的基本概念更容易理解。而在函数上下文中,变量对象还可以定义一些相关的额外细节。

下面,我们将详细展开探讨;
全局上下文中的变量对象
这里有必要先对全局对象(Global object)进行明确定义:
全局对象(Global object) 是在进入任何执行上下文之前就已经创建的对象;该对象仅存在一份,其属性在程序中任何地方都可以访问,全局对象的生命周期终止于程序退出那一刻。
在初始创建阶段,全局对象通过 Math、String、Date、parseInt 等属性进行初始化,同时还可以附加其他对象作为属性,其中包括引用全局对象自身的对象。例如,在 DOM 环境中,全局对象的 window 属性即引用全局对象自身(当然,并非所有具体实现都如此):
global = {
Math: <...>,
String: <...>
...
...
window: global
};
由于全局对象无法通过名称直接访问,因此在访问全局对象属性时,通常忽略前缀。尽管如此,通过全局上下文的 this 仍然可以直接访问到全局对象,同样也可以通过引用自身的属性来访问,例如 DOM 中的 window。综上所述,代码可以简写为:
String(10); // means global.String(10);
// with prefixes
window.a = 10; // === global.window.a = 10 === global.a = 10;
this.b = 20; // global.b = 20;
因此,全局上下文中的变量对象就是全局对象自身(global object itself):
VO(globalContext) === global;
准确理解“全局上下文中的变量对象就是全局对象自身”是非常必要的,基于这一事实,在全局上下文中声明一个变量时,我们才能够通过全局对象的属性间接访问到这个变量(例如,当事先未知变量名时):
var a = new String('test');
alert(a); // directly, is found in VO(globalContext): "test"
alert(window['a']); // indirectly via global === VO(globalContext): "test"
alert(a === this.a); // true
var aKey = 'a';
alert(window[aKey]); // indirectly, with dynamic property name: "test"
函数上下文中的变量对象
在函数执行上下文中,VO 是不能直接访问的,此时由激活对象(activation object, 缩写为 AO)扮演 VO 的角色。
VO(functionContext) === AO;
激活对象 是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性的值是 Arguments object:
AO = {
arguments: <ArgO>
};
Arguments objects 是函数上下文里的激活对象中的内部对象,它包括下列属性:
- callee — 指向当前函数的引用;
- length — 真正传递的参数的个数;
- properties-indexes (字符串类型的整数) 属性的值就是函数的参数值(按参数列表从左到右排列)。 properties-indexes 内部元素的个数等于 arguments.length. properties-indexes 的值和实际传递进来的参数之间是共享的。(译者注:共享与不共享的区别可以对比理解为引用传递与值传递的区别)
例如:
function foo(x, y, z) {
alert(arguments.length); // 2 – quantity of passed arguments
alert(arguments.callee === foo); // true
alert(x === arguments[0]); // true
alert(x); // 10
arguments[0] = 20;
alert(x); // 20
x = 30;
alert(arguments[0]); // 30
// however, for not passed argument z,
// related index-property of the arguments
// object is not shared
z = 40;
alert(arguments[2]); // undefined
arguments[2] = 50;
alert(z); // 40
}
foo(10, 20);
最后一个例子的场景,在当前版本的 Google Chrome 浏览器里有一个 bug — 即使没有传递参数 z,z 和 arguments[2] 仍然是共享的。(译者注:我试验了一下,在 Chrome Ver4.1.249.1059 版本,该 bug 仍然存在)
分阶段处理上下文代码
现在我们终于触及到本文的核心内容。执行上下文的代码被分成两个基本的阶段来处理:
- 进入执行上下文;
- 执行代码;
变量对象的变化与这两个阶段紧密相关。
进入执行上下文
当进入执行上下文(代码执行之前)时,VO 已被下列属性填充满(这些都已经在前文描述过):
- 函数的所有形式参数(如果我们是在函数执行上下文中)
— 变量对象的一个属性,这个属性由一个形式参数的名称和值组成;如果没有对应传递实际参数,那么这个属性就由形式参数的名称和 undefined 值组成;
- 所有函数声明(FunctionDeclaration, FD)
—变量对象的一个属性,这个属性由一个函数对象(function-object)的名称和值组成;如果变量对象已经存在相同名称的属性,则完全替换这个属性。
- 所有变量声明(var, VariableDeclaration)
—变量对象的一个属性,这个属性由变量名称和 undefined 值组成;如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
让我们看一个例子:
function test(a, b) {
var c = 10;
function d() {}
var e = function _e() {};
(function x() {});
}
test(10); // call
当进入“test”函数的上下文时(传递参数 10),AO 如下:
AO(test) = {
a: 10,
b: undefined,
c: undefined,
d: <reference to FunctionDeclaration "d">
e: undefined
};
注意,AO 里并不包含函数“x”。这是因为“x” 是一个函数表达式(FunctionExpression, 缩写为 FE) 而不是函数声明,函数表达式不会影响 VO(译者注:这里的 VO 指的就是 AO)。 不管怎样,函数“_e” 同样也是函数表达式,但是就像我们下面将看到的那样,因为它分配给了变量 “e”,所以它变成可以通过名称“e”来访问。 FunctionDeclaration 与 FunctionExpression 的不同,将在 Chapter 5. Functions 进行详细的探讨。
这之后,将进入处理上下文代码的第二个阶段 — 执行代码。
执行代码
这一刻,AO/VO 已经被属性(不过,并不是所有的属性都有值,大部分属性的值还是系统默认的初始值 undefined )填满。
还是前面那个例子, AO/VO 在代码解释期间被修改如下:
AO['c'] = 10;
AO['e'] = <reference to FunctionExpression "_e">;
再次注意,因为 FunctionExpression“_e” 保存到了已声明的变量“e”上,所以它仍然存在于内存中(译者注:就是还在 AO/VO 中的意思)。而 FunctionExpression。未保存的函数表达式只有在它自己的定义或递归中才能被调用。 “x” 并不存在于 AO/VO 中。即,如果我们想尝试调用“x”函数,不管在函数定义之前还是之后,都会出现一个错误“x is not defined”
另一个经典例子:
alert(x); // function
var x = 10;
alert(x); // 10
x = 20;
function x() {};
alert(x); // 20
为什么第一个 alert “x” 的返回值是 function,而且它还是在“x” 声明之前访问的“x” 的?为什么不是 10 或 20 呢?因为,根据规范 — 当进入上下文时,往 VO 里填入函数声明;在相同的阶段,还有一个变量声明“x”,那么正如我们在上一个阶段所说,变量声明在顺序上跟在函数声明和形式参数声明之后,而且,在这个阶段(译者注:这个阶段是指进入执行上下文阶段),变量声明不会干扰 VO 中已经存在的同名函数声明或形式参数声明,因此,在进入上下文时,VO 的结构如下:
VO = {};
VO['x'] = <reference to FunctionDeclaration "x">
// found var x = 10;
// if function "x" would not be already defined
// then "x" be undefined, but in our case
// variable declaration does not disturb
// the value of the function with the same name
VO['x'] = <the value is not disturbed, still function>
随后在执行代码阶段,VO 做如下修改:
VO['x'] = 10;
VO['x'] = 20;
我们可以在第二、三个 alert 看到这个效果。
在下面的例子里我们可以再次看到,变量是在进入上下文阶段放入 VO 中的。(因为,虽然 else 部分代码永远不会执行,但是不管怎样,变量“b”仍然存在于 VO 中。)(译者注:变量 b 虽然存在于 VO 中,但是变量 b 的值永远是 undefined)
if (true) {
var a = 1;
} else {
var b = 2;
}
alert(a); // 1
alert(b); // undefined, but not "b is not defined"
关于变量
通常,各类文章和 JavaScript 相关的书籍都声称:“不管是使用 var 关键字(在全局上下文)还是不使用 var 关键字(在任何地方),都可以声明一个变量”。请记住,这绝对是谣传:
任何时候,变量只能通过使用 var 关键字才能声明。
那么像下面这样分配:
a = 10;
这仅是给全局对象创建了一个新属性(但是它不是变量)。“不是变量”的意思并不是说它不能被改变,而是指它不符合 ECMAScript 规范中的变量概念,所以它“不是变量”(它之所以能成为全局对象的属性,完全是因为 VO(globalContext) === global,大家还记得这个吧?)。
让我们通过下面的实例看看具体的区别吧:
alert(a); // undefined
alert(b); // "b" is not defined
b = 10;
var a = 20;
所有根源仍然是 VO 和它的修改阶段(进入上下文 阶段和执行代码 阶段):
进入上下文阶段:
VO = {
a: undefined
};
我们可以看到,因为“b”不是一个变量,所以在这个阶段根本就没有“b”,“b”将只在执行代码阶段才会出现(但是在我们这个例子里,还没有到那就已经出错了)。
让我们改变一下例子代码:
alert(a); // undefined, we know why
b = 10;
alert(b); // 10, created at code execution
var a = 20;
alert(a); // 20, modified at code execution
关于变量,还有一个重要的知识点。变量相对于简单属性来说,变量有一个特性(attribute):{DontDelete},这个特性的含义就是不同通过 delete 操作符直接删除变量属性。
a = 10;
alert(window.a); // 10
alert(delete a); // true
alert(window.a); // undefined
var b = 20;
alert(window.b); // 20
alert(delete b); // false
alert(window.b); // still 20
但是,在 eval 上下文,这个规则并不起作用,因为在这个上下文里,变量没有{DontDelete}特性。
eval('var a = 10;');
alert(window.a); // 10
alert(delete a); // true
alert(window.a); // undefined
使用一些调试工具(例如:Firebug)的控制台测试该实例时,请注意,Firebug 同样是使用 eval 来执行控制台里你的代码。因此,变量属性同样没有{DontDelete}特性,可以被删除。
特殊实现: __parent__ 属性
前面已经提到过,按标准规范,激活对象是不可能被直接访问到的。但是,一些具体实现并没有完全遵守这个规定,例如 SpiderMonkey 和 Rhino;在这些具体实现中,函数有一个特殊的属性 __parent__,通过这个属性可以直接引用到函数已经创建的激活对象或全局变量对象。
例如 (SpiderMonkey, Rhino):
var global = this;
var a = 10;
function foo() {}
alert(foo.__parent__); // global
var VO = foo.__parent__;
alert(VO.a); // 10
alert(VO === global); // true
在上面的例子中我们可以看到,函数 foo 是在全局上下文中创建的,所以属性__parent__ 指向全局上下文的变量对象,即全局对象。(译者注:还记得这个吧:VO(globalContext) === global)
然而,在 SpiderMonkey 中用同样的方式访问激活对象是不可能的:在不同版本的 SpiderMonkey 中,内部函数的__parent__ 有时指向 null ,有时指向全局对象。
在 Rhino 中,用同样的方式访问激活对象是完全可以的。
例如 (Rhino):
var global = this;
var x = 10;
(function foo() {
var y = 20;
// the activation object of the "foo" context
var AO = (function () {}).__parent__;
print(AO.y); // 20
// __parent__ of the current activation
// object is already the global object,
// i.e. the special chain of variable objects is formed,
// so-called, a scope chain
print(AO.__parent__ === global); // true
print(AO.__parent__.x); // 10
})();
结论
在这篇文章里,我们进一步深入学习了跟执行上下文相关的对象。我希望这些知识对您来说能有所帮助,能解决一些您曾经遇到的问题或困惑。按照计划,在后续的章节中,我们将探讨 Scope chain, Identifier resolution ,Closures。
如果您有问题,我很高兴在下面评论中解答。
英文地址 : ECMA-262-3 in detail.Chapter 2.Variable object
感兴趣的朋友可以使用在线 HTML/CSS/JavaScript 代码运行工具:http://tools.jb51.net/code/HtmlJsRun 测试上述代码运行效果。
更多关于 JavaScript 相关内容可查看本站专题:《javascript 面向对象入门教程》、《JavaScript 错误与调试技巧总结》、《JavaScript 数据结构与算法技巧总结》、《JavaScript 遍历算法与技巧总结》及《JavaScript 数学运算用法总结》
希望本文所述对大家 JavaScript 程序设计有所帮助。