JavaScript内部原理实践——真的懂JavaScript吗?(转)
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaScript内部原理实践——真的懂JavaScript吗?(转)相关的知识,希望对你有一定的参考价值。
通过翻译了Dmitry A.Soshnikov的关于ECMAScript-262-3 JavaScript内部原理的文章, 从理论角度对javascript中部分特性的内部工作机制有了一定的了解。
但是,邓爷爷说过:“实践才是检验真理的唯一标准”。
所以,我打算通过从内部原理来解释一些经常在笔试或者面试中遇到的关于JavaScript语言层面的题目来进一步学习和掌握JavaScript内部工作原理。
那么,首先就是要去找那些题目,google了一圈终于找到了来自Dmitry Baranovskiy的非常著名的5个问题, 这5个问题,NCZ给出了非常清楚的解释。 不过,我还是想尝试下从low-level——JavaScript内部工作机制的角度去解释下这些问题。
好吧,我承认我废话很多,那就开始吧。
问题 #1
if (!("a" in window)) {
var a = 1;
}
alert(a);
- 正确答案: undefined
解释:
这个问题,初一看感觉答案很自然是1。因为从上到下执行下去,if语句中的条件应该为 true,因为”a”的确是没有定义啊。 随后,顺理成章地进入 var a = 1;,最后,alert出来就应该是1。
而事实上,从JavaScript内部工作原理去看,在变量对象中讲过, JavaScript处理上下文分为两个阶段:
- 进入执行上下文
- 执行代码
可以理解为,第一个阶段是静态处理阶段,第二个阶段为动态处理阶段。
而在静态处理阶段,就会创建 变量对象(variable object),并且将变量申明作为属性进行填充。
到了执行阶段,才会根据执行情况,来对变量对象中属性(就是申明的变量)的值进行更新。
针对这个问题,其实际过程如下:
- 进入执行上下文: 创建VO,并填充变量申明 a,VO如下所示:
VO(global) = {
a: undefined
}
所以,这个时候,a其实已经存在了。
- 执行代码: 进入 if语句,发现条件判断 "a" in window 为true。于是就不会进入if代码块,直接执行alert语句,因此,最终为undefined。
问题 #2
var a = 1,
b = function a(x) {
x && a(--x);
};
alert(a);
- 正确答案: 1
解释:
这个问题,第一反应可能会是将 function a打印出来。因为明明就看到了function a了。看似,也顺其自然。
但是,事实并非如此。还是和此前一个问题一样。从两个阶段来分析:
- 进入执行上下文: 这个时候要注意了, b = function a(){},这里的 function a并非函数申明,因为整个这个句话属于赋值语句(assignment statement),所以,这里的 function a会被看作是函数表达式。 函数表达式是不会对VO造成影响的。所以,这个时候VO中其实只有 a和x(函数形参):
VO(global) = {
a: undefined,
b: undefined
}
- 执行代码: 这个时候a的值修改为1:
VO(global) = {
x: undefined,
a: 1
}
所以,最后alert(a)的结果是1。
问题 #3
function a(x) {
return x * 2;
}
var a;
alert(a);
- 正确答案: 函数a
解释:
这个问题,很多人可能会以为是: undefined。理由可能是,明明看到了 var a定义在了function a的后面,感觉应该会覆盖之前a的申明。
事实又是怎样的呢? 老套路,从两个阶段来分析:
- 进入执行上下文: 这里出现了名字一样的情况,一个是函数申明,一个是变量申明。那么,根据变量对象 介绍的,填充VO的顺序是: 函数的形参 -> 函数申明 -> 变量申明。
上述例子中,变量a在函数a后面,那么,变量a遇到函数a怎么办呢?还是根据 变量对象中介绍的,当变量申明遇到VO中已经有同名的时候,不会影响已经存在的属性。因此,VO如下所示:
VO(global) = {
a: 引用了函数申明“x”
}
- 执行代码:啥也没变化
所以,最终的结果是:函数a。
问题 #4
function b(x, y, a) {
arguments[2] = 10;
alert(a);
}
b(1, 2, 3);
- 正确答案: 10
解释:
个人感觉这个问题其实不是很复杂。这里也不需要从两个阶段去分析了。根据 变量对象中介绍的,arguments对象的properties-indexes和实际传递的参数是共享的 也就是说,通过arguments[2]修改的参数,也会影响到a,所以,这里的值是10。但是,要注意的是和实际传递的值,所以,如果把上述问题改成如下形式:
function b(x, y, a) {
arguments[2] = 10;
alert(a);
}
b(1, 2);
结果就会是: undefined。因为,并没有传递a的值。
问题 #5
function a() {
alert(this);
}
a.call(null);
- 正确答案: 全局对象(window)
解释:
这个问题,可能会比较困惑。因为懂call的童鞋都会觉得,call的时候把null传递为了当前的上下文了。里面的this应该是null才对啊。
事实却是: 前面都没错,this会是null。但是,this中介绍过,null是没有任何意义的,因此,最终会变成全局对象。 所以,这里结果就变成了全局对象。 这里还有ECMAScript-262-3标准文档中的一句话作为证据:
“If thisArg is null or undefined, the called function is passed the global object as the this value. Otherwise, the called function is passed ToObject(thisArg) as the this value.”
总结
上面这5个问题其实也只是牵涉到了JavaScript内部原理中的部分知识点,要想了解更多,还是建议读完JavaScript内部原理系列 以及去看Dmitry A.Soshnikov的文章。
闭包(Closures)
说明
此文译自Dmitry A.Soshnikov 的文章closures
另,此文还有另外一位同事(彭森材)共同参译
概要
本文将介绍一个在JavaScript经常会拿来讨论的话题 —— 闭包(closure)。闭包其实已经是个老生常谈的话题了; 有大量文章都介绍过闭包的内容(其中不失一些很好的文章,比如,扩展阅读中Richard Cornford的文章就非常好), 尽管如此,这里还是要试着从理论角度来讨论下闭包,看看ECMAScript中的闭包内部究竟是如何工作的。
正如在此前文章中提到的,这些文章都是系列文章,相互之间都是有关联的。因此,为了更好的理解本文要介绍的内容, 建议先去阅读下第四章 - 作用域链和 第二章 - 变量对象。
概论
在讨论ECMAScript闭包之前,先来介绍下函数式编程(与ECMA-262-3 标准无关)中一些基本定义。 然而,为了更好的解释这些定义,这里还是拿ECMAScript来举例。
众所周知,在函数式语言中(ECMAScript也支持这种风格),函数即是数据。就比方说,函数可以保存在变量中,可以当参数传递给其他函数,还可以当返回值返回等等。 这类函数有特殊的名字和结构。
定义
函数式参数(“Funarg”) —— 是指值为函数的参数。
如下例子:
function exampleFunc(funArg) {
funArg();
}
exampleFunc(function () {
alert(‘funArg‘);
});
上述例子中funarg的实参是一个传递给exampleFunc的匿名函数。
反过来,接受函数式参数的函数称为 高阶函数(high-order function 简称:HOF)。还可以称作:函数式函数 或者 偏数理的叫法:操作符函数。 上述例子中,exampleFunc 就是这样的函数。
此前提到的,函数不仅可以作为参数,还可以作为返回值。这类以函数为返回值的函数称为 _带函数值的函数(functions with functional value or function valued functions)。
(function functionValued() {
return function () {
alert(‘returned function is called‘);
};
})()();
可以以正常数据形式存在的函数(比方说:当参数传递,接受函数式参数或者以函数值返回)都称作 第一类函数(一般说第一类对象)。 在ECMAScript中,所有的函数都是第一类对象。
接受自己作为参数的函数,称为 自应用函数(auto-applicative function 或者 self-applicative function):
(function selfApplicative(funArg) {
if (funArg && funArg === selfApplicative) {
alert(‘self-applicative‘);
return;
}
selfApplicative(selfApplicative);
})();
以自己为返回值的函数称为 自复制函数(auto-replicative function 或者 self-replicative function)。 通常,“自复制”这个词用在文学作品中:
(function selfReplicative() {
return selfReplicative;
})();
在函数式参数中定义的变量,在“funarg”激活时就能够访问了(因为存储上下文数据的变量对象每次在进入上下文的时候就创建出来了):
function testFn(funArg) {
// 激活funarg, 本地变量localVar可访问
funArg(10); // 20
funArg(20); // 30
}
testFn(function (arg) {
var localVar = 10;
alert(arg + localVar);
});
然而,我们知道(特别在第四章中提到的),在ECMAScript中,函数是可以封装在父函数中的,并可以使用父函数上下文的变量。 这个特性会引发 funarg问题。
Funarg问题
在面向堆栈的编程语言中,函数的本地变量都是保存在 堆栈上的, 每当函数激活的时候,这些变量和函数参数都会压栈到该堆栈上。
当函数返回的时候,这些参数又会从堆栈中移除。这种模型对将函数作为函数式值使用的时候有很大的限制(比方说,作为返回值从父函数中返回)。 绝大部分情况下,问题会出现在当函数有 自由变量的时候。
自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量
如下所示:
function testFn() {
var localVar = 10;
function innerFn(innerParam) {
alert(innerParam + localVar);
}
return innerFn;
}
var someFn = testFn();
someFn(20); // 30
上述例子中,对于innerFn函数来说,localVar就属于自由变量。
对于采用 面向堆栈模型来存储局部变量的系统而言,就意味着当testFn函数调用结束后,其局部变量都会从堆栈中移除。 这样一来,当从外部对innerFn进行函数调用的时候,就会发生错误(因为localVar变量已经不存在了)。
而且,上述例子在 面向堆栈实现模型中,要想将innerFn以返回值返回根本是不可能的。 因为它也是testFn函数的局部变量,也会随着testFn的返回而移除。
还有一个函数对象问题和当系统采用动态作用域,函数作为函数参数使用的时候有关。
看如下例子(伪代码):
var z = 10;
function foo() {
alert(z);
}
foo(); // 10 – 静态作用域和动态作用域情况下都是
(function () {
var z = 20;
foo(); // 10 – 静态作用域情况下, 20 – 动态作用域情况下
})();
// 将foo函数以参数传递情况也是一样的
(function (funArg) {
var z = 30;
funArg(); // 10 – 静态作用域情况下, 30 – 动态作用域情况下
})(foo);
我们看到,采用动态作用域,变量(标识符)处理是通过动态堆栈来管理的。 因此,自由变量是在当前活跃的动态链中查询的,而不是在函数创建的时候保存起来的静态作用域链中查询的。
这样就会产生冲突。比方说,即使Z仍然存在(与之前从堆栈中移除变量的例子相反),还是会有这样一个问题: 在不同的函数调用中,Z的值到底取哪个呢(从哪个上下文,哪个作用域中查询)?
上述描述的就是两类 funarg问题 —— 取决于是否将函数以返回值返回(第一类问题)以及是否将函数当函数参数使用(第二类问题)。
为了解决上述问题,就引入了 闭包的概念。
闭包
闭包是代码块和创建该代码块的上下文中数据的结合。
让我们来看下面这个例子(伪代码):
var x = 20;
function foo() {
alert(x); // 自由变量 "x" == 20
}
// foo的闭包
fooClosure = {
call: foo // 对函数的引用
lexicalEnvironment: {x: 20} // 查询自由变量的上下文
};
上述例子中,“fooClosure”部分是伪代码。对应的,在ECMAScript中,“foo”函数已经有了一个内部属性——创建该函数上下文的作用域链。
这里“lexical”是不言而喻的,通常是省略的。上述例子中是为了强调在闭包创建的同时,上下文的数据就会保存起来。 当下次调用该函数的时候,自由变量就可以在保存的(闭包)上下文中找到了,正如上述代码所示,变量“z”的值总是10。
定 义中我们使用的比较广义的词 —— “代码块”,然而,通常(在ECMAScript中)会使用我们经常用到的函数。 当然了,并不是所有对闭包的实现都会将闭包和函数绑在一起,比方说,在Ruby语言中,闭包就有可能是: 一个程序对象(procedure object), 一个lambda表达式或者是代码块。
对于要实现将局部变量在上下文销毁后仍然保存下来,基于堆栈的实现显然是不适用的(因为与基于堆栈的结构相矛盾)。 因此在这种情况下,上层作用域的闭包数据是通过 动态分配内存的方式来实现的(基于“堆”的实现),配合使用垃圾回收器(garbage collector简称GC)和 引用计数(reference counting)。 这种实现方式比基于堆栈的实现性能要低,然而,任何一种实现总是可以优化的: 可以分析函数是否使用了自由变量,函数式参数或者函数式值,然后根据情况来决定 —— 是将数据存放在堆栈中还是堆中。
ECMAScript闭包的实现
讨论完理论部分,接下来让我们来介绍下ECMAScript中闭包究竟是如何实现的。 这里还是有必要再次强调下:ECMAScript只使用静态(词法)作用域(而诸如Perl这样的语言,既可以使用静态作用域也可以使用动态作用域进行变量声明)。
var x = 10;
function foo() {
alert(x);
}
(function (funArg) {
var x = 20;
// funArg的变量 "x" 是静态保存的,在该函数创建的时候就保存了
funArg(); // 10, 而不是 20
})(foo);
从技术角度来说,创建该函数的上层上下文的数据是保存在函数的内部属性 [[Scope]]中的。 如果你还不了解什么是[[Scope]],建议你先阅读第四章, 该章节对[[Scope]]作了非常详细的介绍。如果你对[[Scope]]和作用域链的知识完全理解了的话,那对闭包也就完全理解了。
根据函数创建的算法,我们看到 在ECMAScript中,所有的函数都是闭包,因为它们都是在创建的时候就保存了上层上下文的作用域链(除开异常的情况) (不管这个函数后续是否会激活 —— [[Scope]]在函数创建的时候就有了):
var x = 10;
function foo() {
alert(x);
}
// foo is a closure
foo: <FunctionObject> = {
[[Call]]: <code block of foo>,
[[Scope]]: [
global: {
x: 10
}
],
... // other properties
};
正如此前提到过的,出于优化的目的,当函数不使用自由变量的时候,实现层可能就不会保存上层作用域链。 然而,ECMAScript-262-3标准中并未对此作任何说明;因此,严格来说 —— 所有函数都会在创建的时候将上层作用域链保存在[[Scope]]中。
有些实现中,允许对闭包作用域直接进行访问。比如Rhino,针对函数的[[Scope]]属性,对应有一个非标准的 __parent__属性,在第二章中作过介绍:
var global = this;
var x = 10;
var foo = (function () {
var y = 20;
return function () {
alert(y);
};
})();
foo(); // 20
alert(foo.__parent__.y); // 20
foo.__parent__.y = 30;
foo(); // 30
// 还可以操作作用域链
alert(foo.__parent__.__parent__ === global); // true
alert(foo.__parent__.__parent__.x); // 10
“万能”的[[Scope]]
这里还要注意的是:在ECMAScript中,同一个上下文中创建的闭包是共用一个[[Scope]]属性的。 也就是说,某个闭包对其中的变量做修改会影响到其他闭包对其变量的读取:
var firstClosure;
var secondClosure;
function foo() {
var x = 1;
firstClosure = function () { return ++x; };
secondClosure = function () { return --x; };
x = 2; // 对AO["x"]产生了影响, 其值在两个闭包的[[Scope]]中
alert(firstClosure()); // 3, 通过 firstClosure.[[Scope]]
}
foo();
alert(firstClosure()); // 4
alert(secondClosure()); // 3
正因为这个特性,很多人都会犯一个非常常见的错误: 当在循环中创建了函数,然后将循环的索引值和每个函数绑定的时候,通常得到的结果不是预期的(预期是希望每个函数都能够获取各自对应的索引值)。
var data = [];
for (var k = 0; k < 3; k++) {
data[k] = function () {
alert(k);
};
}
data[0](); // 3, 而不是 0
data[1](); // 3, 而不是 1
data[2](); // 3, 而不是 2
上述例子就证明了 —— 同一个上下文中创建的闭包是共用一个[[Scope]]属性的。因此上层上下文中的变量“k”是可以很容易就被改变的。
如下所示:
activeContext.Scope = [
... // higher variable objects
{data: [...], k: 3} // activation object
];
data[0].[[Scope]] === Scope;
data[1].[[Scope]] === Scope;
data[2].[[Scope]] === Scope;
这样一来,在函数激活的时候,最终使用到的k就已经变成了3了。
如下所示,创建一个额外的闭包就可以解决这个问题了:
var data = [];
for (var k = 0; k < 3; k++) {
data[k] = (function _helper(x) {
return function () {
alert(x);
};
})(k); // 将 "k" 值传递进去
}
// 现在就对了
data[0](); // 0
data[1](); // 1
data[2](); // 2
上述例子中,函数“_helper”创建出来之后,通过参数“k”激活。其返回值也是个 函数,该函数保存在对应的数组元素中。 这种技术产生了如下效果: 在函数激活时,每次“_helper”都会创建一个新的变量对象,其中含有参数“x”,“x”的值就是传递进来的“k”的值。 这样一来,返回的函数的[[Scope]]就成了如下所示:
data[0].[[Scope]] === [
... // 更上层的变量对象
上层上下文的AO: {data: [...], k: 3},
_helper上下文的AO: {x: 0}
];
data[1].[[Scope]] === [
... // 更上层的变量对象
上层上下文的AO: {data: [...], k: 3},
_helper上下文的AO: {x: 1}
];
data[2].[[Scope]] === [
... // 更上层的变量对象
上层上下文的AO: {data: [...], k: 3},
_helper上下文的AO: {x: 2}
];
我们看到,这个时候函数的[[Scope]]属性就有了真正想要的值了,为了达到这样的目的,我们不得不在[[Scope]]中创建额外的变量对象。 要注意的是,在返回的函数中,如果要获取“k”的值,那么该值还是会是3。
顺便提下,大量介绍JavaScript的文章都认为只有额外创建的函数才是闭包,这种说法是错误的。 实践得出,这种方式是最有效的,然而,从理论角度来说,在ECMAScript中所有的函数都是闭包。
然而,上述提到的方法并不是唯一的方法。通过其他方式也可以获得正确的“k”的值,如下所示:
var data = [];
for (var k = 0; k < 3; k++) {
(data[k] = function () {
alert(arguments.callee.x);
}).x = k; // 将“k”存储为函数的一个属性
}
// 同样也是可行的
data[0](); // 0
data[1](); // 1
data[2](); // 2
Funarg和return
另外一个特性是从闭包中返回。在 ECMAScript中,闭包中的返回语句会将控制流返回给调用上下文(调用者)。 而在其他语言中,比如,Ruby,有很多中形式的闭包,相应的处理闭包返回也都不同,下面几种方式都是可能的:可能直接返回给调用者,或者在某些情况下 ——直接从上下文退出。
ECMAScript标准的退出行为如下:
function getElement() {
[1, 2, 3].forEach(function (element) {
if (element % 2 == 0) {
// 返回给函数"forEach",
// 而不会从getElement函数返回
alert(‘found: ‘ + element); // found: 2
return element;
}
});
return null;
}
alert(getElement()); // null, 而不是 2
然而,在ECMAScript中通过try catch可以实现如下效果:
var $break = {};
function getElement() {
try {
[1, 2, 3].forEach(function (element) {
if (element % 2 == 0) {
// 直接从getElement"返回"
alert(‘found: ‘ + element); // found: 2
$break.data = element;
throw $break;
}
});
} catch (e) {
if (e == $break) {
return $break.data;
}
}
return null;
}
alert(getElement()); // 2
理论版本
通常,程序员会错误的认为,只 有匿名函数才是闭包。其实并非如此,正如我们所看到的 —— 正是因为作用域链,使得所有的函数都是闭包(与函数类型无关: 匿名函数,FE,NFE,FD都是闭包), 这里只有一类函数除外,那就是通过Function构造器创建的函数,因为其[[Scope]]只包含全局对象。 为了更好的澄清该问题,我们对ECMAScript中的闭包作两个定义(即两种闭包):
ECMAScript中,闭包指的是:
- 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
- 从实践角度:以下函数才算是闭包:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了自由变量
闭包实践
实际使用的时候,闭包可以创建出非常优雅的设计,允许对funarg上定义的多种计算方式进行定制。 如下就是数组排序的例子,它接受一个排序条件函数作为参数:
[1, 2, 3].sort(function (a, b) {
... // 排序条件
});
同样的例子还有,数组的map方法(并非所有的实现都支持数组map方法,SpiderMonkey从1.6版本开始有支持),该方法根据函数中定义的条件将原数组映射到一个新的数组中:
[1, 2, 3].map(function (element) {
return element * 2;
}); // [2, 4, 6]
使用函数式参数,可以很方便的实现一个搜索方法,并且可以支持无穷多的搜索条件:
someCollection.find(function (element) {
return element.someProperty == ‘searchCondition‘;
});
还有应用函数,比如常见的forEach方法,将funarg应用到每个数组元素:
[1, 2, 3].forEach(function (element) {
if (element % 2 != 0) {
alert(element);
}
}); // 1, 3
顺便提下,函数对象的 apply 和 call方法,在函数式编程中也可以用作应用函数。 apply和call已经在讨论“this”的时候介绍过了;这里,我们将它们看作是应用函数 —— 应用到参数中的函数(在apply中是参数列表,在call中是独立的参数):
(function () {
alert([].join.call(arguments, ‘;‘)); // 1;2;3
}).apply(this, [1, 2, 3]);
闭包还有另外一个非常重要的应用 —— 延迟调用:
var a = 10;
setTimeout(function () {
alert(a); // 10, 一秒钟后
}, 1000);
也可以用于回调函数:
...
var x = 10;
// only for example
xmlHttpRequestObject.onreadystatechange = function () {
// 当数据就绪的时候,才会调用;
// 这里,不论是在哪个上下文中创建,变量“x”的值已经存在了
alert(x); // 10
};
..
还可以用于封装作用域来隐藏辅助对象:
var foo = {};
// initialization
(function (object) {
var x = 10;
object.getX = function _getX() {
return x;
};
})(foo);
alert(foo.getX()); // get closured "x" – 10
总结
本文介绍了更多关于ECMAScript-262-3的理论知识,而我认为,这些基础的理论有助于理解ECMAScript中闭包的概念。
扩展阅读
函数(Functions)
说明
此文译自Dmitry A.Soshnikov 的文章Functions
概要
本 文将给大家介绍ECMAScript中的一般对象之一——函数。我们将着重介绍不同类型的函数以及不同类型的函数是如何影响上下文的变量对象以及函数的作 用域链的。 我们还会解释经常会问到的问题,诸如:“不同方式创建出来的函数会不一样吗?(如果会,那么到底有什么不一样呢?)”:
var foo = function () {
...
};
上述方式创建的函数和如下方式创建的有什么不同?
function foo() {
...
}
如下代码中,为啥一个函数要用括号包起来呢?
(function () {
...
})();
由于本文和此前几篇文章都是有关联的,因此,要想完全搞懂这部分内容,建议先去阅读第二章-变量对象 以及第四章-作用域链。
下面,来我们先来介绍下函数类型。
函数类型
ECMAScript中包含三类函数,每一类都有各自的特性。
函数声明(Function Declaration)
函数声明(简称FD)是指这样的函数
* 有函数名
* 代码位置在:要么在程序级别或者直接在另外一个函数的函数体(FunctionBody)中
* 在进入上下文时创建出来的
* 会影响变量对象
* 是以如下形式声明的
function exampleFunc() {
...
}
这类函数的主要特性是:只有它们可以影响变量对象(存储在上下文的VO中)。此特性同时也引出了非常重要的一点(变量对象的天生特性导致的) —— 它们在执行代码阶段就已经存在了(因为FD在进入上下文阶段就收集到了VO中)。
下面是例子(从代码位置上来看,函数调用在声明之前):
foo();
function foo() {
alert(‘foo‘);
}
从定义中还提到了非常重要的一点 —— 函数声明在代码中的位置:
// 函数声明可以直接在程序级别的全局上下文中
function globalFD() {
// 或者直接在另外一个函数的函数体中
function innerFD() {}
}
除了上述提到了两个位置,其他位置均不能出现函数声明 —— 比方说,在表达式的位置或者是代码块中进行函数声明都是不可以的。
介绍完了函数声明,接下来介绍函数表达式(function expression)。
函数表达式
函数表达式(简称:FE)是指这样的函数:
* 代码位置必须要在表达式的位置
* 名字可有可无
* 不会影响变量对象
* 在执行代码阶段创建出来
这类函数的主要特性是:它们的代码总是在表达式的位置。最简单的表达式的例子就是赋值表达式:
var foo = function () {
...
};
上述例子中将一个匿名FE赋值给了变量“foo”,之后该函数就可以通过“foo”来访问了—— foo()。
正如定义中提到的,FE也可以有名字:
var foo = function _foo() {
...
};
这里要注意的是,在FE的外部可以通过变量“foo”——foo()来访问,而在函数内部(比如递归调用),还可以用“_foo”(译者注:但在外部是无法使用“_foo”的)。
当FE有名字的时候,它很难和FD作区分。不过,如果仔细看这两者的定义的话,要区分它们还是很容易的: FE总是在表达式的位置。 如下例子展示的各类ECMAScript表达式都属于FE:
// 在括号中(grouping operator)只可能是表达式
(function foo() {});
// 在数组初始化中 —— 同样也只能是表达式
[function bar() {}];
// 逗号操作符也只能跟表达式
1, function baz() {};
定义中还提到FE是在执行代码阶段创建的,并且不是存储在变量对象上的。如下所示:
// 不论是在定义前还是定义后,FE都是无法访问的
// (因为它是在代码执行阶段创建出来的),
alert(foo); // "foo" is not defined
(function foo() {});
// 后面也没用,因为它根本就不在VO中
alert(foo); // "foo" is not defined
问题来了,FE要来干嘛?其实答案是很明显的 —— 在表达式中使用,从而避免对变量对象造成“污染”。最简单的例子就是将函数作为参数传递给另外一个函数:
function foo(callback) {
callback();
}
foo(function bar() {
alert(‘foo.bar‘);
});
foo(function baz() {
alert(‘foo.baz‘);
});
上述例子中,部分变量存储了对FE的引用,这样函数就会保留在内存中并在之后,可以通过变量来访问(因为变量是可以影响VO的):
var foo = function () {
alert(‘foo‘);
};
foo();
如下例子是通过创建一个封装的作用域来对外部上下文隐藏辅助数据(例子中我们使用FE使得函数创建后就立马执行):
var foo = {};
(function initialize() {
var x = 10;
foo.bar = function () {
alert(x);
};
})();
foo.bar(); // 10;
alert(x); // "x" is not defined
我们看到函数“foo.bar”(通过其[[Scope]]属性)获得了对函数“initialize”内部变量“x”的访问。 而同样的“x”在外部就无法访问到。很多库都使用这种策略来创建“私有”数据以及隐藏辅助数据。通常,这样的情况下FE的名字都会省略掉:
(function () {
// 初始化作用域
})();
还有一个FE的例子是:在执行代码阶段在条件语句中创建FE,这种方式也不会影响VO:
var foo = 10;
var bar = (foo % 2 == 0
? function () { alert(0); }
: function () { alert(1); }
);
bar(); // 0
“有关括号”的问题
让我们回到本文之初,来回答下此前提到的问题 —— “为什么在函数创建之后立即进行函数调用时,需要用括号将其包起来?”。 要回答此问题,需要先介绍下关于表达式语句的限制。
标准中提到,表达式语句(ExpressionStatement)不能以左大括号{开始 —— 因为这样一来就和代码块冲突了, 也不能以function关键字开始,因为这样一来又和函数声明冲突了。比方说,以如下所示的方式来定义一个立马要执行的函数:
function () {
...
}();
// or with a name
function foo() {
...
}();
对于这两种情况,解释器都会抛出错误,只是原因不同。
如果我们是在全局代码(程序级别)中这样定义函数,解释器会以函数声明来处理,因为它看到了是以function开始的。 在第一个例子中,会抛出语法错误,原因是既然是个函数声明,则缺少函数名了(一个函数声明其名字是必须的)。
而在第二个例子中,看上去已经有了名字了(foo),应该会正确执行。然而,这里还是会抛出语法错误 —— 组操作符内部缺少表达式。 这里要注意的是,这个例子中,函数声明后面的()会被当组操作符来处理,而非函数调用的()。因此,如果我们有如下代码:
// "foo" 是函数声明
// 并且是在进入上下文的时候创建的
alert(foo); // function
function foo(x) {
alert(x);
}(1); // 这里只是组操作符,并非调用!
foo(10); // 这里就是调用了, 10
上述代码其实就是如下代码:
// function declaration
function foo(x) {
alert(x);
}
// 含表达式的组操作符
(1);
// 另外一个组操作符
// 包含一个函数表达式
(function () {});
// 这里面也是表达式
("foo");
// etc
当这样的定义出现在语句位置时,也会发生冲突并产生语法错误:
if (true) function foo() {alert(1)}
上述结构根据标准规定是不合法的。(表达式是不能以function关键字开始的),然而,正如我们在后面要看到的,没有一种实现对其抛出错误, 它们各自按照自己的方式在处理。
讲 了这么多,那究竟要怎么写才能达到创建一个函数后立马就进行调用的目的呢? 答案是很明显的。它必须要是个函数表达式,而不能是函数声明。而创建表达式最简单的方式就是使用上述提到的组操作符。因为在组操作符中只可能是表达式。 这样一来解释器也不会纠结了,会果断将其以FE的方式来处理。这样的函数将在执行阶段创建出来,然后立马执行,随后被移除(如果有没有对其的引用的话):
(function foo(x) {
alert(x);
})(1); // 好了,这样就是函数调用了,而不再是组操作符了,1
要注意的是,在下面的例子中,函数调用,其括号就不再是必须的了,因为函数本来就在表达式的位置了,解释器自然会以FE来处理,并且会在执行代码阶段创建该函数:
var foo = {
bar: function (x) {
return x % 2 != 0 ? ‘yes‘ : ‘no‘;
}(1)
};
alert(foo.bar); // ‘yes‘
因此,对“括号有关”问题的完整的回答则如下所示:
如果要在函数创建后立马进行函数调用,并且函数不在表达式的位置时,括号就是必须的 —— 这样情况下,其实是手动的将其转换成了FE。 而当解释器直接将其以FE的方式处理的时候,说明FE本身就在函数表达式的位置 —— 这个时候括号就不是必须的了。
另外,除了使用括号的方式将函数转换成为FE之外,还有其他的方式,如下所示:
1, function () {
alert(‘anonymous function is called‘);
}();
// 或者这样
!function () {
alert(‘ECMAScript‘);
}();
// 当然,还有其他很多方式
...
不过,括号是最通用也是最优雅的方式。
顺便提下,组操作符既可以包含没有调用括号的函数,又可以包含有调用括号的函数,这两者都是合法的FE:
(function () {})();
(function () {}());
实现扩展: 函数语句
看如下代码,符合标准的解释器都无法解释这样的代码:
if (true) {
function foo() {
alert(0);
}
} else {
function foo() {
alert(1);
}
}
foo(); // 1 还是 0 ? 在不同引擎中测试
这里有必要提下:根据标准,上述代码结构是不合法的,因为,此前我们就介绍过,函数声明是不能出现在代码块中的(这里if和else就包含代码块)。 此前提到的,函数声明只能出现在两个位置: 程序级别或者另外一个函数的函数体中。
为什么这种结构是错误的呢?因为在代码块中只允许语句。函数要想在这个位置出现的唯一可能就是要成为表达式语句。 但是,根据定义表达式语句又不能以左大括号开始(这样会与代码块冲突)也不能以function关键字开始(这样又会和FD冲突)。
然而,在错误处理部分,标准允许实现对程序语法进行扩展。而上述例子就是其中一种扩展。目前,所有的实现中都不会对上述情况抛出错误,都会以各自的方式进行处理。
因此根据标准,上述if-else中应当需要FE。然而,绝大多数实现中都在进入上下文的时候在这里简单地创建了FD,并且使用了最后一次的声明。 最后“foo”函数显示了1,尽管理论上else中的代码根本不会被执行到。
而 SpiderMonkey(TraceMonkey也是)实现中,会将上述情况以两种方式来处理: 一方面它不会将这样的函数以函数声明来处理(也就意味着函数会在执行代码阶段才会创建出来), 然而,另外一方面,它们又不属于真正的函数表达式,因为在没有括号的情况是不能作函数调用的(同样会有解析错误——和FD冲突),它们还是存储在变量对象 中。
我认为SpiderMonkey单独引入了自己的中间函数类型——(FE+FD),这样的做法是正确的。这样的函数会根据时间和对应的条件正确创建出来,不像FE。 和FD有点类似,可以在外部对其进行访问。SpiderMonkey将这种语法扩展命名为函数语句(Function Statement)(简称FS);这部分理论在MDC中有具体的介绍。 JavaScript的发明者 Brendan Eich也提到过这类函数类型。
有名字的函数表达式的特性(NFE)
当 FE有名字之后(named function expression,简称:NFE),就产生了一个重要的特性。 正如在定义中提到的,函数表达式是不会影响上下文的变量对象的(这就意味着不论是在定义前还是在定义后,都是不可能通过名字来进行调用的)。 然而,FE可以通过自己的名字进行递归调用:
(function foo(bar) {
if (bar) {
return;
}
foo(true); // "foo" name is available
})();
// but from the outside, correctly, is not
foo(); // "foo" is not defined
这里“foo”这个名字究竟保存在哪里呢?在foo的活 跃对象中吗?非也,因为在foo函数中根本就没有定义任何“foo”。 那么是在上层上下文的变量对象中吗?也不是,因为根据定义——FE是不会影响VO的——正如我们在外层对其调用的结果所看到的那样。 那么,它究竟保存在哪里了呢?
不卖关子了,马上来揭晓。当解释器在执行代码阶段看到了有名字的FE之后,它会在创建FE之前,创建一个辅助 型的特殊对象,并把它添加到当前的作用域链中。 然后,再创建FE,在这个时候(根据第四章-作用域链描述的),函数拥有了[[Scope]]属性 —— 创建函数所在上下文的作用域链(这个时候,在[[Scope]]就有了那个特殊对象)。 之后,特殊对象中唯一的属性 —— FE的名字添加到了该对象中;其值就是对FE的引用。在最后,当前上下文退出的时候,就会把该特殊对象移除。 用伪代码来描述此算法就如下所示:
specialObject = {};
Scope = specialObject + Scope;
foo = FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}
delete Scope[0]; // 从作用域链的最前面移除specialObject
这就是为什么在函数外是无法通过名字访问到该函数的(因为它并不在上层作用域中存在),而在函数内部却可以访问到。
而这里要注意的一点是: 在某些实现中,比如Rhino,FE的名字并不是保存在特殊对象中的,而是保存在FE的活跃对象中。 再比如微软的实现 —— JScript,则完全破坏了FE的规则,直接将该名字保存在上层作用域的变量对象中了,这样在外部也可以访问到。
NFE和SpiderMonkey
说 到实现,部分版本的SpiderMonkey有一个与上述提到的特殊对象相关的特性,这个特性也可以看作是个bug(既然所有的实现都是严格遵循标准的, 那么这个就是标准的问题了)。 此特性和标识符处理相关: 作用域链的分析是二维的,在标识符查询的时候,还要考虑作用域链中每个对象的原型链。
当在Object.prototype对象上定义一个属性,并将该属性值指向一个“根本不存在”的变量时,就能够体现该特性。 比如,如下例子中的变量“x”,在查询过程中,通过作用域链,一直到全局对象也是找不到“x”的。 然而,在SpiderMonkey中,全局对象继承自Object.prototype,于是,对应的值就在该对象中找到了:
Object.prototype.x = 10;
(function () {
alert(x); // 10
})();
活跃对象是没有原型一说的。可以通过内部函数还证明。 如果在定义一个局部变量“x”并声明一个内部函数(FD或者匿名的FE),然后,在内部函数中引用变量“x”,这个时候该变量会在上层函数上下文中查询到(理应如此),而不是在Object.prototype中:
Object.prototype.x = 10;
function foo() {
var x = 20;
// function declaration
function bar() {
alert(x);
}
bar(); // 20, from AO(foo)
// the same with anonymous FE
(function () {
alert(x); // 20, also from AO(foo)
})();
}
foo();
在有些实现中,存在这样的异常:它们会在活跃对象设置原型。比方说,在Blackberry的实现中,上述例子中变量“x”值就会变成10。 因为,“x”从Object.prototype中就找到了:
AO(bar FD or anonymous FE) -> no ->
AO(bar FD or anonymous FE).[[Prototype]] -> yes - 10
当出现有名字的FE的特殊对象的时候,在SpiderMonkey中也是有同样的异常。该特殊对象是常见对象 —— “和通过new Object()表达式产生的一样”。 相应地,它也应当继承自Object.prototype,上述描述只针对SpiderMonkey(1.7版本)。其他的实现(包括新的TraceMonkey)是不会给这个特殊对象设置原型的:
function foo() {
var x = 10;
(function bar() {
alert(x); // 20, but not 10, as don‘t reach AO(foo)
// "x" is resolved by the chain:
// AO(bar) - no -> __specialObject(bar) -> no
// __specialObject(bar).[[Prototype]] - yes: 20
})();
}
Object.prototype.x = 20;
foo();
NFE和JScript
微软的实现——JScript,是IE的JS引擎(截至本文撰写时最新是JScript5.8——IE8),该引擎与NFE相关的bug有很多。每个bug基本上都和ECMA-262-3rd标准是完全违背的。 有些甚至会引发严重的错误。
第 一,针对上述这样的情况,JScript完全破坏了FE的规则:不应当将函数名字保存在变量对象中的。 另外,FE的名字应当保存在特殊对象中,并且只有在函数自身内部才可以访问(其他地方均不可以)。而JScript却将其直接保存在上层上下文的变量对象 中。 并且,JScript居然还将FE以FD的方式处理,在进入上下文的时候就将其创建出来,并在定义之前就可以访问到:
// FE 保存在变量对象中
// 和FD一样,在定义前就可以通过名字访问到
testNFE();
(function testNFE() {
alert(‘testNFE‘);
});
// 同样的,在定义之后也可以通过名字访问到
testNFE();
正如大家所见,完全破坏了FE的规则。
第二,在声明同时,将NFE赋值给一个变量的时候,JScript会创建两个不同的函数对象。 这种行为感觉完全不符合逻辑(特别是考虑到在NFE外层,其名字根本是无法访问到的):
var foo = function bar() {
alert(‘foo‘);
};
alert(typeof bar); // "function", NFE 有在VO中了 – 这里就错了
// 然后,还有更有趣的
alert(foo === bar); // false!
foo.x = 10;
alert(bar.x); // undefined
// 然而,两个函数完全做的是同样的事情
foo(); // "foo"
bar(); // "foo"
然而,要注意的是: 当将NFE和赋值给变量这两件事情分开的话(比如,通过组操作符),在定义好后,再进行变量赋值,这样,两个对象就相同了,返回true:
(function bar() {});
var foo = bar;
alert(foo === bar); // true
foo.x = 10;
alert(bar.x); // 10
这个时候就好解释了。实施上,一开始的确创建了两个对象,不过之后就只剩下一个 了。这里将NFE以FD的方式来处理,然后,当进入上下文的时候,FD bar就创建出来了。 在这之后,到了执行代码阶段,又创建出了第二个对象 —— FE bar,该对象不会进行保存。相应的,由于没有变量对其进行引用,随后FE bar对象就被移除了。 因此,这里就只剩下一个对象——FD bar对象,对该对象的引用就赋值给了foo变量。
第三,通过arguments.callee对一个函数进行间接引用,它引用的是和激活函数名一致的对象(事实上是——函数,因为有两个对象):
var foo = function bar() {
alert([
arguments.callee === foo,
arguments.callee === bar
]);
};
foo(); // [true, false]
bar(); // [false, true]
第四,JScript会将NFE以FD来处理,但当遇到条件语句又不遵循此规则了。比如说,和FD那样,NFE会在进入上下文的时候就创建出来,这样最后一次定义的就会被使用:
var foo = function bar() {
alert(1);
};
if (false) {
foo = function bar() {
alert(2);
};
}
bar(); // 2
foo(); // 1
上述行为从逻辑上也是可以解释通的: 当进入上下文的时候,最后一次定义的FD bar被创建出来(有alert(2)的函数), 之后到了执行代码阶段又一个新的函数 —— FE bar被创建出来,对其引用赋值给了变量foo。因此(if代码块中由于判断条件是false,因此其代码块中的代码永远不会被执行到)foo函数的调用 会打印出1。 尽管“逻辑上”是对的,但是这个仍然算是IE的bug。因为它明显就破坏了实现的规则,所以我这里用了引号“逻辑上”。
第五 个JScript中NFE的bug和通过给一个未受限的标识符赋值(也就是说,没有var关键字)来创建全局对象的属性相关。 由于这里NFE会以FD的方式来处理,并相应地会保存在变量对象上,赋值给未受限的标识符(不是给变量而是给全局对象的一般属性), 当函数名和标识符名字相同的时候,该属性就不会是全局的了。
(function () {
// 没有var,就不是局部变量,而是全局对象的属性
foo = function foo() {};
})();
// 然而,在匿名函数的外层,foo又是不可访问的
alert(typeof foo); // undefined
这里从“逻辑上”又是可以解释通的: 进入上下文时,函数声明在匿名函数本地上下文的活跃对象中。 当进入执行代码阶段的时候,因为foo这个名字已经在AO中存在了(本地),相应地,赋值操作也只是简单的对AO中的foo进行更新而已。 并没有在全局对象上创建新的属性。
通过Function构造器创建的函数
这类函数有别于FD和FE,有自己的专属特性: 它们的[[Scope]]属性中只包含全局对象:
var x = 10;
function foo() {
var x = 20;
var y = 30;
var bar = new Function(‘alert(x); alert(y);‘);
bar(); // 10, "y" is not defined
}
我们看到bar函数的[[Scope]]属性并未包含foo上下文的AO —— 变量“y”是无法访问的,并且变量“x”是来自全局上下文。 顺便提下,这里要注意的是,Function构造器可以通过new关键字和省略new关键字两种用法。上述例子中,这两种用法都是一样的。
此类函数其他特性则和同类语法产生式以及联合对象有 关。 该机制在标准中建议在作优化的时候采用(当然,具体的实现者也完全有权利不使用这类优化)。比方说,有100元素的数组,在循环数组过程中会给数组每个元 素赋值(函数), 这个时候,实现的时候就可以采用联合对象的机制了。这样,最终所有的数组元素都会引用同一个函数(只有一个函数):
var a = [];
for (var k = 0; k < 100; k++) {
a[k] = function () {}; // 这里就可以使用联合对象
}
但是,通过Function构造器创建的函数就无法使用联合对象了:
var a = [];
for (var k = 0; k $lt; 100; k++) {
a[k] = Function(‘‘); // 只能是100个不同的函数
}
下面是另外一个和联合对象相关的例子:
function foo() {
function bar(z) {
return z * z;
}
return bar;
}
var x = foo();
var y = foo();
上述例子,在实现过程中同样可以使用联合对象。来使得x和y引用同一个对象,因为函数(包括它们内部的[[Scope]]属性)物理上是不可分辨的。 因此,通过Function构造器创建的函数总是会占用更多内存资源。
函数创建的算法
如下所示使用伪代码表示的函数创建的算法(不包含联合对象的步骤)。有助于理解ECMAScript中的函数对象。此算法对所有函数类型都是一样的。
F = new NativeObject();
// 属性 [[Class]] is "Function"
F.[[Class]] = "Function"
// 函数对象的原型
F.[[Prototype]] = Function.prototype
// 对函数自身引用
// [[Call]] 在函数调用时F()激活
// 同时创建一个新的执行上下文
F.[[Call]] = <reference to function>
// 内置的构造器
// [[Construct]] 会在使用“new”关键字的时候激活
// 事实上,它会为新对象申请内存
// 然后调用 F.[[Call]]来初始化创建的对象,将this值设置为新创建的对象
F.[[Construct]] = internalConstructor
// 当前上下文(创建函数F的上下文)的作用域名链
F.[[Scope]] = activeContext.Scope
// 如果是通过new Function(...)来创建的,则
F.[[Scope]] = globalContext.Scope
// 形参的个数
F.length = countParameters
// 通过F创建出来的对象的原型
__objectPrototype = new Object();
__objectPrototype.constructor = F // {DontEnum}, 在遍历中不能枚举
F.prototype = __objectPrototype
return F
要注意的是,F.[[Prototype]]是函数(构造器)的原型,而F.prototype是通过该函数创建出来的对象的原型(因为通常对这两个概念都会混淆,在有些文章中会将F.prototype叫做“构造器的原型”,这是错误的)。
总结
本文介绍了很多关于函数的内容;不过在后面的关于对象和原型的文章中,还会提到函数作为构造器是如何工作的。
扩展阅读
ECMAScript标准:
另外一篇文章:
作用域链(Scope Chain)
说明
此文译自Dmitry A.Soshnikov 的文章Scope Chain
另,此文还有另外一位同事(邵信衡)共同参译
概要
在第二章变量对象的时候, 已经介绍过执行上下文的数据是以变量对象的属性的形式进行存储的。
还介绍了,每次进入执行上下文的时候,就会创建变量对象,并且赋予其属性初始值,随后在执行代码阶段会对属性值进行更新。
本文要与执行上下文密切相关的另外一个重要的概念——作用域链(Scope Chain)。
定义
若要简单扼要对作用域脸做个解释,那就是:作用域链和内部函数息息相关。
众所周知,ECMAScript允许创建内部函数,甚至可以将这些内部函数作为父函数的返回值。
var x = 10;
function foo() {
var y = 20;
function bar() {
alert(x + y);
}
return bar;
}
foo()(); // 30
每个上下文都有自己的变量对象:对于全局上下文而言,其变量对象就是全局对象本身,对于函数而言,其变量对象就是活跃对象。
作用域链其实就是所有内部上下文的变量对象的列表。用于变量查询。比如,在上述例子中,“bar”上下文的作用域链包含了AO(bar),AO(foo)和VO(global)。
下面就来详细介绍下作用域链。
先从定义开始,随后再结合例子详细介绍:
作用域链是一条变量对象的链,它和执行上下文有关,用于在处理标识符时候进行变量查询。
函数上下文的作用域链在函数调用的时候创建出来,它包含了活跃对象和该函数的内部[[Scope]]属性。关于[[Scope]]会在后面作详细介绍。
大致表示如下:
activeExecutionContext = {
VO: {...}, // 或者 AO
this: thisValue,
Scope: [ // 所用域链
// 所有变量对象的列表
// 用于标识符查询
]
};
上述代码中的Scope定义为如下所示:
Scope = AO + [[Scope]]
针对我们的例子来说,可以将Scope和[[Scope]]用普通的ECMAScript数组来表示:
var Scope = [VO1, VO2, ..., VOn]; // 作用域链
除此之外,还可以用分层对象链的数据结构来表示,链中每一个链接都有对父作用域(上层变量对象)的引用。这种表示方式和第二章中讨论的某些实现中__parent__的概念相对应:
var VO1 = {__parent__: null, ... other data}; -->
var VO2 = {__parent__: VO1, ... other data}; -->
// etc.
然而,使用数组来表示作用域链会更方便,因此,我们这里就采用数组的表示方式。 除此之外,不论在实现层是否采用包含__parent__特性的分层对象链的数据结构,标准自身对其做了抽象的定义“作用域链是一个对象列表”。 数组就是实现列表这一概念最好的选择。
下面将要介绍的 AO+[[Scope]]以及标识符的处理方式,都和函数的生命周期有关。
函数的生命周期
函数的生命周期分为创建阶段和激活(调用)阶段。下面就来详细对其作介绍。
函数的创建
众所周知,在进入上下文阶段,函数声明会存储在变量/活跃对象中(VO/AO)。让我们来看一个全局上下文中变量声明和函数声明的例子(这种情况下,变量对象就是全局对象本身,应该还没忘记吧?):
var x = 10;
function foo() {
var y = 20;
alert(x + y);
}
foo(); // 30
在函数激活后,我们看到了正确(预期)的结果——30。不过,这里有一个非常重要的特性。
在 说当前上下文的变量对象前。上述代码中我们看到变量“y”是在“foo”函数中定义的(意味着它存储在“foo”上下文的AO对象中), 然而变量“x”则并没有在“foo”上下文中定义,自然也不会添加到“foo”的AO中。乍一眼看过去,变量“x”压根就不在“foo”中存在; 然而,正如我们下面要看到的——仅仅只是“乍一眼看过去“而已。我们看到“foo”上下文的活跃对象中只包含一个属性——“y”:
fooContext.AO = {
y: undefined // undefined – 在进入上下文时, 20 – 在激活阶段
};
那么,“foo”函数到底是如何访问到变量“x”的呢?一个顺其自然的想法是:函数应当有访问更高层上下文变量对象的权限。 而事实也恰是如此,就是通过函数的内部属性[[Scope]]来实现这一机制的。
[[Scope]]是一个包含了所有上层变量对象的分层链,它属于当前函数上下文,并在函数创建的时候,保存在函数中。
这里要注意的很重要的一点是:[[Scope]]是在函数创建的时候保存起来的——静态的(不变的),只有一次并且一直都存在——直到函数销毁。 比方说,哪怕函数永远都不能被调用到,[[Scope]]属性也已经保存在函数对象上了。
另外要注意的一点是:[[Scope]]与Scope(作用域链)是不同的,前者是函数的属性,后者是上下文的属性。 以上述例子来说,“foo”函数的[[Scope]]如下所示:
foo.[[Scope]] = [
globalContext.VO // === Global
];
之后,有了函数调用,就会进入函数上下文,这个时候会创建活跃对象并且this的值和Scope(作用域链)都会确定。下面来详细介绍下。
函数的激活
正如在“定义”这节提到的,在进入上下文,AO/VO创建之后,上下文的Scope属性(作用域链,用于变量查询)会定义为如下所示:
Scope = AO|VO + [[Scope]]
这里要注意的是活跃对象是Scope数组的第一个元素。添加在作用域链的最前面:
Scope = [AO].concat([[Scope]]);
此特性对处理标识符非常重要。
处理标识符其实就是一个确定变量(或者函数声明)属于作用域链中哪个变量对象的过程。
此算法返回的总是一个引用类型的值,其base属性就是对应的变量对象(或者如果变量不存在的时候则返回null),其property name属性的名字就是要查询的标识符。 要详细了解引用类型可以参看第三章-this。
标识符处理过程包括了对应的变量名的属性查询,比如:在作用域链中会进行一系列的变量对象的检测,从作用域链的最底层上下文一直到最上层上下文。
因此,在查询过程中上下文中的局部变量相比较上层上下文的变量会优先被查询到,换句话说,如果两个相同名字的变量存在于不同的上下文中时,处于底层上下文的变量会优先被找到。
下面是一个相对比较复杂的例子:
var x = 10;
function foo() {
var y = 20;
function bar() {
var z = 30;
alert(x + y + z);
}
bar();
}
foo(); // 60
针对上述代码,对应了如下的变量/活跃对象,函数的[[Scope]]属性以及上下文的作用域链:
全局上下文的变量对象如下所示:
globalContext.VO === Global = {
x: 10
foo:
};
在“foo”函数创建的时候,其[[Scope]]属性如下所示:
foo.[[Scope]] = [
globalContext.VO
];
在“foo”函数激活的时候(进入上下文时),“foo”函数上下文的活跃对象如下所示:
fooContext.AO = {
y: 20,
bar:
};
同时,“foo”函数上下文的作用域链如下所示:
fooContext.Scope = fooContext.AO + foo.[[Scope]] // i.e.:
fooContext.Scope = [
fooContext.AO,
globalContext.VO
];
在内部“bar”函数创建的时候,其[[Scope]]属性如下所示:
bar.[[Scope]] = [
fooContext.AO,
globalContext.VO
];
在“bar”函数激活的时候,其对应的活跃对象如下所示:
barContext.AO = {
z: 30
};
同时,“bar”函数上下文的作用域链如下所示:
barContext.Scope = barContext.AO + bar.[[Scope]] // i.e.:
barContext.Scope = [
barContext.AO,
fooContext.AO,
globalContext.VO
];
如下是“x”,“y”和“z”标识符的查询过程:
- "x"
-- barContext.AO // not found
-- fooContext.AO // not found
-- globalContext.VO // found - 10
- "y"
-- barContext.AO // not found
-- fooContext.AO // found - 20
- "z"
-- barContext.AO // found - 30
作用域的特性
接下来为大家介绍一些与作用域链和函数的[[Scope]]属性相关的重要特性。
闭包
在 ECMAScript中,闭包和函数的[[Scope]]属性息息相关。正如此前介绍的,[[Scope]]是在函数创建的时候就保存在函数对象上了,并 且直到函数销毁的时候才消失。 事实上,闭包就是函数代码和其[[Scope]]属性的组合。因此,[[Scope]]包含了函数创建所在的词法环境(上层变量对象)。 上层上下文中的变量,可以在函数激活的时候,通过变量对象的词法链(函数创建的时候就保存起来了)查询到。
如下例子所示:
var x = 10;
function foo() {
alert(x);
}
(function () {
var x = 20;
foo(); // 10, but not 20
})();
我们看到变量“x”是在“foo”函数的[[Scope]]中找到的。对于变量查询而言,词法链是在函数创建的时候就定义的,而不是在使用的调用的动态链(这个时候,变量“x”才会是20)。
下面是另外一个(典型的)闭包的例子:
function foo() {
var x = 10;
var y = 20;
return function () {
alert([x, y]);
};
}
var x = 30;
var bar = foo(); // anonymous function is returned
bar(); // [10, 20]
上述例子再一次证明了处理标识符的时候,词法作用域链是在函数创建的时候定义的 ——变量“x”的值是10,而不是30。 并且,上述例子清楚的展示了函数(上述例子中指的是函数“foo”返回的匿名函数)的[[Scope]]属性,即使在创建该函数的上下文结束的时候依然存 在。
更多关于ECMAScript对闭包的实现细节会在第六章-闭包中做介绍。
通过Function构造器创建的函数的[[Scope]]属性
在前面的例子中,我们看到函数在创建的时候就拥有了[[Scope]]属性,并且通过该属性可以获取所有上层上下文中的变量。 然而,这里有个例外,就是当函数通过Function构造器创建的时候。
var x = 10;
function foo() {
var y = 20;
function barFD() { // FunctionDeclaration
alert(x);
alert(y);
}
var barFE = function () { // FunctionExpression
alert(x);
alert(y);
};
var barFn = Function(‘alert(x); alert(y);‘);
barFD(); // 10, 20
barFE(); // 10, 20
barFn(); // 10, "y" is not defined
}
foo();
上述例子中,函数“barFn”就是通过Function构造器来创建的,这个时候变量“y”就无法访问到了。 但这并不意味着函数“barFn”就没有内部的[[Scope]]属性了(否则它连变量“x”都无法访问到了)。 问题就在于当函数通过Function构造器来创建的时候,其[[Scope]]属性永远都只包含全局对象。 哪怕在上层上下文中(非全局上下文)创建一个闭包都是无济于事的。
二维作用域链查询
在 作用域链查询的时候还有很重要的一点:变量对象的原型(如果有的话)也是需要考虑的——因为原型是ECMAScript天生的特性:如果属性在对象中没有 找到,那么会继续通过原型链进行查询。 比方说如下这些二维链:(1)在作用域链的链接上,(2)在每个作用域链接上——深入到原型链的链接上。如果在原型链(Object.prototype)上定义了属性就能观察到效果了:
function foo() {
alert(x);
}
Object.prototype.x = 10;
foo(); // 10
活跃对象是没有原型这一说的。通过如下例子可以看出:
function foo() {
var x = 20;
function bar() {
alert(x);
}
bar();
}
Object.prototype.x = 10;
foo(); // 20
试想下,如果“bar”函数的活跃对象有原型的话,属性“x”则应当在 Object.prototype中找到,因为它在AO中根本不存在。 然而,上述第一个例子中,在标识符处理阶段遍历了整个作用域链,到了全局对象(部分实现是这样的),该对象继承自Object.prototype,因 此,最终变量“x”的值就变成了10。
同样的情况,在某些版本的SpiderMonkey中,通过命名函数表达式(简称:NFE)也会发生,其中的存储了可选的函数表达式的名字的特殊对象也继承自Object.prototype, 同样的,在某些版本的Blackberry中,也是如此,其活跃对象是继承自Object.prototype的。不过,关于这块详细的特性将会在第五章-函数中作介绍。
全局和eval上下文的作用域链
尽管这部分内容没多大意思,但还是值得一提的。全局上下文的作用域链中只包含全局对象。“eval”代码类型的上下文和调用上下文(calling context)有相同的作用域链。
globalContext.Scope = [
Global
];
evalContext.Scope === callingContext.Scope;
执行代码阶段对作用域的影响
ECMAScript中,在运行时,执行代码阶段有两种语句可以修改作用域链——with语句和catch从句。在标识符查询阶段,这两者都会被添加到作用域链的最前面。 比如,当有with或者catch的时候,作用域链就会被修改如下形式:
Scope = withObject|catchObject + AO|VO + [[Scope]]
如下例子中,with语句添加了foo对象,使得它的属性可以不需要前缀直接访问。
var foo = {x: 10, y: 20};
with (foo) {
alert(x); // 10
alert(y); // 20
}
对应的作用域链修改为如下所示:
Scope = foo + AO|VO + [[Scope]]
接着来看下面这个例子:
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
发生了什么?怎么最外层的“y”变成了30? 在进入上下文的时候,“x”和“y”标识符已经添加到了变量对象。之后,到了执行代码阶段,发生了如下的改动:
- x=10, y=10
- 对象{x: 20}添加到了作用域链的最前面
- 在with中遇到了var语句,当然了,这个时候什么也不会发生。因为早在进入上下文阶段所有的变量都已经解析过了并且添加到了对应的变量对象上了。
- 这里修改了“x”的值,原本“x”是在第二步的时候添加的对象{x: 20}(该对象被添加到了作用域链的最前面)中的“x”,现在变成了30。
- 同样的,“y”的值也修改了,由原本的10变成了30
- 之后,在with语句结束之后,其特殊对象从作用域链中移除(修改过的“x”——30,也随之移除),作用域链又恢复到了with语句前的状态。
- 正如在最后两个alert中看到的,“x”的值恢复到了原先的10,而“y”的值因为在with语句的时候被修改过了,因此变为了30。
同样的,catch从句(可以访问参数异常)会创建一个只包含一个属性(异常参数名)的新对象。如下所示:
try {
...
} catch (ex) {
alert(ex);
}
作用域链修改为如下所示:
var catchObject = {
ex:
};
Scope = catchObject + AO|VO + [[Scope]]
在catch从句结束后,作用域链同样也会恢复到原先的状态。
总结
本文,介绍了几乎所有与执行上下文相关的概念以及相应的细节。后面的章节中,会给大家介绍函数对象的细节:函数的类型(FunctionDeclaration,FunctionExpression)和闭包。 顺便提下,本文中介绍过,闭包是和[[Scope]]有直接的关系,但是关于闭包的细节会在后续章节中作介绍。
扩展阅读
- 8.6.2 —— [[Scope]]
- 10.1.4 —— 作用域链和标识符的处理
this
说明
此文译自Dmitry A.Soshnikov 的文章this
概要
本文将进一步讨论与执行上下文密切相关的概念——this关键字。
事实证明,this这块的内容非常的复杂,它在不同执行上下文的情况下其值都会不同,并且会相应的引发一些问题。
很多程序员一看到this关键字,就会把它和面向对象的编程方式联系在一起,它指向利用构造器新创建出来的对象。在ECMAScript中,也支持this,然而, 正如大家所熟知的,this不仅仅只用来表示创建出来的对象。
接下来给大家揭开在ECMAScript中this神秘的面纱。
定义
This是执行上下文的一个属性:
activeExecutionContext = {
VO: {...},
this: thisValue
};
这里的VO就是前一章介绍的变量对象。
This与上下文的可执行代码类型有关,其值在进入上下文阶段就确定了,并且在执行代码阶段是不能改变的。
下面就来详细对其作个介绍。
全局代码中This的值
这种情况下,一切都变得非常简单,this的值总是全局对象本身;因此,可以间接地获取引用:
// 显式定义全局对象的属性
this.a = 10; // global.a = 10
alert(a); // 10
// 通过赋值给不受限的标识符来进行隐式定义
b = 20;
alert(this.b); // 20
// 通过变量声明来进行隐式定义
// 因为全局上下文中的变量对象就是全局对象本身
var c = 30;
alert(this.c); // 30
函数代码中This的值
当this在函数代码中的时候,事情就变得有趣多了。这种情况下是最复杂的,并且会引发很多的问题。
函数代码中this值的第一个特性(同时也是最主要的特性)就是:它并非静态的绑定在函数上。
正如此前提到的,this的值是在进入上下文的阶段确定的,并且在函数代码中的话,其值每次都会大不相同。
然而,一旦进入执行代码阶段,其值就不能改变了。比方说,要想给this赋一个新的值是不可能的,因为this根本就不是变量(相反的,在Python语言中,它显示定义的self对象是可以在运行时随意更改的):
var foo = {x: 10};
var bar = {
x: 20,
test: function () {
alert(this === bar); // true
alert(this.x); // 20
this = foo; // error, 不能更改this的值
alert(this.x); // 如果没有错误,则其值为10而不是20
}
};
// 在进入上下文的时候,this的值就确定了是“bar”对象
// 至于为什么,会在后面作详细介绍
bar.test(); // true, 20
foo.test = bar.test;
// 但是,这个时候,this的值又会变成“foo”
// 纵然我们调用的是同一个函数
foo.test(); // false, 10
因此,在函数代码中影响this值的因素是有很多的。
首先,在一般的函数调用中,this的值是由激活上下文代码的调用者决定的,比如说,调用函数的外层上下文。this的值是由调用表达式的形式决定的。
理解并谨记这一点是非常必要的,有利于在任何上下文中都能准确的确定this的值。
影响调用上下文中的this的值的只有可能是调用表达式的形式,也就是调用函数的方式。 (一些关于JavaScript的文章和书籍中指出的“this的值取决于函数的定义方式,如果是全局函数,则this的值就会设置为全局对象,如果是某个对象的方法,则this的值就会设置为该对象”——这纯属扯淡,根本就是在误人子弟)。 正如此前大家看到的,纵然是全局函数,this的值也会随着函数调用方式的不同而不同:
function foo() {
alert(this);
}
foo(); // global
alert(foo === foo.prototype.constructor); // true
// 然而,同样的函数,以另外一种调用方式的话,this的值就不同了
foo.prototype.constructor(); // foo.prototype
调用一个对象的某个方法的时候,this的值也有可能不是该对象的:
var foo = {
bar: function () {
alert(this);
alert(this === foo);
}
};
foo.bar(); // foo, true
var exampleFunc = foo.bar;
alert(exampleFunc === foo.bar); // true
// 同样地,相同的函数以不同的调用方式,this的值也就不同了
exampleFunc(); // global, false
那么,究竟调用表达式的方式是如何影响this的值的呢?为了完全搞明白这其中的奥妙,首先,这里有必要先介绍一种内部类型——引用类型(the Reference type)。
引用类型
引用类型的值可以用伪代码表示为一个拥有两个属性的对象——base属性(属性所属的对象)以及该base对象中的propertyName属性:
var valueOfReferenceType = {
base: ,
propertyName:
};
引用类型的值只有可能是以下两种情况:
- 当处理一个标识符的时候
- 或者进行属性访问的时候
关于标识符的处理会在第四章——所用域链中作介绍,这里我们只要注意的是,此算法总返回一个引用类型的值(这对this的值是至关重要的)。
标识符其实就是变量名,函数名,函数参数名以及全局对象的未受限的属性。如下所示:
var foo = 10;
function bar() {}
中间过程中,对应的引用类型的值如下所示:
var fooReference = {
base: global,
propertyName: ‘foo‘
};
var barReference = {
base: global,
propertyName: ‘bar‘
};
要从引用类型的值中获取一个对象实际的值需要GetValue方法,该方法用伪代码可以描述成如下形式:
function GetValue(value) {
if (Type(value) != Reference) {
return value;
}
var base = GetBase(value);
if (base === null) {
throw new ReferenceError;
}
return base.[[Get]](GetPropertyName(value));
}
上述代码中的[[Get]]方法返回了对象属性实际的值,包括从原型链中继承的属性:
GetValue(fooReference); // 10
GetValue(barReference); // function object "bar"
对于属性访问来说,有两种方式: 点符号(这时属性名是正确的标识符并且提前已经知道了)或者中括号符号:
foo.bar();
foo[‘bar‘]();
中间过程中,得到如下的引用类型的值:
var fooBarReference = {
base: foo,
propertyName: ‘bar‘
};
GetValue(fooBarReference); // function object "bar"
问题又来了,引用类型的值又是如何影响函数上下文中this的值的呢?——非常重要。这也是本文的重点。总的来说,决定函数上下文中this的值的规则如下所示:
函数上下文中this的值是函数调用者提供并且由当前调用表达式的形式而定的。 如果在调用括号()的左边,有引用类型的值,那么this的值就会设置为该引用类型值的base对象。 所有其他情况下(非引用类型),this的值总是null。然而,由于null对于this来说没有任何意义,因此会隐式转换为全局对象。
如下所示:
function foo() {
return this;
}
foo(); // global
上述代码中,调用括号的左侧是引用类型的值(因为foo是标识符):
var fooReference = {
base: global,
propertyName: ‘foo‘
};
相应的,this的值会设置为引用类型值的base对象,这里就是全局对象。
属性访问也是类似的:
var foo = {
bar: function () {
return this;
}
};
foo.bar(); // foo
同样的,也是引用类型的值,它的base对象是foo对象,激活bar函数的时候,this的值就设置为foo对象了:
var fooBarReference = {
base: foo,
propertyName: ‘bar‘
};
然而,同样的函数以不同的激活方式的话,this的值就完全不同了:
var test = foo.bar;
test(); // global
因为test也是标识符,这样就产生了另外的引用类型的值,其中base对象(全局对象)就是this的值:
var testReference = {
base: global,
propertyName: ‘test‘
};
至此,我们就可以精确的解释,为什么同样的函数,以不同的调用方式激活,this的值也会不同了——答案就是处理过程中,是不同的引用类型的值:
function foo() {
alert(this);
}
foo(); // global, 因为
var fooReference = {
base: global,
propertyName: ‘foo‘
};
alert(foo === foo.prototype.constructor); // true
// 另一种调用方式
foo.prototype.constructor(); // foo.prototype, 因为
var fooPrototypeConstructorReference = {
base: foo.prototype,
propertyName: ‘constructor‘
};
如下是另外一种(典型的)利用调用表达式来动态决定this值的例子:
function foo() {
alert(this.bar);
}
var x = {bar: 10};
var y = {bar: 20};
x.test = foo;
y.test = foo;
x.test(); // 10
y.test(); // 20
函数调用以及非引用类型
正如此前提到过的,当调用括号左侧为非引用类型的时候,this的值会设置为null,并最终变成全局对象。
我们来考虑下如下表达式:
(function () {
alert(this); // null => global
})();
上述例子中,有函数对象,但非引用类型对象(因为它不既不是标识符也不属于属性访问),因此,this的值最终设置为全局对象。
如下是更为复杂的例子:
var foo = {
bar: function () {
alert(this);
}
};
foo.bar(); // Reference, OK => foo
(foo.bar)(); // Reference, OK => foo
(foo.bar = foo.bar)(); // global?
(false || foo.bar)(); // global?
(foo.bar, foo.bar)(); // global?
看了上述代码,你可能又有疑问了:为什么明明是属性访问,但是最终this的值不是base对象而是全局对象呢?
这里主要疑问在最后三个表达式,这三个表达式添加了特定的操作之后,调用括号左侧就不再是引用类型的值了。
第一种情况——非常明确,是引用类型,最终this的值设置为base对象,foo。
第二种情况有一个组操作符(grouping operator),该操作符不会触发调用获取引用类型实际值的方法,比如:GetValue方法。 相应的,处理组操作符中间过程中——获得的仍然是一个引用类型的值,这也就解释了为什么this的值设置成了base对象,foo。
第三种情况是一个赋值操作符(assignment operator),与组操作符不同的是,它会触发调用GetValue方法(参见11.13.1中的第三步)。 最后返回的时候就是一个函数对象了(而不是引用类型的值了),这就意味着this的值会设置为null,最终会变成全局对象。
第四和第五种情况也是类似的——逗号操作符和OR逻辑表达式都会触发调用GetValue方法,于是相应地就会丢失原先的引用类型值,变成了函数类型,this的值就变成了全局对象了。
引用类型以及null(this的值)
有这么一种情况下,当调用表达式左侧是引用类型的值,但是this的值却是null,最终变为全局对象。 发生这种情况的条件是当引用类型值的base对象恰好为活跃对象。
当内部子函数在父函数中被调用的时候就会发生这种情况。正如第二章介绍的, 局部变量,内部函数以及函数的形参都会存储在指定函数的活跃对象中:
function foo() {
function bar() {
alert(this); // global
}
bar(); // 和AO.bar()是一样的
}
活跃对象总是会返回this值为——null(用伪代码来表示,AO.bar()就相当于null.bar())。然后,如此前描述的,this的值最终会由null变为全局对象。
当函数调用包含在with语句的代码块中,并且with对象包含一个函数属性的时候,就会出现例外的情况。with语 句会将该对象添加到作用域链的最前面,在活跃对象的之前。 相应地,在引用类型的值(标识符或者属性访问)的情况下,base对象就不再是活跃对象了,而是with语句的对象。另外,值得一提的是,它不仅仅只针对 内部函数,全局函数也是如此, 原因就是with对象掩盖了作用域链中更高层的对象(全局对象或者活跃对象):
var x = 10;
with ({
foo: function () {
alert(this.x);
},
x: 20
}) {
foo(); // 20
}
// because
var fooReference = {
base: __withObject,
propertyName: ‘foo‘
};
当调用的函数恰好是catch从句的参数时,情况也是类似的:在这种情况下,catch对象也会添加到作用域链的最前面,在活跃对象和全局对象之前。 然而,这个行为在ECMA-262-3中被指出是个bug,并且已经在ECMA-262-5中修正了;因此,在这种情况下,this的值应该设置为全局对象,而不是catch对象。
try {
throw function () {
alert(this);
};
} catch (e) {
e(); // __catchObject - in ES3, global - fixed in ES5
}
// on idea
var eReference = {
base: __catchObject,
propertyName: ‘e‘
};
// 然而,既然这是个bug
// 那就应该强制设置为全局对象
// null => global
var eReference = {
base: global,
propertyName: ‘e‘
};
同样的情况还会在递归调用一个非匿名函数的时候发生(函数相关的内容会在第五章作相应的介绍)。在第一次函数调用的 时候,base对象是外层的活跃对象(或者全局对象), 在接下来的递归调用的时候——base对象应当是一个存储了可选的函数表达式名字的特殊对象,然而,事实却是,在这种情况下,this的值永远都是全局对 象:
(function foo(bar) {
alert(this);
!bar && foo(1); // "should" be special object, but always (correct) global
})(); // global
但函数作为构造器被调用时this的值
这里要介绍的是函数上下文中关于this值的另外一种情况——当函数作为构造器被调用的时候:
function A() {
alert(this); // newly created object, below - "a" object
this.x = 10;
}
var a = new A();
alert(a.x); // 10
在这种情况下,new操作符会调用“A”函数的内部[[Construct]]。 在对象创建之后,会调用内部的[[Call]]函数,然后所有“A”函数中this的值会设置为新创建的对象。
手动设置函数调用时this的值
Function.prototype上定义了两个方法(因此,它们对所有函数而言都是可访问的),允许手动指定函数调用时this的值。这两个方法是:.apply和.call; 它们都接受第一个参数作为调用上下文中this的值。而它们的不同点其实无关紧要:对于.apply来说,第二个参数接受数组类型(或者是类数组的对象,比如arguments), 而.call方法接受任意多的参数。这两个方法只有第一个参数是必要的——this的值。
如下所示:
var b = 10;
function a(c) {
alert(this.b);
alert(c);
}
a(20); // this === global, this.b == 10, c == 20
a.call({b: 20}, 30); // this === {b: 20}, this.b == 20, c == 30
a.apply({b: 30}, [40]) // this === {b: 30}, this.b == 30, c == 40
总结
本文我们讨论了ECMAScript中this关键字的特性(相对C++或者Java而言,真的可以说是特性)。洗完此文对大家理解this关键字在ECMAScript中的工作原理有所帮助。
扩展阅读
变量对象(Variable object)
说明
此文译自Dmitry A.Soshnikov 的文章Variable object
另,此文还有另外一位同事(宋珍珍)共同参译
概要
我们总是会在程序中定义一些函数和变量,之后会使用这些函数和变量来构建我们的系统。
然而,对于解释器来说,它又是如何以及从哪里找到这些数据的(函数,变量)?当引用一个对象的时候,在解释器内部又发生了什么?
许多ECMA脚本程序员都知道,变量和执行上下文是密切相关的:
var a = 10; // 全局上下文中的变量
(function () {
var b = 20; // 函数上下文中的局部变量
})();
alert(a); // 10
alert(b); // "b" is not defined
不仅如此,许多程序员也都知道,ECMAScript标准中指出独立的作用域只有通过“函数代码”(可执行代码类型中的一种)才能创建出来。比方说,与C/C++不同的是,在ECMAScript中for循环的代码块是无法创建本地上下文的:
for (var k in {a: 1, b: 2}) {
alert(k);
}
alert(k); // 尽管循环已经结束,但是变量“k”仍然在作用域中
下面就来详细介绍下,当声明变量和函数的时候,究竟发生了什么。
数据声明
既然变量和执行上下文有关,那它就该知道数据存储在哪里以及如何获取。这种机制就称作变量对象:
A variable object (in abbreviated form — VO) is a special object related with an execution context and which stores:
- variables (var, VariableDeclaration);
- function declarations (FunctionDeclaration, in abbreviated form FD);
- and function formal parameters
declared in the context.
举个例子,可以用ECMAScript的对象来表示变量对象:
VO = {};
VO同时也是一个执行上下文的属性:
activeExecutionContext = {
VO: {
// 上下文中的数据 (变量声明(var), 函数声明(FD), 函数形参(function arguments))
}
};
对变量的间接引用(通过VO的属性名)只允许发生在全局上下文中的变量对象上(全局对象本身就是变量对象,这部分会在后续作相应的介绍)。 对于其他的上下文而言,是无法直接引用VO的,因为VO是实现层的。
声明新的变量和函数的过程其实就是在VO中创建新的和变量以及函数名对应的属性和属性值的过程。
如下所示:
var a = 10;
function test(x) {
var b = 20;
};
test(30);
上述代码对应的变量对象则如下所示:
// 全局上下文中的变量对象
VO(globalContext) = {
a: 10,
test:
};
// “test”函数上下文中的变量对象
VO(test functionContext) = {
x: 30,
b: 20
};
但是,在实现层(标准中定义的),变量对象只是一个抽象的概念。在实际执行上下文中,VO可能完全不叫VO,并且初始的结构也可能完全不同。
不同执行上下文中的变量对象
变量对象上的一些操作(比如:变量的初始化)和行为对于所有的执行上下文类型来说都已一样的。从这一点来说,将变量对象表示成抽象的概念更加合适。 函数上下文还能定义额外的与变量对象相关的信息。
AbstractVO (generic behavior of the variable instantiation process)
║
╠══> GlobalContextVO
║ (VO === this === global)
║
╚══> FunctionContextVO
(VO === AO, object and are added)
接下来对这块内容进行详细介绍。
全局上下文中的变量对象
首先,有必要对全局对象(Global object)作个定义。
全局对象是一个在进入任何执行上下文前就创建出来的对象;此对象以单例形式存在;它的属性在程序任何地方都可以直接访问,其生命周期随着程序的结束而终止。
全局对象在创建的时候,诸如Math,String,Date,parseInt等等属性也会被初始化,同时,其中一些对象会指向全局对象本身——比如,DOM中,全局对象上的window属性就指向了全局对象(但是,并非所有的实现都是如此):
global = {
Math: ,
String:
...
...
window: global
};
在引用全局对象的属性时,前缀通常可以省略,因为全局对象是不能通过名字直接访问的。然而,通过全局对象上的this值,以及通过如DOM中的window对象这样递归引用的方式都可以访问到全局对象:
String(10); // 等同于 global.String(10);
// 带前缀
window.a = 10; // === global.window.a = 10 === global.a = 10;
this.b = 20; // global.b = 20;
回到全局上下文的变量对象上——这里变量对象就是全局对象本身:
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(functionContext) === AO;
活跃对象会在进入函数上下文的时候创建出来,初始化的时候会创建一个arguments属性,其值就是Arguments对象:
AO = {
arguments:
};
Arguments对象是活跃对象上的属性,它包含了如下属性:
- callee —— 对当前函数的引用
- length —— 实参的个数
- properties-indexes(数字,转换成字符串)其值是函数参数的值(参数列表中,从左到右)。properties-indexes的个数 == arguments.length;
arguments对象的properties-indexes的值和当前(实际传递的)形参是共享的。
如下所示:
function foo(x, y, z) {
// 定义的函数参数(x,y,z)的个数
alert(foo.length); // 3
// 实际传递的参数个数
alert(arguments.length); // 2
// 引用函数自身
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
// 然而,对于没有传递的参数z,
// 相关的arguments对象的index-property是不共享的
z = 40;
alert(arguments[2]); // undefined
arguments[2] = 50;
alert(z); // 40
}
foo(10, 20);
上述例子,在当前的Google Chrome浏览器中有个bug——参数z和arguments[2]也是互相共享的。
处理上下文代码的几个阶段
至此,也就到了本文最核心的部分了。处理执行上下文代码分为两个阶段:
- 进入执行上下文
- 执行代码
对变量对象的修改和这两个阶段密切相关。
要注意的是,这两个处理阶段是通用的行为,与上下文类型无关(不管是全局上下文还是函数上下文都是一致的)。
进入执行上下文
一旦进入执行上下文(在执行代码之前),VO就会被一些属性填充(在此前已经描述过了):
- 函数的形参(当进入函数执行上下文时)
—— 变量对象的一个属性,其属性名就是形参的名字,其值就是实参的值;对于没有传递的参数,其值为undefined - 函数声明(FunctionDeclaration, FD) —— 变量对象的一个属性,其属性名和值都是函数对象创建出来的;如果变量对象已经包含了相同名字的属性,则替换它的值
- 变量声明(var,VariableDeclaration) —— 变量对象的一个属性,其属性名即为变量名,其值为undefined;如果变量名和已经声明的函数名或者函数的参数名相同,则不会影响已经存在的属性。
看下面这个例子:
function test(a, b) {
var c = 10;
function d() {}
var e = function _e() {};
(function x() {});
}
test(10); // call
当以10为参数进入“test”函数上下文的时候,对应的AO如下所示:
AO(test) = {
a: 10,
b: undefined,
c: undefined,
d: <reference to FunctionDeclaration "d">
e: undefined
};
注意了,上面的AO并不包含函数“x”。这是因为这里的“x”并不是函数声明而是函数表达式(FunctionExpression,简称FE),函数表达式不会对VO造成影响。 尽管函数“_e”也是函数表达式,然而,正如我们所看到的,由于它被赋值给了变量“e”,因此它可以通过“e”来访问到。关于函数声明和函数表达式的区别会在第五章——函数作具体介绍。
至此,处理上下文代码的第一阶段介绍完了,接下来介绍第二阶段——执行代码阶段。
执行代码
此时,AO/VO的属性已经填充好了。(尽管,大部分属性都还没有赋予真正的值,都只是初始化时候的undefined值)。
继续以上一例子为例,到了执行代码阶段,AO/VO就会修改成为如下形式:
AO[‘c‘] = 10;
AO[‘e‘] = ;
再次注意到,这里函数表达式“_e”仍在内存中,这是因为它被保存在声明的变量“e”中,而同样是函数表达式的“x”却不在AO/VO中: 如果尝试在定义前或者定义后调用“x”函数,这时会发生“x为定义”的错误。未保存的函数表达式只有在定义或者递归时才能调用。
如下是更加典型的例子:
alert(x); // function
var x = 10;
alert(x); // 10
x = 20;
function x() {};
alert(x); // 20
上述例子中,为何“x”打印出来是函数呢?为何在声明前就可以访问到?又为何不是10或 者20呢?原因在于,根据规则——在进入上下文的时候,VO会被填充函数声明; 同一阶段,还有变量声明“x”,但是,正如此前提到的,变量声明是在函数声明和函数形参之后,并且,变量声明不会对已经存在的同样名字的函数声明和函数形 参发生冲突, 因此,在进入上下文的阶段,VO填充为如下形式:
VO = {};
VO[‘x‘] = <引用了函数声明“x”>
// 发现var x = 10;
// 如果函数“x”还未定义
// 则 "x" 为undefined, 但是,在我们的例子中
// 变量声明并不会影响同名的函数值
VO[‘x‘] = <值不受影响,仍是函数>
随后,在执行代码阶段,VO被修改为如下所示:
VO[‘x‘] = 10;
VO[‘x‘] = 20;
正如在第二个和第三个alert显示的那样。
如下例子再次看到在进入上下文阶段,变量存储在VO中(因此,尽管else的代码块永远都不会执行到,而“b”却仍然在VO中):
if (true) {
var a = 1;
} else {
var b = 2;
}
alert(a); // 1
alert(b); // undefined, but not "b is not defined"
关于变量
大多数讲JavaScript的文章甚至是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的修改(进入上下文阶段和执行代码阶段):
进入上下文:
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
这里关于变量还有非常重要的一点:与简单属性不同的是,变量是不能删除的{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”执行上下文中,是可以删除变量的:
eval(‘var a = 10;‘);
alert(window.a); // 10
alert(delete a); // true
alert(window.a); // undefined
利用某些debug工具,在终端测试过这些例子的童鞋要注意了:其中Firebug也是使用了eval来执行终端的代码。因此,这个时候var也是可以删除的。
实现层的特性:__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__属性设置为全局上下文的变量对象,比如说:全局对象。
然而,在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
})();
总结
本文,我们介绍了与执行上下文相关的对象。希望,本文能够对大家有所帮助,同时也希望本文能够起到解惑的作用。
扩展阅读
安装node后node的js代码都跑哪里去了
Node的源代码有两部分组成:
1. C/C++代码(包括Google的V8引擎代码): 位于src目录下
2. JavaScript代码:node.js位于src目录下,其余全部位于lib目录下,而且数量很多
但是,奇怪的是,按照node wiki上的安装文档安装好Node之后,却发现在安装目录中并没有任何JavaScript文件,于是我就很想知道为什么(我犯贱)
因为node运行的时候这些js代码都是需要的,而且基本是node的核心代码,
所以大致猜测:JS文件在make的过程中可能被翻译然后合并到C代码中一同编译成了二进制代码
通过粗略地看了下node的几个和安装相关的脚本文件后,大概知道了真正的原因,如下:
首先,回忆下node的安装过程:
1. ./configure —prefex=node_install_path: 配置node安装,指定安装目录
2. make:编译代码
3. make install: 安装
那么,按照这个顺序首先去看下configure文件,发现其代码非常少:
if [ ! -z "`echo $CC | grep ccache`" ]; then
echo "Error: V8 doesn‘t like ccache. Please set your CC env var to ‘gcc‘"
echo " (ba)sh: export CC=gcc"
exit 1
fi
CUR_DIR=$PWD
#possible relative path
WORKINGDIR=`dirname $0`
cd "$WORKINGDIR"
#abs path
WORKINGDIR=`pwd`
cd "$CUR_DIR"
"${WORKINGDIR}/tools/waf-light" --jobs=1 configure $*
exit $?
没啥发现,继续去看make执行的makefile,makefile里面一堆代码,没怎么看懂,感觉没啥发现
这个时候,心想这个思路不好,不一定能够找到原因,于是决定换个思路:
既然JavaScript文件都是在lib目录下,那直接搜索lib看看哪些文件用到了,这个方法果然奏效,搜索发现根目录下的node.gyp文件非常可疑,
其文件内容就感觉是个build文件,其中有如下这段代码:
‘library_files‘: [
‘src/node.js‘,
‘lib/_debugger.js‘,
‘lib/_linklist.js‘,
‘lib/assert.js‘,
‘lib/buffer.js‘,
...
这里就是把所有JavaScript文件都罗列出来了,这时已经看到光明了,在此文件中找library_files这个变量,又发现如下代码:
‘actions‘: [
{
‘action_name‘: ‘node_js2c‘,
‘inputs‘: [
‘./tools/js2c.py‘,
‘<@(library_files)‘,
],
‘outputs‘: [
‘<(SHARED_INTERMEDIATE_DIR)/node_natives.h‘,
],
...
猜测就是一个build任务,其输入源就是这些JS文件,输出是一个C的头文件,这里有个文件极其可疑./tools/js2c.py,继续追看该文件
终于找到真凶了:
# This is a utility for converting JavaScript source code into C-style
# char arrays. It is used for embedded JavaScript code in the V8
# library.
这三行注释就大致等于告诉我们了,node在安装过程中会把JavaScript代码翻译成C语言风格的字符数组,在V8引擎中运行,这下终于豁然开朗了。
说明:以上过程纯属自己YY,仅供大家一起YY。
执行上下文(Execution Context)
说明
此文译自Dmitry A.Soshnikov 的文章Execution Context
概要
本文将向大家介绍ECMAScript的执行上下文以及相关的可执行代码类型。
定义
每当控制器到达ECMAScript可执行代码的时候,控制器就进入了一个执行上下文。
执行上下文(简称:EC)是个抽象的概念,ECMA-262标准中用它来区分不同类型的可执行代码。
标准中并没有从技术实现的角度来定义执行上下文的具体结构和类型;这是实现标准的ECMAScript引擎所要考虑的问题。
一系列活动的执行上下文从逻辑上形成一个栈。栈底总是全局上下文,栈顶是当前(活动的)执行上下文。当在不同的执行上下文间切换(退出的而进入新的执行上下文)的时候,栈会被修改(通过压栈或者退栈的形式)。
可执行代码类型
可执行代码类型和执行上下文相关。有的时候,当提到代码类型的时候,其实就是在说执行上下文。
举个例子,我们将执行上下文的栈以数组的形式来表示:
ECStask = [ ];
每次控制器进入一个函数(哪怕该函数被递归调用或者作为构造器),都会发生压栈的操作。内置eval函数工作的时候也不例外。
全局代码
这类代码是在“程序”级别上被处理的:比如,加载一个外部的js文件或者内联的js代码(被包含在<script></script>标签内)。全局代码不包含任何函数体内的代码。
在初始化的时候(程序开始),ECStack如下所示:
ECStack = [
globalContext
];
函数代码
一旦控制器进入函数代码(各类函数),就会有新的元素会被压栈到ECStack。要注意的是:实体函数代码并不包括内部函数的代码。如下所示,我们调用一个函数,该函数递归调用自己一次:
(function foo(bar){
if (bar){
return;
}
foo(true);
})();
之后,ECStack就被修改成如下所示:
//首先激活foo函数
ECStack = [
functionContext
globalContext
];
//递归激活foo函数
ECStack = [
functionContext - recursively
functionContext
globalContext
];
每次函数返回,退出当前活动的执行上下文时,ECStack就会被执行对应的退栈操作——先进后出——和传统的栈实 现一致。同样的,当抛出未捕获的 异常时,也会退出一个或者多个执行上下文,ECStack也会做相应的退栈操作。待这些代码完成之后,ECStack中就只剩下一个执行上下文 (globalContext)——直到整个程序结束。
Eval代码
说到eval代码就比较有意思了。这里要提到一个叫做调用上下文的概念,比如:调用eval函数时候的上下文,就是一个调用上下文,eval函数中执行的动作(例如:变量声明或者函数声明)会影响整个调用上下文:
eval(‘var x = 10’);
(function foo(){
eval(‘ var y = 20’);
})();
alert(x); // 10
alert(y); // ”y” is not defined
ECStack会被修改为:
ECStack = [
globalContext
];
//eval(‘var x = 10’);
ECStack.push(
evalContext,
callingContext: globalContext
);
// eval exited context
ECStack.pop();
//foo function call
ECStack.push( functionContext);
//eval(‘ var y = 20’);
ECStack.push(
evalContext,
callingContext: functionContext
);
//return from eval
ECStack.pop();
//return from foo
ECStack.pop();
在1.7以上版本SpiderMonkey的实现中 (Firefox,Thunderbird浏览器内置的JS引擎),允许在调用eval函数的时 候,将调用上下文作为第二个参数传递给eval函数。因此,如果传入的调用上下文存在的话,就有可能会影响该上下文中原有的私有变量(在该上下文中声明的 变量):
function foo(){
var x = 1;
return function() { alert(x); }
};
var bar = foo();
bar(); // 1
eval(‘x = 2’, bar); //传递上下文,影响了内部变量“var x”
bar(); // 2
总结
这些基本理论对于后面执行上下文相关的细节(诸如变量对象、作用域链等等)分析是非常必要的。
扩展阅读
ECMA-363-3标准文档的对应的章节—— 10. 执行上下文
npm中本地安装命令行类型的模块是不注册Path的
首先有必要解释下什么是命令行(Command Line)类型的模块。
npm的模块一共分为三类:
1. 绑定型(Binding):本地模块,C++书写,如node-png
2. 库型(Library):JS书写,直接使用require(‘module’)这种方式,如Socket.IO-node
3. 命令行型(Command Line): 以命令行形式使用,如json-command
显而易见,提供命令行形式调用方式的模块就属于命令行型的。那么如何来申明自己的模块以什么命令调用,调用执行代码又如何制定呢?
这里就要说到npm模块的核心文件:package.json,此文件是npm模块的配置信息(npm help json可以获取详细信息)。
其中有一个key就叫“bin”,具体形式为“{ “bin”: {“command-name” : “command-file”}}”,意思是申明用户可以直接使用“command-name”这个命令,而输入该命令后就会去执行“command-file”这个文件。
比如json-command这个模块,安装好后就可以看到它的package.json文件中bin配置项为“{“bin” : {“json” : “./bin/json.js”}}”。这就申明了命令行名字为“json”,对应地会去执行“bin目录下的json.js”这个文件。
其次还要解释下npm中安装模块的两种模式:
1. 全局模式: npm -g install module-name,这种模式模块会被安装在node安装记录的lib所在目录的node_modules文件夹中,全局使用
2. 本地模式: npm install module-name,这种模式模块只会被安装在当前目录的node_modules文件夹中,非全局使用
理解了什么叫“命令行型的模块”和“npm模块的安装模式”之后,再来看看什么叫“npm中本地安装命令行类型的模块是不注册Path的”?
这句话的意思是说:比如像json-command这样的命令行类型的模块,如果是“全局安装”,安装成功之后,就可以直接使用“json”的命令了,而如果是本地模式安装,则没法使用“json”命令。只能手动找到执行文件,然后调用./command-file这样调用。很是不爽!
后来仔细想了下,npm这样设计也是有道理的,因为命令行的模块,通过命令行使用,一般也都会认为是全局的,就像压缩文件,合并文件之类的命令,一 般是不会随着应用的发布而发布的,都只是开发过程中的中间工具。所以,npm对这种类型的模块只有当全局安装的时候才会可以直接使用命令。
那么,“npm到底是如何实现全局直接使用,而本地就不能使用的呢?”
通过查看npm的源码,稍作调试就能发现其中的奥妙了:
- 通过npm的package.json配置文件就能知道npm的命令行入口是“bin/npm.js”
- npm.js有会去调用“模块根目录的npm.js”,并会调用相关的命令,依据来自如下代码:
npm = require("../npm")
npm.load(conf, function (er) {
if (er) return errorHandler(er)
npm.commands[npm.command](npm.argv, errorHandler)
})
- “npm.commands…(npm.argv,errorHandler)”是一行典型的command模式代码,npm.js中的commands对象肯定维护了所有command的列表。依据来自如下代码:
var cmd = require(__dirname+"/lib/"+a+".js")
- 那么install命令就会去调用“lib/install.js”的install方法,install方法会去做一些模块查询,模块依赖关系处理,模块下载,模块安装等工作,之后会去区分本地模式和全局模式,依据来自如下代码:
var fn = npm.config.get("global") ? installMany : installManyTop
- 之后会去调用lib/build.js中的build函数,依据来自于如下代码:
npm.commands.build([where], false, true, function (er) {
return cb_(er, d)
})
- 然后,再看看build函数的申明中,其第二个参数正是表示模块是否全局安装,依据来自如下代码:
function build (args, global, didPre, didRB, cb) {
- 最后,global参数被赋予给gtop变量,最后判断是否全局,如果是则注册Path否则直接调用回调函数返回,依据来自如下代码:
if (er || !gtop) return cb(er)
var dest = path.resolve(binRoot, b)
, src = path.resolve(folder, pkg.bin[b])
, out = npm.config.get("parseable")
? dest + "::" + src + ":BINFILE"
: dest + " -> " + src
这里很清楚,如果“!gtop”,即本地模式就直接调用cb并返回,否则,就去注册Path,其中“dest->src”会发现很眼熟,通常全局安装好一个命令行模块之后,都会显示这行log。这行代码其实就做了如下这件事情:
将命令执行文件json.js link到node/bin/json命令中
其中node的bin路径命令已经被加入到PATH中了,这样输入json的时候,系统自动去PATH中查找对应的执行文件,这样就能够找到了!
参考文献
* http://howtonode.org/how-to-module
* https://github.com/isaacs/npm
原文地址:http://www.360doc.com/content/13/1105/19/9200790_326857427.shtml
以上是关于JavaScript内部原理实践——真的懂JavaScript吗?(转)的主要内容,如果未能解决你的问题,请参考以下文章