你不知道的JS之作用域和闭包(二)词法作用域
原文:你不知道的js系列
词法作用域(Lexical Scope)
Lex time
一个标准的编译器的第一个阶段就是分词(token化)
词法作用域就是在词法分析时定义的作用域。换句话说,词法作用域是你在写代码的时候,变量和代码块的位置决定的,因此在词法分析时也是固定不变的了。
注:有一些方法可以欺骗词法作用域,从而在词法分析结束之后,修改词法作用域。但好的实践是避免使用这些方法,让词法作用域只经过词法分析,也就是说完全保持编写时的作用域。
下面这段示例代码有三个嵌套作用域,
圈 1 包含了全局作用域,只有一个标识符 foo
圈 2 包含 foo 作用域,有三个标识符 a,bar,和 b
圈3 包含 bar 作用域,有一个标识符 c
作用域的范围是根据作用域代码块定义的位置决定的,在这里每个函数创建了一个作用域。
bar 的作用域完全包含在 foo 作用域中,因为 bar 是在 foo 中定义的
注:这里的作用域嵌套是严格的, 不存在像 Venn 图那样可以超出外部作用域的嵌套,一个函数不能同时存在于两个外部函数中。
查询
这些作用域的结构和相对位置就解释引擎需要找一个标识符时需要查询的位置。
如果在 bar() 和 foo() 的内部都存在变量 c,console.log() 就首先找到 bar 作用域中的变量,不会找到外部 foo 中的那个。
作用域查询一旦找到第一个匹配的标识符就会终止,对于相同的标识符名称,内部的标识符就会覆盖外部的标识符。不管如何覆盖,作用域查询总是从嵌套的最内层作用域开始查找。
注:全局变量就是全局对象的属性
所以当全局变量被覆盖时,可以通过全局对象进行引用(比如浏览器的 window 对象),被覆盖的非全局对象则无法被访问到了
无论一个函数在哪里被调用,或者如何被调用,它的词法作用域近在这个函数声明时定义。
词法作用域查询只查询第一级标识符,如 a,b 和 c,如果你有类似于 foo.bar.baz 这样的代码,词法作用域查询仅作用在标识符 foo ,一旦查找到这个变量,就会使用对象属性访问规则分别解析 bar 和 baz 属性。
欺骗词法作用域
虽然词法作用域仅在函数声明时定义,但是 JavaScript 有两种机制可以在运行时改变(欺骗)词法作用域,而这种行为会导致更低的性能。
eval
eval() 可以接收一个字符串作为参数,并把字符串的内容当作代码运行。也就是说,调用 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() 被调用的时候,字符串参数被当作真正的代码,这段代码声明了变量 b ,改变了 foo() 的词法作用域。在 foo 的内部创建了变量 b,覆盖了外部全局作用域中定义的变量 b,所以最后输出的结果是 1 3。
注:在这个例子中,我们传入的代码字符串是固定字面量,但是它可以很容易通过字符串拼接动态创建代码。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() 和 setTnterval() 可以接收一个字符串作为第一个参数,这个字符串内容将会被 eval() 成一个动态生成的函数的内部代码。这是一种老旧的早已经被废弃的行为,不要这么做!
函数构造方法 new Function() 也可以在最后一个参数接收一个字符串,然后动态生成函数内部代码(前面的参数将作为新函数的参数),这种语法要比 eval() 安全一些,但也要避免使用。
with
在 JavaScript 中可以欺骗词法作用域的另一个特性就与 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!
在这个示例中, 在 with 代码块中,有一个对变量 a 的 LHS 引用,并且要赋值为 2。
然而当 foo 的参数为 o2 的时候,o2 并没有 a 这个属性,o2.a 就是 undefined。
但是副作用是,有一个全局变量 a 被创建并赋值为 2 了。
with 语句,将对应的对象看作一个独立的词法作用域,因此对象的属性被当作这个作用域里的标识符。
注:即使 with 代码块将对象看作一个词法作用域,在这个块中的 var 声明不会限制在这个块的作用域中,而是包含在外部函数作用域中。
所以在这里对 a 的 LHS 查询一直到全局作用域都没有完成,(非严格模式)就会自动创建一个全局变量。
注:with 语句在严格模式下禁止使用,eval() 则只保留核心功能。
性能
JavaScript 引擎在编译期间会进行各种优化,一些优化其实可以归结为在词法分析阶段,静态分析了代码,预先确定了变量和函数声明的位置,所以在执行期间就可以快速解析标识符。
但是如果引擎在代码中找到一个 eval() 或 with 语句,那么它会假设它知道的标识符位置可能是无效的,因为在词法分析期间它不知道到底给 eval() 传入了什么参数,或者 with 绑定的对象创建的词法作用域的内容。
如果 eval 或者 with 存在,那么大部分优化都将毫无意义,所有引擎干脆不做优化了。没有优化,代码运行起来肯定就非常慢了。
Don't use them.