Chapter2: Lexical Scope
在第一章中,我们定义了“作用域”是指一套规定引擎根据名字查找变量的规则,在当前作用域中查找或者在任意内嵌作用域中查找。
作用域的工作方式有两种主要的模型。第一种是目前应用最普遍,被大多数编程语言所使用的,叫Lexical Scope,我们将会更深入的讨论他。另一种模型被一些语言(如Basic, Perl中的模块等)语言使用,被称作Dynamic Scope。
Dynamic Scope在附录A中有说明。这里我们只对Lexical Scope做解释,他也是被Javascript使用的模型。
Lex-Time
在第一章中我们讨论过,一个标准语言编译器的第一个阶段是词法分析(aka,tokening)。他用来检查源代码中的一个字符串,根据语义赋值给Token从而完成字符解析。
这个定义为理解Lexical Scope提供了基础,并解释了他的命名来源。
Lexical Scope是一个定义在Lexing时段的作用域。换句话说,在你写程序的时候,Lexical Scope基于作用域中变量和块的签名位置,因此他是在词法分析过程中被引入进来。
注意:通过在词法分析之后去修改Lexical Scope可以做一些欺骗操作,但这并不鼓励去做。最好的做法是把Lexical Scope看成仅为分析词法的工程。
看一下下面的代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12
上面定义了3个内嵌作用域。可以把他们想成套在一起的气泡。
Bubble1: 包括全局作用域,只有一个变量:foo
Bubble2: foo的作用域, 有3个变量,a,bar和b。
Bubble3: bar的作用域,包括一个变量,c。
气泡的范围定义就是代码的块定义,一个一个嵌套在一起。下一章我会讨论作用域的不用单元,但现在假定一个方法会创建一个新的作用域气泡。
包含bar的气泡被完全包含在foo的气泡里,因为这是我们选择定义bar方法的位置。
值得注意的是这些嵌套气泡是严格的内嵌在一起。这和维恩图有些区别,他的气泡是交叉在一起的。换句话说,没有一个气泡可以部分的存在于两个大气泡内,正如没有方法可以被定义在两个父方法中一样。
查找
这些气泡的结构和相对位置告诉引擎查找变量的所有位置。
在上面的代码中,引擎执行console.log(...) 时会查找a,b和c这三个引用变量。首先他开始在最内部的气泡中查找,bar()方法的作用域内。在这里没有找到a,他会向一级查找,在最近的气泡内foo(...)中。这里他找到了a,引擎将使用这个a引用。对于b会有相似的查找过程。而c会直接在bar中找到。
c出现在bar(...)和foo(...)内,console.log(...)会使用bar(...)中的c,而不会去foo(...)中查找。
作用域当找到了第一个匹配的引用就停止继续查找。同名的变量可以定义在多个不同的内嵌作用域中,这种情况叫做“shadowing” (内部变量覆盖了外部的同名变量)。作用域查找过程始终会是从最内部的作用域开始,然后向外查找知道找到该变量。
注意:全局变量也是全局对象的属性(如浏览器中的window对象),所以引用一个全局变量可以不直接通过他的名字,而是通过全局对象的属性。
window.a
词法作用域查找过程只针对最外部的变量,如:a,b和c。如果你在代码里引用了foo.bar.baz,词法作用域会找foo对象,一旦发现这个变量,就会使用对象属性访问规则去分别解析bar和baz属性。
欺骗词法分析
如果词法作用域是在函数声明时定义的,函数声明是在程序员写代码时声明的。那么我们如何在运行时改变词法作用域呢?
JavaScript对此有两种机制。但他们都是在外界不提倡的使用方式。但是对他们的争论通常会忽略最重要的一点:改变词法作用域会导致性能下降。
在解释性能问题之前,让我们来看看这两种机制是如何工作的。
eval
JavaScript中的eval() 方法接收一个字符串参数并把这个字符串当成是编译阶段的代码。换句话说,你可以在程序运行时动态生成代码并执行,就好像他们在编译期间创建的一样。
eval()函数很明确的允许你修改词法作用域环境并把他们当成是编译时期的代码。
在执行eval()后面的语句时,引擎不会知道也不会关心之前问题中的代码是动态解析并修改了词法作用域环境。引擎会像以前一样对代码进行查找。
看下面的代码:
function foo(str, a) {
eval( str ); // cheating!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3
在调用eval()方法时传进的代码 var b=3; 是关键所在。因为这行代码声明了一个新变量b,从而修改了foo()函数的词法作用域。就像上面描述的那样,这句代码在foo()作用域内创建了变量b,这个变量覆盖了外部作用域中的b。
当执行console.log(...)时,他会在foo(..)作用域内找到a和b,从而不会引用外部作用域中的b。所以,程序输出“1,3”, 而不是正常情况下的“1,2”。
注意:为了简单起见,这个例子使用了固定的字符串。但他可以很容易的根据程序逻辑生成字符串。eval(..)通常用来执行动态生成的代码,直接执行静态字符串代码没有什么意义。
如果eval()执行的一个字符串代码包括声明多个变量或函数,这个行为会修改当前包含eval()的作用域。通过各种技巧(例如上面讨论的那样)eval()可以被间接的调用,这将导致执行全局作用域中的上下文。不论何种情况,eval()可以在运行时修改词法作用域。
注意:当eval(..)用在严格模式的程序中时,修改他内部的词法作用域,也就是说在eval() 内部声明变量不会修改作用域。
function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
在JavaScript中还有类似eval(..)的其他方法。setTimeout(..) 和 setInterval(..) 可以接收一个字符串作为第一个参数,字符串的内容杯执看作是动态生成的算法。这是老式的做法,已经很长时间被反对了,请不要这样写代码。
在程序中动态生成代码的场景很少,在考虑性能方面时不要做这样的尝试。
with
在JavaScript中另一个可以欺骗词法作用域的关键字是with。解释with的方法有很多种,这里我只从影响作用域的角度出发来解释他的执行。
with通常被用在为一个对象创建多个属性而不需要每次重复对象引用。
例如:
var obj = {
a: 1,
b: 2,
c: 3
};
// more "tedious" to repeat "obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// "easier" short-hand
with (obj) {
a = 3;
b = 4;
c = 5;
}
然而,除了可以为对象创建多个属性之外还有其他的作用。例如:
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 -- Oops, leaked global!
在上面的例子中,创建了o1和o2两个对象。一个有属性a,另一个没有。foo()方法接收一个对象引用作为参数,并调用 with(obj) {..}。在with里面,出现了引用变量a的常用场景,一个LHS引用(见第一章),并把2赋值给他。
当我们传入o1时,赋值语句a=2找到了 o1.a 并把2赋值给他,可以看到console.log(o1.a)的输出结果。然而,当我们传入o2, 而它没有a属性,o2.a 仍然是未定义。
但是我们发现了一个错误场景,一个全局变量a被a=2语句创建出来。怎么会这样呢?
with语句拿到一个有0个或多个属性的对象,并把这个对象当成是一个在隔绝作用域中的对象,因此对象属性被看作是定义在这个作用域中的。
注意:尽管with语句将对象看作是一个独立的作用域中,但是一个正常的var声明变量操作并不会在这个作用域中运行,而是在with所包含的函数作用域中运行。
eval(..) 函数在传入一个包含变量声明语句的字符串时会修改当前作用,而with语句会创建一个新的作用域。
当我们向with 中传入o1,with语句的作用域是o1,并且这个作用域有一个于o1.a属性相关的身份。但是当我们用o2作为作用域时,它没有a的身份,所以正常的LHS规则会生效(见第一章)。
o2的作用域,foo()的作用域以及全局作用域都没有a的定义,所以当a=2被执行时,他是在全局范围内被创建出来(因为我们在非严格模式执行)。
看到with如何在运行时将一个对象和属性放进带身份的作用域是有点怪,但是这是一个我们能看到的很清晰的结果。
注意:除了引用一个比较不好的场景外,eval()和with()被严格模式所影响。with是被完全不建议使用,eval()在保留核心功能外不建议用做其他场景。
性能
eval()和with 都会在运行时期修改编译时期定义的作用域。
那么,这会有什么问题吗?如果他们提供了更加基础的功能和代码的灵活度,难道这不是一个好事情吗?不。
JavaScript引擎有很多在编译时期的性能优化机制。其中一些方法在静态分析代码的过程用执行并提前决定变量和方法声明的位置,所以在运行时期去解析变量的工作就会很轻。
换言之,极端情况下,如果遇到eval()或with,大部分优化的结果会被认为是无效的,那么引擎会不做任何优化。
你的代码会运行的很慢紧紧是因为你在代码里写了eval()或with语句。不论引擎在处理这种情况下有多么智能,它也不能回避代码在没有优化的情况下运行变慢的情况。
回顾:
词法做用户是定义在编译阶段用来决定方法声明的位置。在编译阶段词法分析对于变量的定位很重要,可以用来预计执行时如何查找变量的位置。
两种JavaScript语法可以修改词法作用域:eval() 和 with。前者能够传入一个包括变量声明的字符串语句,从而修改当前作用域(在运行时)。后者会把一个对象引用当成一个全新的作用域并创建出来,其中包括对象的属性定义。
这些机制的不足是对引擎在编译时期对作用域查找优化的机制造成危害。因为引擎不得不假定之前的优化是无效的。代码会由于使用了任何一种上面提到的方法而变慢。所以,不要使用它们。