# 图解作用域链
# 引言
在讲作用域链(scopeChain)之前我们先来了解下什么叫作用域,(scope) 《你不知道的javaScript(上)》
书中是这么解释的: 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。
作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。
我们看个例子,用泡泡来比喻作用域可能好理解一点:
最后输出的结果为 2, 4, 12
- 泡泡1是全局作用域,有标识符foo;
- 泡泡2是作用域foo,有标识符a, b, bar;foo函数中的形参a也相当于函数中的私有变量(标识符)
- 泡泡3是作用域bar,仅有标识符c。
# 作用域 (scope)
在 JavaScript 中有两种作用域
- 全局作用域:拥有全局作用域的对象可以在代码的任何地方访问到。
- 局部作用域:和全局作用于相反,局部作用域一般只能在固定代码片段内可以访问到。最常见的就是函数作用域。
# 全局作用域
在js中一般有以下几种情形拥有全局作用域:
- 最外层的函数以及最外层变量:
var globleVariable= 'global'; // 最外层变量
function globalFunc(){ // 最外层函数
var childVariable = 'global_child'; //函数内变量
function childFunc(){ // 内层函数
console.log(childVariable);
}
console.log(globleVariable)
}
console.log(globleVariable); // global
globalFunc(); // global
console.log(childVariable) // childVariable is not defined
console.log(childFunc) // childFunc is not defined
2
3
4
5
6
7
8
9
10
11
12
从上面代码中可以看到globleVariable
和globalFunc
在任何地方都可以访问到, 反之不具有全局作用域特性的变量只能在其作用域内使用。
- 未定义直接赋值的变量(由于变量提升使之成为全局变量)
function func1(){
special = 'special_variable'; // 没有用var声明自动提升全局变量
var normal = 'normal_variable';
}
func1();
console.log(special); //special_variable
console.log(normal) // normal is not defined
// 有var和不带var有什么区别呢??
// => 带var不能被delete删除
var a = 10;
b = 20;
delete a; // false 删除不了这个变量存储的值
delete b; // true 可以删除
2
3
4
5
6
7
8
9
10
11
12
13
14
15
虽然我们可以在全局作用域中声明函数以及变量, 使之成为全局变量, 但是不建议这么做,因为这可能会和其他的变量名冲突,一方面如果我们再使用const
或者let
声明变量, 当命名发生冲突时会报错。
// 变量冲突
var globleVariable = "person";
let globleVariable = "animal"; // Error, thing has already been declared
2
3
另一方面如果你使用var
申明变量,第二个申明的同样的变量将覆盖前面的,这样会使你的代码很难调试。
// 张三写的代码
var name = 'beige'
// 李四写的代码
var name = 'yizhan'
console.log(name); // yizhan
2
3
4
5
6
# 局部作用域
和全局作用于相反,局部作用域一般只能在固定代码片段内可以访问到。最常见的就是函数作用域。
# 1、函数作用域
定义在函数中的变量就在函数作用域中, 形参变量也相当于在函数内声明的,并且每个函数拥有自己独立的作用域,意味着同名变量可以用在不同的函数中,彼此之间不能访问。
function test1() {
var a = 10;
console.log(a);
}
function test2() {
var a = 20;
console.log(a);
}
test1(); // 10
test2(); // 20
// => 两个函数内的同名变量a相互独立,互不影响。
2
3
4
5
6
7
8
9
10
11
12
13
14
# 2、块级作用域
ES6 引入了块级作用域,让变量的生命周期更加可控,块级作用域可通过新增命令let和const声明,所声明的变量在指定块的作用域外无法被访问。块级作用域在如下情况被创建:
- 在一个函数内部
- 在一个代码块(由一对花括号包裹)内部
let声明和var声明的区别:
- 不存在变量提升
- 不允许重复声明
- 会形成暂时性死区(temporal dead zone)简称TDZ
- 不存在和全局window之间的相互映射
代码演示
// 变量提升
console.log(str); // undefined;
var str = '北歌';
// 不存在变量提升
console.log(str); // str is not defined;
let str = '北歌';
// 允许重复声明 => 后面覆盖前面
var a = 10;
var a = 20;
// 不允许重复声明 => Identifier 'b' has already been declared
let a = 10;
let a = 20;
// TDZ
function foo1() {
console.log(a); // a is not defined
var a = 10;
}
function foo2() {
console.log(a); // Cannot access 'a' before initialization
let a = 10;
}
foo1()
foo2()
// 存在映射
var a = 10;
console.log(window.a); // 10;
window.a = 20;
console.log(a); // 20
// 不存在映射
var a = 10;
console.log(window.a); // undefined => window对象没有这个属性
window.a = 20;
console.log(a); // 10
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
循环中的绑定块作用域的妙用
for (let i = 0; i < 10; i++) {
// ...
}
console.log(i);
// ReferenceError: i is not defined
2
3
4
5
上面代码中,计数器i只在for循环体内有效,在循环体外引用就会报错。
var a = [];
for (var i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 10
2
3
4
5
6
7
上面代码中,变量i是var命令声明的,在全局范围内都有效,所以全局只有一个变量i。每一次循环,变量i的值都会发生改变,而循环内被赋给数组a的函数内部的console.log(i),里面的i指向的就是全局的i。也就是说,所有数组a的成员里面的i,指向的都是同一个i,导致运行时输出的是最后一轮的i的值,也就是 10。
如果使用let,声明的变量仅在块级作用域内有效,最后输出的是 6。
var a = [];
for (let i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 6
2
3
4
5
6
7
上面代码中,变量i是let声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。你可能会问,如果每一轮循环的变量i都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。
另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}
// abc
// abc
// abc
2
3
4
5
6
7
上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域。
内部实现相当于这样
{
// 父作用域
let i;
for (i = 0; i < 3; i++) {
// 子作用域
let i = 'abc';
console.log(i);
}
}
2
3
4
5
6
7
8
9
# 作用域链
在讲解作用域链之前先说一下,先了解一下 JavaScript是如何执行的?
# JavaScript是如何执行的?
为了能够完全理解 JavaScript 的工作原理,你需要开始像引擎(和它的朋友们)一样思考, 从它们的角度提出问题,并从它们的角度回答这些问题。
- 引擎 •从头到尾负责整个 JavaScript 程序的编译及执行过程。
- 编译器 •引擎的好朋友之一,负责语法分析及代码生成等脏活累活
- 作用域 • 引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查 询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
JavaScript代码执行分为两个阶段:
# 分析阶段
javascript编译器编译完成,生成代码后进行分析
- 分析函数参数
- 分析变量声明
- 分析函数声明
分析阶段的核心,在分析完成后(也就是接下来函数执行阶段的瞬间)会创建一个AO(Active Object 活动对象)
# 执行阶段
分析阶段分析成功后,会把给AO(Active Object 活动对象)
给执行阶段
- 引擎询问作用域,作用域中是否有这个叫X的标识(变量)
- 如果作用域有标识(变量),引擎会使用这个标识(变量)
- 如果作用域中没有,引擎会继续寻找(向上层作用域),如果到了最后都没有找到这个标识(变量),引擎会抛出错误。
执行阶段的核心就是找
,具体怎么找
,后面会讲解LHS查询
与RHS查询
。
# JavaScript执行举例说明
看一段代码:
function a(age) {
console.log(age);
var age = 20
console.log(age);
function age() {
}
console.log(age);
}
a(18);
2
3
4
5
6
7
8
9
# 首先进入分析阶段
前面已经提到了,函数运行的瞬间,创建一个AO (Active Object 活动对象)
AO = {}
第一步:分析函数参数:
形式参数:AO.age = undefined
实参:AO.age = 18
2
第二步,分析变量声明:
// 第3行代码有var age
// 但此前第一步中已有AO.age = 18, 有同名属性,不做任何事
即AO.age = 18
2
3
第三步,分析函数声明:
// 第5行代码有函数age
// 则将function age(){}付给AO.age
AO.age = function age() {}
2
3
进入执行阶段
分析阶段分析成功后,会把给AO(Active Object 活动对象)
给执行阶段,引擎会询问作用域,找
的过程。所以上面那段代码AO链中最终应该是
AO.age = function age() {}
//之后
AO.age=20
//之后
AO.age=20
2
3
4
5
所以最后的输出结果是:
function age(){
}
20
20
2
3
4
5
# 找
过程中LHS和RHS查询特殊说明
LHS,RHS 这两个术语就是出现在引擎对标识(变量)进行查询的时候。在《你不知道的javaScript(上)》
也有很清楚的描述。在这里,我想引用freecodecamp
上面的回答来解释:
LHS = 变量赋值或写入内存。想象为将文本文件保存到硬盘中。 RHS = 变量查找或从内存中读取。想象为从硬盘打开文本文件。
# LHS和RHS特性
- 都会在所有作用域中查询
- 严格模式下,找不到所需的变量时,引擎都会抛出
ReferenceError
异常。 - 非严格模式下,
LHR
稍微比较特殊: 会自动创建一个全局变量 - 查询成功时,如果对变量的值进行不合理的操作,比如:对一个非函数类型的值进行函数调用,引擎会抛出
TypeError
异常
LHS和RHS举例说明,例子来自于《你不知道的Javascript(上)》
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
2
3
4
5
引擎:我说作用域,我需要为 c 进行 LHS引用, 你见过吗?
作用域:别说,我还真见过,编译器那小子刚刚声明了它,给你。
引擎:哥们太够意思了!
引擎:作用域,还有个事儿。我需要为 c 进行赋值,foo RHS引用这个你见过吗?
作用域:这个也见过,编译器最近把它声名为一个函数
引擎: 好现在我来执行一下foo, 它最好是一个函数类型
引擎 作用域,还有个事儿。我需要为 a 进行LHS引用,这个你见过吗?
作用域:这个也见过,编译器最近把它声名为 foo 的一个形式参数了,拿去吧。
引擎:大恩不言谢,你总是这么棒。现在我要把 2 赋值给 a 。
引擎:哥们,不好意思又来打扰你。我要给b进行LHS引用, 你见过这个人嘛?
作用域:咱俩谁跟谁啊,再说我就是干这个。编译器那小子刚声明了它, 我给你
引擎:么么哒。能帮我再找一下对 a 的RHS引用吗?虽然我记得它,但想再确认一次。
作用域:放心吧,这个变量没有变动过,拿走,不谢。
引擎:能帮我再找一下对 a 和 b 的RHS引用吗?虽然我记得它,但想再确认一次
作用域:放心吧,这个变量没有变动过,拿走,不谢。
引擎:好, 现在我要返回 2 + 2 的值
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
现在来看引擎在作用域找
这个过程: LSH(写入内存):
c=, a=2(隐式变量分配), b=
RHS(读取内存):
读foo(2), = a, a ,b
(return a + b 时需要查找a和b)
2
最后对作用域链做一个总结,引用《你不知道的Javascript(上)》中的一张图解释
好, 你现在应该要在脑子里把作用域链想象成一栋楼,当前执行的作用域所处的位置就在一层,楼顶就是全局作用域。作用域内收集并维护由所有声明的标识符(变量),当调用函数时如果自己没有这个标识就向上一层查找(上一个作用域),直到顶楼(window)还没有话就停止。
最后来看看代码
let str = 'global' // 全局作用域
function outer() { // 第二层作用域
let str = 'outer';
return function inner() { // 第一层作用域
console.log(str);
}
}
let inner = outer();
inner(); // outer
2
3
4
5
6
7
8
9
# 参考
关注作者公众号
自学路上一起进步!
加入前端自学交流群
扫描二维码回复 加群 学习
← js中的内存机制 js深入之彻底理解闭包 →