了解了JS引擎的工作方式之后,这一节,我们先通过一些代码更具体的认识JavaScript中的作用域。然后,来看两个欺骗JS作用域的方法,了解它们的目的并不是鼓励我们使用这些技术。而是,当你了解这些方法的复杂性之后,才会更加认同我们提出的观点:尽可能避免去使用它们。
由词法确定的作用域
首先,来看下面的代码:
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(a, b, c);
}
bar(b * 3);
}
foo(2);
在这个例子中,存在几个作用域呢?我们在注释中标记出了作用域的边界:
/* Global scope */
function foo(a) {
/* Begin foo scope */
var b = a * 2;
function bar(c) {
/* Begin bar scope */
console.log(a, b, c);
/* End bar scope */
}
bar(b * 3);
/* End foo scope*/
}
foo(2);
这里要说明的是,函数的参数是属于对应函数作用域的。
于是:
- 在Global scope中,只有一个符号,就是函数
foo
; - 在Foo scope中,有符号
a
,b
和bar
; - 在Bar scope中,只有一个符号
c
;
在JS里,作用域的嵌套关系一定是完整的。绝不可能存在一个作用域同时被两个不相关的作用域包围。换句话说,也不可能存在一个跨越两个作用域的函数。
并且,在上一节我们也提到了,对符号的查询,是从当前作用域逐步向外层作用域进行的。例如,在bar
的作用域内,当JS引擎查询符号a/b/c
的时候:它可以直接在bar
的作用域内,找到c
,在foo
的作用域里找到a
和b
。并且,一旦引擎找到了匹配,就不会再到外层作用域中继续查找了。
这里,还有一点要说明的是,符号的逐级向上查询的规则,对类对象的属性,以及属性的属性这样的符号,是不生效的。它只对诸如a/b/c
这样的一级符号生效。
另外,如果我们的JS代码在浏览器里执行,在全局空间中定义的变量,还可以通过window
的属性来访问。通过这种方式,我们可以避免同名局部变量覆盖掉全局变量的情况:
var a = 1;
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(window.a); // Only works in browser
console.log(a, b, c);
}
bar(b * 3);
}
最后,要再次强调一点的是,函数的作用域是静态的,是和词法相关的概念,当我们编写好一个函数之后,这个函数的作用域就已经完成确定了。这与函数在什么地方被调用没有任何关系。
如何欺骗JS的词法作用域
尽管作用域是一个静态的概念,JS也提供了一些方法,允许我们在运行时动态修改它。但这些方法在JS社区中的口碑并不好,稍后我们就看到,这些方法不仅不容易用对,还会显著降低JS代码的执行性能。
总之,一句话:理解它们,是为了远离它们。
使用eval
第一种方法,是调用eval
函数,它接受一个字符串参数。JS引擎会把它的参数直接作为JS代码放在调用eval
的位置,这样我们就可以在运行时,通过eval
向特定的作用域安插任何内容了,也就达到了我们修改某个作用域的效果。来看下面这个例子:
function foo(a) {
console.log(a, b);
}
var b = 2;
foo(1);
执行一下,就会看到,结果是1和2,此时log
中访问的b
,是全局作用域中的变量。接下来,我们修改一下foo
,让它接受另外一个参数,允许我们向它的作用域内,插入任意代码:
function foo(str, a) {
eval(str);
console.log(a, b);
}
var b = 2;
foo("var b = 3;", 1);
现在,重新执行一下,结果就变成1和3了。JS引擎会直接在eval
调用的地方,插入代码var b = 3;
,这样,foo
的作用域里,就多了一个变量b
。于是,调用log
时,b
的RHS查询就会直接使用当前作用域中的版本,而不会再到外围作用域中查找了。
但是,当eval
工作在strict
模式的时候,插入的代码会带有自己的作用域,而不会污染到它所在的外围作用域,因此下面的代码,会发生ReferenceError
:
function foo(str, a) {
"use strict";
eval(str);
console.log(a, b); // ReferenceError: b is not defined.
}
foo("var b = 3;", 1);
另外,除了eval
之外,JS中还有一些允许我们以字符串的形式在运行时执行代码函数,例如setInterval
,Function
等等,尽管它们的工作方式略有差异,但是我们的态度是相同的:记着,不要使用这种会修改函数作用域的方式使用它们。
with
第二个会在运行时修改作用域方法,是通过with
关键字。和eval
不同的是,with
对作用域的修改是间接的,这并不是with
的核心功能。最初,我们只是使用它避免编写重复类对象而已:
var obj = {
a: 1,
b: 2,
c: 3
};
obj.a = 1;
obj.b = 2;
obj.c = 3;
在上面这个例子中,为了避免反复使用obj
访问它的属性,我们就可以这样:
with(obj) {
a = 4;
b = 5;
c = 6;
}
console.log(obj);
这时,obj
的值就变成{ a: 4, b: 5, c: 6 }
了。但事情并没有想象的这么简单,一些IDE甚至会在你使用with
的时候给你一个提示,告诉你with
会带来一些容易让人困惑的结果。我们先来看下面这个例子。
首先,定义一个函数:
function foo(obj) {
with(obj) {
a = 2;
}
}
var o1 = { a: 1; }
var o2 = { b: 1; }
其次,分别把o1
和o2
传递给它:
foo(o1);
console.log(o1.a); // 2
foo(o2);
console.log(o2.a); // undefined
直到现在,所有的结果都还如我们预期,o1.a
是2,o2.a
是undefined。但是,当我们接下来打印全局变量a
的时候,你认为是什么结果呢?
console.log(a);
你可能会觉得,当然也是undefined
啊,全局作用域里哪来的变量a
。但如果你实际执行下就会发现,结果是2。很奇怪对不对?而这,就是with
的功劳。
简单来说,with
接受一个对象参数,并且会为这个对象创建一个自己独立的作用域。因此,给它传递o1
的时候,由于o1
包含属性a
,我们就修改了o1.a
的值;当我们给它传递o2
的时候,由于o2
中并不存在属性a
,我们会得到一个undefined
。
但是,这里with
还带来了一个副作用,由于with
给o2
创建了一个独立的作用域,当JS引擎在这个作用域里执行a = 2
的时候,会对变量a
进行LHS查询,显然,当前作用域里没有a
,上一级作用域,也就是foo
中,也没有,于是按照我们上一节提到过的规则,JS引擎会在全局作用域自动创建一个变量a
。因此,我们在全局作用域执行console.log(a);
的时候,就会看到2,而不是undefined了。
看到这,你是不是觉得已经足够让你意外了。但故事还没结束,在with
创建的独立作用域里,还有一个特例:用var
定义的变量,并不属于这个作用域。于是,看下面的代码:
function foo(obj) {
with(obj) {
a = 2;
var c = 10;
}
console.log(c);
}
var o1 = {
a: 1
};
foo(o1);
console.log(o1.a);
这次,在with
的作用域里,我们用var
定义了变量c
。就像我们刚才说过的,c
并不属于with
作用域,而是属于它的外层作用域,也就是foo
,因此,我们可以在foo
里,直接访问到这个变量。
现在,你应该能彻底理解我们在一开始提到的关于这些修改作用域方法的评价了。即便你可以在大脑清醒的时候用对它们,也很难保证在之后的修改中不引入问题。因此,要说它们最大的用处,应该也就是帮助我们理解作用域这个概念了。
所以,我们的结论就是:记着作用域的概念,然后忘了这些函数吧。
What's next?
以上,就是这一节的内容,通过这些例子,我们应该对JS中的作用域概念有一个比较清楚的认识了。下一节,我们将围绕函数作用域,来看一些JS中的惯用方法,以及容易发生错误的场景。