作用域
# 初识作用域
在 Javascript 内,我们可以定义变量,这个时候我们就需要考虑这些变量存储在哪儿?JavaScript 引擎又是如何找到它们的?
这个时候就需要一套良好的规则来存储变量,并且之后可以方便地找到变量。这套规则称为作用域。
现在我带你一层一层得揭露它的外皮,了解它在底层是如何运作的。
# 编译原理
通常在传统的编程语言中,程序在执行之前都需要经历以下三个阶段:
分词/词法分析
举个 🌰:在 js 中有这么一段代码
var a = 1
,那么在这个阶段,通常会被分解为这些语法单元:var
、a
、=
、1
解析/语法分析
这个阶段,就会将上面生成段词法单元流解析为抽象语法树(AST)
var a = 2;
的抽象语法树中可能会有一个叫作VariableDeclaration
的顶级节点,接下 来是一个叫作Identifier
(它的值是 a)的子节点,以及一个叫作AssignmentExpression
的子节点。AssignmentExpression
节点有一个叫作NumericLiteral
(它的值是 2)的子节点·。代码生成·
这个阶段会将上面生产的 AST 解析生产一组机器指令,用来创建一个叫做
a
的变量(包括内存分配等),并将一个值存储在a
中。
事实上,JavaScript 引擎比上面这个流程会复杂得多。
# 理解作用域
在处理 JavaScript 程序时需要用上这么几个 道具
- 引擎 — 从头到尾负责 Javascript 的编译和执行
- 编译器 — 负责词法分析和代码生成
- 作用域 — 负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
接着上面的 🌰:
JavaScript 引擎在执行 var a = 2
这段代码的时候,编译器会询问作用域是否有同一个变量存在同一个作用域集合内,如果是,就忽略该声明继续编译,如果不是,会要求作用域在当前作用域声明一个新的变量,并命名为a
。
接着编译器会为引擎生成所需的代码,引擎运行时,会在当前作用域询问是否有该变量的声明,如果是,引擎会使用这个变量,如果不是,引擎会向上级作用域查找。
通过编译器的角度进一步理解上面这个流程
编译器在编译过程中的第二步生成了代码,引擎为了执行它,会通过查找变量 a 来判断它是否被声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查找结果。
LHS: 当变量出现在赋值操作的左侧时进行 LHS 查询,一般情况为给变量赋值,无需获取到变量的内容
RHS: 当变量出现在赋值操作的右侧时进行 RHS 查询,一般情况为获取到变量的内容
例如:
console.log( a );
这段代码需要打印出 a 的值,所以需要获取到 a 的内容,这为 RHS 查询a = 2
这段代码为定义一个变量,需要找到 a,所以为 LHS
故名思义,可以将这个理解为 R(right)和 L(left)
引擎和作用域就是通过这种关系来进行对话的。
function foo(a) { console.log( a ); // 2
}
foo( 2 );
让我们把上面这段代码的处理过程想象成一段对话,这段对话可能是下面这样的。
- 引擎:我说作用域,我需要为 foo 进行 RHS 引用。你见过它吗?
- 作用域:别说,我还真见过,编译器那小子刚刚声明了它。它是一个函数,给你。
- 引擎:哥们太够意思了!好吧,我来执行一下 foo。
- 引擎:作用域,还有个事儿。我需要为 a 进行 LHS 引用,这个你见过吗?
- 作用域:这个也见过,编译器最近把它声名为 foo 的一个形式参数了,拿去吧。
- 引擎:大恩不言谢,你总是这么棒。现在我要把 2 赋值给 a。
- 引擎:哥们,不好意思又来打扰你。我要为 console 进行 RHS 引用,你见过它吗?
- 作用域:咱俩谁跟谁啊,再说我就是干这个。这个我也有,console 是个内置对象。 给你。
- 引擎:么么哒。我得看看这里面是不是有 log(..)。太好了,找到了,是一个函数。
- 引擎:哥们,能帮我再找一下对 a 的 RHS 引用吗?虽然我记得它,但想再确认一次。
- 作用域:放心吧,这个变量没有变动过,拿走,不谢。
- 引擎:真棒。我来把 a 的值,也就是 2,传递进 log(..)。
# 作用域嵌套
function foo(a) {
console.log(a + b);
}
var b = 2;
foo(2); // 4
2
3
4
5
例如上面这一段代码,当在 foo 函数内的作用域中无法对 b 进行 RHS 引用,就会往上级作用域进行 RHS 引用,一直到全局作用域,就像上面这个例子。当在全局作用域都没找到对应的引用时,全局作用域就会在全局自动创建这么一个变量,并赋值为 undefined。
# 为什么会区分 RHS 和 LHS?
考虑这个问题之前,我们需要了解到 JavaScript 中两种类型的异常,区分出这两种异常,你就会轻易得知道这个问题。
ReferenceError:
当我们在全局直接 console.log(a)
这个时候,RHS 查询无法找到对应的变量,就会抛出一个异常,也就是 ReferenceError。
TypeError:
上面我们知道,作用域会存在作用域嵌套,当在当前作用域上无法找到 RHS 引用,会继续往上查找,直到全局作用域。但是我们在 RHS 处,对这个变量进行不合理的操作,就会抛出TypeError
,例如:对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的属性。
# 词法作用域
词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变。
# 欺骗词法
如果词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修改”(也可以说欺骗)词法作用域呢?
目前 Javascript 中存在两种机制来实现这个目的,但是这两种机制会带来性能上的一些缺陷。
eval
JavaScript 中的eval()
函数可以接受一个字符串为参数,并且将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,可以在书写的代码中用程序生成代码并运行,就好像代码说写在那个位置。
eval()
通过代码欺骗和假装书写时(也就是词法期)代码就在那,来实现修改词法作用域环境的,例如:
function foo(str, a) {
eval(str); //欺骗
console.log(a, b);
}
var b = 2;
foo("var b = 3", 1); // 1,3
2
3
4
5
6
with
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 1,
};
var o2 = {
b: 1,
};
foo(o1);
o1.a; //2
foo(o2);
o2.a; //undefined
a; //2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
为什么会带来性能问题?
JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但是如果引擎在代码中发现了 eval()
或者 with
,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道eval()
阶段会收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。所以在代码中使用eval()
和 with
会造成新能上的一些缺陷。
# 函数作用域和块作用域
# 函数作用域
简单来说在函数中声明的变量无法在函数外部访问
# 块级作用域
在ES6时出现了块级作用域这个说法,在块内声明的变量无法在块外访问,ES6声明块级变量的方式有两种,let
、const
其实,在ES3中,try/catch
的catch内也有块级作用域这个概念
# 提升
# 变量提升
来看看下面这个经典的🌰,分别都输出什么?:
var a
a = 2
console.log(a)
2
3
var a = 2
console.log(a)
2
第一个:2
第二个:undefined
如果你是一个JavaScript初学者,你可能会感到比较疑惑。
现在,我们逐步来看看,这个问题。
从前面我们知道,在执行JavaScript之前,有三个阶段:词法分析、编译、代码生成。
在编译阶段的一部分工作就是找到所有的声明,并且用合适的作用域关联起来。
在第二个例子里面,JavaScript实际上会把 var a = 2
拆分为两段var a
和 a = 2
。第一个声明是在编译阶段执行的。第二个声明会留在原地等待执行。
所以,第二段代码实际上会是下面这样的
var a
a = 2
console.log(a)
2
3
同理,我们分析第二段代码:
var a
console.log(a)
a = 2
2
3
这也就是上面输出不一致的原因
总结来说,只有声明本身会被提升,而赋值和其他运行逻辑会被留在原地。如果提升改变了代码执行顺序,会造成非常严重的后果。
# 函数提升
变量会出现提升,另外值得注意的是每个作用域都会执行提升操作,同样,函数也会发生提升,如下:
foo(1) //1
function foo(a){
console.log(a)
}
2
3
4
函数声明可以提升,但是函数表达式无法进行提升
foo() // TypeError
var foo = function(a){
console.log(a)
}
2
3
4
上面这个报错是TypeError而不是ReferenceError,如果你不知道为什么,建议往上翻翻RHS和LHS部分内容。
同时,还有一种具名函数表达式也无法提升。
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() { // ...
};
2
3
4
这段代码会这样执行
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self... // ...
}
2
3
4
5
6
# 函数优先
对于同名的函数和变量,函数提升都优先级比变量优先级高,比如下面这个例子
foo(); // 1
var foo;
function foo() { console.log( 1 );
}
foo = function() { console.log( 2 );
};
2
3
4
5
6
实际上,这段代码会被引擎理解为这样:
function foo() { console.log( 1 );
}
foo(); // 1
foo = function() { console.log( 2 );
};
foo()//2
2
3
4
5
6
尽管var定义的变量会被忽略,但是后面声明的函数还是可以覆盖之前的变量。
foo(); // 3
function foo() { console.log( 1 );
}
var foo = function() { console.log( 2 );
};
function foo() { console.log( 3 );
}
2
3
4
5
6
7
8
# 作用域闭包
# 闭包
闭包是基于词法作用域书写代码时所产生的自然结果,当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数时在当前词法作用域之外执行。
function foo(){
var a = 1
function bar(){
console.log(a)
}
return bar
}
var baz = foo()
baz() //2 这就是闭包的效果
2
3
4
5
6
7
8
9
函数bar的词法作用域能够访问foo内部的作用域,然后我们将bar函数本省当作一个值类型进行传递。
在foo执行后,其返回值复制给变量baz并调用baz,实际上只是通过不同的标识符引用调用了内部的函数bar
在foo执行后,通常会期待foo的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo的内容不会再被使用,所以很自然地会对其回收。
而闭包的神奇之处正是可以阻止这件事情的发生,事实上内部的作用域依然存在,因此没有被回收,谁在使用这个内部作用域呢?答:bar本身。
因为bar所声明的位置所赐,它拥有涵盖foo内部作用域的闭包,使得该作用域能够一直存活,以供bar再之后任何时间进行引用
bar依然持有对该作用域的引用,而这个引用就叫做闭包。
其他的闭包
无论何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 闭包!
}
2
3
4
5
6
7
8
9
10
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 将 baz 分配给全局变量
}
function bar() {
fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2
2
3
4
5
6
7
8
9
10
11
12
13
对于setTimeout来说,看下面这个🌰:
function wait(message){
setTimeout(function(){
console.log(message)
},1000)
}
2
3
4
5
上面这个例子,回调函数具有涵盖wait的作用域的,因此还有对变量message的引用
在执行1000毫秒后,内部作用域并不会消失,回调函数依然保有wait()作用域的闭包。
深入到引擎的内部原理中,内置的工具函数setTimeout持有一个参数的引用,这个参数也许叫做fn或者func,或者其他名字,引擎会调用这个函数,而词法作用域在这个过程中保持完整。这就是闭包。
# 循环和闭包
看下面这个例子:
for(var i=1;i<=5;i++){
setTimeout(function(){
console.log(i)
},1000*i)
}
2
3
4
5
上面这个例子本意是想每隔1s输出当前的i值,但是实际上会每隔1s输出5次6
首先解释 6 是从哪里来的。这个循环的终止条件是 i 不再 <=5。条件首次成立时 i 的值是 6。因此,输出显示的是循环结束时 i 的最终值。
仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。事实上, 当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循 环结束后才会被执行,因此会每次输出一个 6 出来。
这里引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一 致呢?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
这样说的话,当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环, 那它同这段代码是完全等价的。
现在我们可以通过立即执行函数为每次的i创建一个副本来达到效果
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function{ console.log( j );
}, j*1000 );
})( i );
}
2
3
4
5
6
这里也可以通过块级作用域来达到效果,我们知道在ES6,出现了两种声明块级作用域的关键词,let、const。
那么我们也可以结合块级作用域和闭包
for (let i=1; i<=5; i++) {
setTimeout( function() {
console.log( i );
}, i*1000 );
}
2
3
4
5