重温《你不知道的JS》—1-作用域
Jan 5, 2019前端js![](/images/icons/js.png)
本篇内容
- 作用域介绍
- 词法作用域
- 函数作用域和块作用域
重温《你不知道的JS》—1-作用域
1 作用域
1.1 演员表
- 引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程。
- 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活。
- 作用域:引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查
询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
1.2 对话
比如解析var a = 2;
。
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编
译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。
可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内
存,将其命名为 a,然后将值 2 保存进这个变量。”然而,这并不完全正确。
编译器会进行如下处理:
- 1.遇到
var a
,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的
集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作
用域的集合中声明一个新的变量,并命名为 a。 - 2.接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理
a = 2
这个赋值
操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的
变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。
如果引擎最终找到了 a 变量,就会将 2 赋值给它。否则引擎就会举手示意并抛出一个异
常
变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如
果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对
它赋值。
1.3 编译器有话说
编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是
否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查
找结果。
在刚才的例子中,引擎会为变量 a 进行 LHS 查询。另外一个查找的类型叫作 RHS。当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。
讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图
找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋
值操作的右侧”,更准确地说是“非左侧”。
你可以将 RHS 理解成 retrieve his source value(取到它的源值),这意味着“得到某某的
值”。
比如1
console.log(a);
其中对 a 的引用是一个 RHS 引用,因为这里 a 并没有赋予任何值。相应地,需要查找并取
得 a 的值,这样才能将值传递给 console.log(..)。
又比如1
a = 2;
这里对 a 的引用则是 LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为 =
2 这个赋值操作找到一个目标。
LHS 和 RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=
赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最
好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头
(RHS)”。
考虑下面的程序,其中既有 LHS 也有 RHS 引用:1
2
3
4function foo(a) {
console.log(a); // 2
}
foo(2);
这里还有一个容易被忽略却非常重要的细节。
代码中隐式的 a=2 操作可能很容易被你忽略掉。这个操作发生在 2 被当作参数传递给
foo(..) 函数时,2 会被分配给参数 a。为了给参数 a(隐式地)分配值,需要进行一次
LHS 查询。
这里还有对 a 进行的 RHS 引用,并且将得到的值传给了 console.log(..)。console.
log(..) 本身也需要一个引用才能执行,因此会对 console 对象进行 RHS 查询,并且检查
得到的值中是否有一个叫作 log 的方法。
最后,在概念上可以理解为在 LHS 和 RHS 之间通过对值 2 进行交互来将其传递进 log(..)
(通过变量 a 的 RHS 查询)。假设在 log(..) 函数的原生实现中它可以接受参数,在将 2 赋
值给其中第一个(也许叫作 arg1)参数之前,这个参数需要进行 LHS 引用查询。
你可能会倾向于将函数声明 function foo(a) {… 概念化为普通的变量声明
和赋值,比如 var foo、foo = function(a) {…。如果这样理解的话,这
个函数声明将需要进行 LHS 查询。
然而还有一个重要的细微差别,编译器可以在代码生成的同时处理声明和值
的定义,比如在引擎执行代码时,并不会有线程专门用来将一个函数值“分
配给”foo。因此,将函数声明理解成前面讨论的 LHS 查询和赋值的形式并
不合适。
1.4 测试
1 | function foo(a) { |
LHS 3次,RHS 4次。
1.5 作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用
域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,
或抵达最外层的作用域(也就是全局作用域)为止。1
2
3
4
5function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4
1.6 异常
为什么区分 LHS 和 RHS 是一件重要的事情?
因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行
为是不一样的。
1 | function foo(a) { |
第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变
量,因为在任何相关的作用域中都无法找到它。
第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变
量,因为在任何相关的作用域中都无法找到它。
如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError
异常。值得注意的是,ReferenceError 是非常重要的异常类型。
相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,
全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非
“严格模式”下。
“不,这个变量之前并不存在,但是我很热心地帮你创建了一个。”
ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上
有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在
严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询
失败时类似的 ReferenceError 异常。
接下来,如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,
比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的
属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。
ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对
结果的操作是非法或不合理的。
2 词法作用域
大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。回
忆一下,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋
予单词语义。这个概念是理解词法作用域及其名称来历的基础。
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写
代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域
不变(大部分情况下是这样的)。
比如:1
2
3
4
5
6
7
8function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含
的气泡。
2.1 查找
作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息
来查找标识符的位置。
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的
标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,
作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见
第一个匹配的标识符为止。
全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此
可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引
用来对其进行访问。如:window.a
。通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量
如果被遮蔽了,无论如何都无法被访问到。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处
的位置决定。
词法作用域查找只会查找一级标识符,比如 a、b 和 c。如果代码中引用了 foo.bar.baz,
词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会分别接
管对 bar 和 baz 属性的访问。
2.2 欺骗词法
JavaScript 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是
什么好注意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能
下降。
2.2.1 eval
JavaScript 中的 eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书
写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并
运行,就好像代码是写在那个位置的一样。
根据这个原理来理解 eval(..),它是如何通过代码欺骗和假装成书写时(也就是词法期)
代码就在那,来实现修改词法作用域环境的,这个原理就变得清晰易懂了。
在执行 eval(..) 之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插
入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。
如:1
2
3
4
5
6function 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,并遮蔽
了外部(全局)作用域中的同名变量。
当 console.log(..) 被执行时,会在 foo(..) 的内部同时找到 a 和 b,但是永远也无法找到
外部的 b。因此会输出“1, 3”而不是正常情况下会输出的“1, 2”。
默认情况下,如果 eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函
数),就会对 eval(..) 所处的词法作用域进行修改。技术上,通过一些技巧(已经超出我
们的讨论范围)可以间接调用 eval(..) 来使其运行在全局作用域中,并对全局作用域进行
修改。但无论何种情况,eval(..) 都可以在运行期修改书写期的词法作用域。
在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其
中的声明无法修改所在的作用域。
如:1
2
3
4
5
6function foo(str) {
;
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );
JavaScript 中 还 有 其 他 一 些 功 能 效 果 和 eval(..) 很 相 似。setTimeout(..) 和
setInterval(..) 的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的
函数代码。这些功能已经过时且并不被提倡。不要使用它们!
new Function(..) 函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转
化为动态生成的函数(前面的参数是这个新生成的函数的形参)。这种构建函数的语法比
eval(..) 略微安全一些,但也要尽量避免使用。
在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损
失。
2.2.2 with
JavaScript 中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是
with 关键字。可以有很多方法来解释 with,在这里我选择从这个角度来解释它:它如何同
被它所影响的词法作用域进行交互。
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象
本身。
比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15var 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;
}
但实际上这不仅仅是为了方便地访问对象属性。考虑如下代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16function 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——不好,a 被泄漏到全局作用域上了!
这个例子中创建了 o1 和 o2 两个对象。其中一个具有 a 属性,另外一个没有。foo(..) 函
数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {..}。
在 with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个
LHS 引用(查看第 1 章),并将 2 赋值给它。
当我们将 o1 传递进去,a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console.
log(o1.a) 中可以体现。而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性,
o2.a 保持 undefined。
with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对
象的属性也会被处理为定义在这个作用域中的词法标识符。
尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var
声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作
用域中。
eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而
with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
可以这样理解,当我们传递 o1 给 with 时,with 所声明的作用域是 o1,而这个作用域中含
有一个同 o1.a 属性相符的标识符。但当我们将 o2 作为作用域时,其中并没有 a 标识符,
因此进行了正常的 LHS 标识符查找(查看第 1 章)。
o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符 a,因此当 a=2 执行
时,自动创建了一个全局变量(因为是非严格模式)。
with 这种将对象及其属性放进一个作用域并同时分配标识符的行为很让人费解。但为了说
明我们所看到的现象,这是我能给出的最直白的解释了。
另外一个不推荐使用 eval(..) 和 with 的原因是会被严格模式所影响(限
制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用
eval(..) 也被禁止了。
2.2.3 性能
eval(..) 和 with 会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词
法作用域。
你可能会问,那又怎样呢?如果它们能实现更复杂的功能,并且代码更具有扩展性,难道
不是非常好的功能吗?答案是否定的。
JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的
词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到
标识符。
但如果引擎在代码中发现了 eval(..) 或 with,它只能简单地假设关于标识符位置的判断
都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会
如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底
是什么。
最悲观的情况是如果出现了 eval(..) 或 with,所有的优化可能都是无意义的,因此最简
单的做法就是完全不做任何优化。
如果代码中大量使用 eval(..) 或 with,那么运行起来一定会变得非常慢。无论引擎多聪
明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代
码会运行得更慢这个事实。
函数作用域和块作用域
3.1 函数中的作用域
比如:1
2
3
4
5
6
7
8function foo(a) {
var b = 2;
// 一些代码
function bar() {
// ...
}
var c = 3;
}
在这个代码片段中,foo(..) 的作用域气泡中包含了标识符 a、b、c 和 bar。无论标识符
声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域的气
泡。我们将在下一章讨论具体的原理。
bar(..) 拥有自己的作用域气泡。全局作用域也有自己的作用域气泡,它只包含了一个标
识符:foo。
由于标识符 a、b、c 和 bar 都附属于 foo(..) 的作用域气泡,因此无法从 foo(..) 的外部
对它们进行访问。也就是说,这些标识符全都无法从全局作用域中进行访问,因此下面的
代码会导致 ReferenceError 错误:
1 | bar(); // 失败 |
但是,这些标识符(a、b、c、foo 和 bar)在 foo(..) 的内部都是可以被访问的,同样在
bar(..) 内部也可以被访问(假设 bar(..) 内部没有同名的标识符声明)。
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复
用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用
JavaScript 变量可以根据需要改变值类型的“动态”特性。
但与此同时,如果不细心处理那些可以在整个作用域范围内被访问的变量,可能会带来意
想不到的问题。
3.2 隐藏内部实现
对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来
一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际
上就是把这些代码“隐藏”起来了。
实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任
何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的
作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域
来“隐藏”它们。
为什么“隐藏”变量和函数是一个有用的技术?
有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来
的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必
要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。
这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作
用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小
特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确
的代码应该是可以阻止对这些变量或函数进行访问的。
例如:
1 | function doSomething(a) { |
在这个代码片段中,变量 b 和函数 doSomethingElse(..) 应该是 doSomething(..) 内部具体
实现的“私有”内容。给予外部作用域对 b 和 doSomethingElse(..) 的“访问权限”不仅
没有必要,而且可能是“危险”的,因为它们可能被有意或无意地以非预期的方式使用,
从而导致超出了 doSomething(..) 的适用条件。更“合理”的设计会将这些私有的具体内
容隐藏在 doSomething(..) 内部,例如:1
2
3
4
5
6
7
8
9function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15
现在,b 和 doSomethingElse(..) 都无法从外部被访问,而只能被 doSomething(..) 所控制。
功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会
依此进行实现。
规避冲突
“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,
两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致
变量的值被意外覆盖。
如:1
2
3
4
5
6
7
8
9
10function foo() {
function bar(a) {
i = 3; // 修改 for 循环所属作用域中的 i
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 糟糕,无限循环了!
}
}
foo();
bar(..) 内部的赋值表达式 i = 3 意外地覆盖了声明在 foo(..) 内部 for 循环中的 i。在这
个例子中将会导致无限循环,因为 i 被固定设置为 3,永远满足小于 10 这个条件。
bar(..) 内部的赋值操作需要声明一个本地变量来使用,采用任何名字都可以,var i = 3;
就可以满足这个需求(同时会为 i 声明一个前面提到过的“遮蔽变量”)。另外一种方法是
采用一个完全不同的标识符名称,比如 var j = 3;。但是软件设计在某种情况下可能自然
而然地要求使用同样的标识符名称,因此在这种情况下使用作用域来“隐藏”内部声明是
唯一的最佳选择。
- 全局命名空间
变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它
们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象
被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属
性,而不是将自己的标识符暴漏在顶级的词法作用域中。
例如:
1 | var MyReallyCoolLibrary = { |
- 模块管理
另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来
使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器
的机制将库的标识符显式地导入到另外一个特定的作用域中。
显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用
域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域
中,这样可以有效规避掉所有的意外冲突。
因此,只要你愿意,即使不使用任何依赖管理工具也可以实现相同的功效。第 5 章会介绍
模块模式的详细内容。
3.3 函数作用域
我们已经知道,在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐
藏”起来,外部作用域无法访问包装函数内部的任何内容。
例如:1
2
3
4
5
6
7var a = 2;
function foo() { // <-- 添加这一行
var a = 3;
console.log( a ); // 3
} // <-- 以及这一行
foo(); // <-- 以及这一行
console.log( a ); // 2
虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。首先,
必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域(在这个
例子中是全局作用域)。其次,必须显式地通过函数名(foo())调用这个函数才能运行其
中的代码。
如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,
这将会更加理想。
幸好,JavaScript 提供了能够同时解决这两个问题的方案、
1 | var a = 2; |
接下来我们分别介绍这里发生的事情。
首先,包装函数的声明以 (function… 而不仅是以 function… 开始。尽管看上去这并不
是一个很显眼的细节,但实际上却是非常重要的区别。函数会被当作函数表达式而不是一
个标准的函数声明来处理
区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位
置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中
的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
比较一下前面两个代码片段。第一个片段中 foo 被绑定在所在作用域中,可以直接通过
foo() 来调用它。第二个片段中 foo 被绑定在函数表达式自身的函数中而不是所在作用域中。
换句话说,(function foo(){ .. }) 作为函数表达式意味着 foo 只能在 .. 所代表的位置中
被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作
用域。
3.3.1 匿名和具名
对于函数表达式你最熟悉的场景可能就是回调参数了,比如:1
2
3setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );
这叫作匿名函数表达式,因为 function().. 没有名称标识符。函数表达式可以是匿名的,
而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。
匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但是
它也有几个缺点需要考虑。
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,
比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑
自身。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,
- 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让
代码不言自明。
- 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让
行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函
数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:
1 | setTimeout( function timeoutHandler() { // <-- 快看,我有名字了! |
3.3.2 立即执行函数表达式
1 | var a = 2; |
由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个
( ) 可以立即执行这个函数,比如 (function foo(){ .. })()。第一个 ( ) 将函数变成表
达式,第二个 ( ) 执行了这个函数。
这种模式很常见,几年前社区给它规定了一个术语:IIFE,代表立即执行函数表达式
(Immediately Invoked Function Expression);
函数名对 IIFE 当然不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式。虽然使
用具名函数的 IIFE 并不常见,但它具有上述匿名函数表达式的所有优势,因此也是一个值
得推广的实践。
1 | var a = 2; |
相较于传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }())。仔
细观察其中的区别。第一种形式中函数表达式被包含在 ( ) 中,然后在后面用另一个 () 括
号来调用。第二种形式中用来调用的 () 括号被移进了用来包装的 ( ) 括号中。
这两种形式在功能上是一致的。选择哪个全凭个人喜好。
IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。
例如:
1 | var a = 2; |
我们将 window 对象的引用传递进去,但将参数命名为 global,因此在代码风格上对全局
对象的引用变得比引用一个没有“全局”字样的变量更加清晰。当然可以从外部作用域传
递任何你需要的东西,并将变量命名为任何你觉得合适的名字。这对于改进代码风格是非
常有帮助的。
这个模式的另外一个应用场景是解决 undefined 标识符的默认值被错误覆盖导致的异常(虽
然不常见)。将一个参数命名为 undefined,但是在对应的位置不传入任何值,这样就可以
保证在代码块中 undefined 标识符的值真的是 undefined:
1 | undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做! |
IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE
执行之后当作参数传递进去。这种模式在 UMD(Universal Module Definition)项目中被广
泛使用。尽管这种模式略显冗长,但有些人认为它更易理解。1
2
3
4
5
6
7
8var a = 2;
(function IIFE( def ) {
def( window );
})(function def( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});
函数表达式 def 定义在片段的第二部分,然后当作参数(这个参数也叫作 def)被传递进
IIFE 函数定义的第一部分中。最后,参数 def(也就是传递进去的函数)被调用,并将
window 传入当作 global 参数的值。
3.4 块作用域
尽管函数作用域是最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计
方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可
以实现维护起来更加优秀、简洁的代码。
除 JavaScript 外的很多编程语言都支持块作用域,因此其他语言的开发者对于相关的思维
方式会很熟悉,但是对于主要使用 JavaScript 的开发者来说,这个概念会很陌生。
尽管你可能连一行带有块作用域风格的代码都没有写过,但对下面这种很常见的 JavaScript
代码一定很熟悉:1
2
3for (var i=0; i<10; i++) {
console.log( i );
}
我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使
用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。
这就是块作用域的用处。变量的声明应该距离使用的地方越近越好,并最大限度地本地
化。另外一个例子:
1 | var foo = true; |
bar 变量仅在 if 声明的上下文中使用,因此如果能将它声明在 if 块内部中会是一个很有
意义的事情。但是,当使用 var 声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。这段代码是为了风格更易读而伪装出的形式上的块作用域,如果使用这
种形式,要确保没在作用域其他地方意外地使用 bar 只能依靠自觉性。
块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息
扩展为在块中隐藏信息。
再次考虑 for 循环的例子:1
2
3for (var i=0; i<10; i++) {
console.log( i );
}
为什么要把一个只在 for 循环内部使用(至少是应该只在内部使用)的变量 i 污染到整个
函数作用域中呢?
更重要的是,开发者需要检查自己的代码,以避免在作用范围外意外地使用(或复用)某
些变量,如果在错误的地方使用变量将导致未知变量的异常。变量 i 的块作用域(如果存
在的话)将使得其只能在 for 循环内部使用,如果在函数中其他地方使用会导致错误。这
对保证变量不会被混乱地复用及提升代码的可维护性都有很大帮助。
但可惜,表面上看 JavaScript 并没有块作用域的相关功能。
除非你更加深入地研究。
3.4.1 with
with 关键字。它不仅是一个难于理解的结构,同时也是块作用域的一
个例子(块作用域的一种形式),用 with 从对象中创建出的作用域仅在 with 声明中而非外
部作用域中有效。
3.4.2 try/catch
非常少有人会注意到 JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作
用域,其中声明的变量仅在 catch 内部有效。
例如:1
2
3
4
5
6
7try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found
正如你所看到的,err 仅存在 catch 分句内部,当试图从别处引用它时会抛出错误。
尽管这个行为已经被标准化,并且被大部分的标准 JavaScript 环境(除了老
版本的 IE 浏览器)所支持,但是当同一个作用域中的两个或多个 catch 分句
用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告。
实际上这并不是重复定义,因为所有变量都被安全地限制在块作用域内部,
但是静态检查工具还是会很烦人地发出警告。为了避免这个不必要的警告,很多开发者会将 catch 的参数命名为 err1、
err2 等。也有开发者干脆关闭了静态检查工具对重复变量名的检查。
3.4.3 let
到目前为止,我们知道 JavaScript 在暴露块作用域的功能中有一些奇怪的行为。如果仅仅
是这样,那么 JavaScript 开发者多年来也就不会将块作用域当作非常有用的机制来使用了。
幸好,ES6 改变了现状,引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。
let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let
为其声明的变量隐式地了所在的块作用域。1
2
3
4
5
6
7var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError
用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。在开发和修改代码的过
程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯性地移动这些块或者将
其包含在其他的块中,就会导致代码变得混乱。
为块作用域显式地创建块可以部分解决这个问题,使变量的附属关系变得更加清晰。通常
来讲,显式的代码优于隐式或一些精巧但不清晰的代码。显式的块作用域风格非常容易书
写,并且和其他语言中块作用域的工作原理一致:
1 | var foo = true; |
只要声明是有效的,在声明中的任意位置都可以使用 { .. } 括号来为 let 创建一个用于绑
定的块。在这个例子中,我们在 if 声明内部显式地创建了一个块,如果需要对其进行重
构,整个块都可以被方便地移动而不会对外部 if 声明的位置和语义产生任何影响。
但是使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不
“存在”。
1 | { |
- 垃圾收集
另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。这里简要说明一
下,而内部的实现原理,也就是闭包的机制。
考虑以下代码:1
2
3
4
5
6
7
8
9function process(data) {
// 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );
click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执
行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成
了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体
实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:1
2
3
4
5
6
7
8
9
10
11
12function process(data) {
// 在这里做点有趣的事情
}
// 在这个块中定义的内容可以销毁了!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
为变量显式声明块作用域,并对变量进行本地绑定是非常有用的工具,可以把它添加到你
的代码工具箱中了。
- let循环
一个 let 可以发挥优势的典型例子就是之前讨论的 for 循环。1
2
3
4for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环
的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
下面通过另一种方式来说明每次迭代时进行重新绑定的行为:1
2
3
4
5
6
7{
let j;
for (j=0; j<10; j++) {
let i = j; // 每个迭代重新绑定!
console.log( i );
}
}
由于 let 声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域),
当代码中存在对于函数作用域中 var 声明的隐式依赖时,就会有很多隐藏的陷阱,如果用
let 来替代 var 则需要在代码重构的过程中付出额外的精力。
考虑以下代码:1
2
3
4
5
6
7
8var foo = true, baz = 10;
if (foo) {
var bar = 3;
if (baz > bar) {
console.log( baz );
}
// ...
}
这段代码可以简单地被重构成下面的同等形式1
2
3
4
5
6
7
8var foo = true, baz = 10;
if (foo) {
var bar = 3;
// ...
}
if (baz > bar) {
console.log( baz );
}
但是在使用块级作用域的变量时需要注意以下变化:
1 | var foo = true, baz = 10; |
3.4.4 const
除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的
(常量)。之后任何试图修改值的操作都会引起错误。
1 | var foo = true; |
温习:
- 编译器及词法解析原理
- 词法作用域、函数作用域
- with/eval、let/const
(完)
Author
My name is Micheal Wayne and this is my blog.
I am a front-end software engineer.
Contact: michealwayne@163.com