如果词法作用域是由函数被声明的位置唯一定义的,而且这个位置完全是一个编写时的决定,那么怎么可能有办法在运行时“修改”(也就是,作弊欺骗)词法作用域呢?
JavaScript 有两种这样的机制。在广大的社区中它们都等同地被认为是让人皱眉头的,在你代码中使用它们是一种差劲儿的做法。但是关于它们的常见的争论经常错过了最重要的一点:欺骗词法作用域会导致更低下的性能。
在我讲解性能的问题以前,先让我们看看这两种机制是如何工作的。
eval
JavaScript 中的 eval(..)
函数接收一个字符串作为参数值,并将这个字符串的内容看作是好像它已经被实际编写在程序的那个位置上。换句话说,你可以用编程的方式在你编写好的代码内部生成代码,而且你可以运行这个生成的代码,就好像它在编写时就已经在那里了一样。
如果以这种观点来评价 eval(..)
,那么 eval(..)
是如何允许你修改词法作用域环境应当是很清楚的:欺骗并假装这个编写时(也就是,词法)代码一直就在那里。
在 eval(..)
被执行的后续代码行中,引擎 将不会“知道”或“关心”前面的代码是被动态翻译的,而且因此修改了词法作用域环境。引擎 将会像它一直做的那样,简单地进行词法作用域查询。
考虑如下代码:
function foo(str, a) {
eval( str ); // 作弊!
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(..)
被用于一个操作它自己的词法作用域的 strict 模式程序时,在 eval(..)
内部做出的声明不会实际上修改包围它的作用域。
function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
在 JavaScript 中还有其他的工具拥有与 eval(..)
非常类似的效果。setTimeout(..)
和 setInterval(..)
可以 为它们各自的第一个参数值接收一个字符串,其内容将会被 eval
为一个动态生成的函数的代码。这种老旧的,遗产行为早就被废弃了。别这么做!
new Function(..)
函数构造器类似地为它的 最后 一个参数值接收一个代码字符串,来把它转换为一个动态生成的函数(前面的参数值,如果有的话,将作为新函数的形式参数)。这种函数构造器语法要比 eval(..)
稍稍安全一些,但在你的代码中它仍然应当被避免。
在你的代码中动态生成代码的用例少的不可思议,因为在性能上的倒退使得这种能力几乎总是得不偿失。
with
JavaScript 的另一个使人皱眉头(而且现在被废弃了!),而且可以欺骗词法作用域的特性是 with
关键字。有许多种合法的方式可以讲解 with
,但是我在此选择从它如何与词法作用域互动并影响词法作用域的角度来讲解它。
讲解 with
的常见方式是作为一种缩写,来引用一个对象的多个属性,而 不必 每次都重复对象引用本身。
例如:
var obj = {
a: 1,
b: 2,
c: 3
};
// 重复“obj”显得更“繁冗”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// “更简单”的缩写
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 -- 哦,全局作用域被泄漏了!
在这个代码示例中,创建了两个对象 o1
和 o2
。一个有 a
属性,而另一个没有。foo(..)
函数接收一个对象引用 obj
作为参数值,并在这个引用上调用 with (obj) {..}
。在 with
块儿内部,我们制造了一个变量 a
的看似是普通词法引用的东西,实际上是一个 LHS 引用(见第一章),并将值 2
赋予它。
当我们传入 o1
时,赋值 a = 2
找到属性 o1.a
并赋予它值 2
,正如在后续的 console.log(o1.a)
语句中反映出的那样。然而,当我们传入 o2
,因为它没有 a
属性,没有这样的属性被创建,所以 o2.a
还是 undefined
。
但是之后我们注意到一个特别的副作用,赋值 a = 2
创建了一个全局变量 a
。这怎么可能?
with
语句接收一个对象,这个对象有0个或多个属性,并 将这个对象视为好像它是一个完全隔离的词法作用域,因此这个对象的属性被视为在这个“作用域”中词法定义的标识符。
注意: 尽管一个 with
块儿将一个对象视为一个词法作用域,但是在 with
块儿内部的一个普通 var
声明将不会归于这个 with
块儿的作用域,而是归于包含它的函数作用域。
如果 eval(..)
函数接收一个含有一个或多个声明的代码字符串,它就会修改现存的词法作用域,而 with
语句实际上是从你传递给它的对象中凭空制造了一个 全新的词法作用域。
以这种方式理解的话,当我们传入 o1
时 with
语句声明的“作用域”就是 o1
,而且这个“作用域”拥有一个对应于 o1.a
属性的“标识符”。但当我们使用 o2
作为“作用域”时,它里面没有这样的 a
“标识符”,于是 LHS 标识符查询(见第一章)的普通规则发生了。
“作用域” o2
中没有,foo(..)
的作用域中也没有,甚至连全局作用域中都没有找到标识符 a
,所以当 a = 2
被执行时,其结果就是自动全局变量被创建(因为我们没有在 strict 模式下)。
with
在运行时将一个对象和它的属性转换为一个带有“标识符”的“作用域”,这个奇怪想法有些烧脑。但是对于我们看到的结果来说,这是我能给出的最清晰的解释。
注意: 除了使用它们是个坏主意以外,eval(..)
和 with
都受Strict模式的影响(制约)。with
干脆就不允许使用,而虽然 eval(..)
还保有其核心功能,但各种间接形式的或不安全的 eval(..)
是不允许的。
性能
通过在运行时修改,或创建新的词法作用域,eval(..)
和 with
都可以欺骗编写时定义的词法作用域。
你可能会问,那又有什么大不了的?如果它们提供了更精巧的功能和编码灵活性,那它们不是 好的 特性吗?不。
JavaScript 引擎 在编译阶段期行许多性能优化工作。其中的一些优化原理都归结为实质上在进行词法分析时可以静态地分析代码,并提前决定所有的变量和函数声明都在什么位置,这样在执行期间就可以少花些力气来解析标识符。
但如果 引擎 在代码中发现一个 eval(..)
或 with
,它实质上就不得不 假定 自己知道的所有的标识符的位置可能是无效的,因为它不可能在词法分析时就知道你将会向eval(..)
传递什么样的代码来修改词法作用域,或者你可能会向with
传递的对象有什么样的内容来创建一个新的将被查询的词法作用域。
换句话说,悲观地看,如果 eval(..)
或 with
出现,那么它 将 做的几乎所有的优化都会变得没有意义,所以它就会简单地根本不做任何优化。
你的代码几乎肯定会趋于运行的更慢,只因为你在代码的任何地方引入了一个了 eval(..)
或 with
。无论 引擎 将在努力限制这些悲观臆测的副作用上表现得多么聪明,都没有任何办法可以绕过这个事实:没有优化,代码就运行的更慢。