# 图解作用域链

# 引言

在讲作用域链(scopeChain)之前我们先来了解下什么叫作用域,(scope) 《你不知道的javaScript(上)》书中是这么解释的: 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。

作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行

我们看个例子,用泡泡来比喻作用域可能好理解一点:

img

最后输出的结果为 2, 4, 12

  • 泡泡1是全局作用域,有标识符foo;
  • 泡泡2是作用域foo,有标识符a, b, bar;foo函数中的形参a也相当于函数中的私有变量(标识符)
  • 泡泡3是作用域bar,仅有标识符c。

# 作用域 (scope)

在 JavaScript 中有两种作用域

  • 全局作用域:拥有全局作用域的对象可以在代码的任何地方访问到。
  • 局部作用域:和全局作用于相反,局部作用域一般只能在固定代码片段内可以访问到。最常见的就是函数作用域

# 全局作用域

在js中一般有以下几种情形拥有全局作用域:

  1. 最外层的函数以及最外层变量:
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
1
2
3
4
5
6
7
8
9
10
11
12

从上面代码中可以看到globleVariableglobalFunc在任何地方都可以访问到, 反之不具有全局作用域特性的变量只能在其作用域内使用。

  1. 未定义直接赋值的变量(由于变量提升使之成为全局变量)
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 可以删除
1
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
1
2
3

另一方面如果你使用var申明变量,第二个申明的同样的变量将覆盖前面的,这样会使你的代码很难调试。

// 张三写的代码
var name = 'beige'

// 李四写的代码
var name = 'yizhan'
console.log(name);  // yizhan
1
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相互独立,互不影响。
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 2、块级作用域

ES6 引入了块级作用域,让变量的生命周期更加可控,块级作用域可通过新增命令let和const声明,所声明的变量在指定块的作用域外无法被访问。块级作用域在如下情况被创建:

  1. 在一个函数内部
  2. 在一个代码块(由一对花括号包裹)内部

let声明和var声明的区别:

  1. 不存在变量提升
  2. 不允许重复声明
  3. 会形成暂时性死区(temporal dead zone)简称TDZ
  4. 不存在和全局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
1
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
1
2
3
4
5

上面代码中,计数器i只在for循环体内有效,在循环体外引用就会报错。

var a = [];
for (var i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[6](); // 10
1
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
1
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
1
2
3
4
5
6
7

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域。

内部实现相当于这样

{
    // 父作用域
    let i; 
    for (i = 0; i < 3; i++) {
        // 子作用域
        let i = 'abc'; 
        console.log(i); 
    }
}
1
2
3
4
5
6
7
8
9

# 作用域链

在讲解作用域链之前先说一下,先了解一下 JavaScript是如何执行的?

# JavaScript是如何执行的?

img

为了能够完全理解 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);
1
2
3
4
5
6
7
8
9
# 首先进入分析阶段

前面已经提到了,函数运行的瞬间,创建一个AO (Active Object 活动对象)

AO = {}
1

第一步:分析函数参数:

形式参数:AO.age = undefined
实参:AO.age = 18
1
2

第二步,分析变量声明:

// 第3行代码有var age
// 但此前第一步中已有AO.age = 18, 有同名属性,不做任何事AO.age = 18
1
2
3

第三步,分析函数声明:

// 第5行代码有函数age
// 则将function age(){}付给AO.age
AO.age = function age() {}
1
2
3

进入执行阶段

分析阶段分析成功后,会把给AO(Active Object 活动对象)给执行阶段,引擎会询问作用域,的过程。所以上面那段代码AO链中最终应该是

AO.age = function age() {}
//之后
AO.age=20
//之后
AO.age=20
1
2
3
4
5

所以最后的输出结果是:

function age(){
    
}
20
20
1
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 );
1
2
3
4
5
引擎:我说作用域,我需要为 c 进行 LHS引用, 你见过吗?
作用域:别说,我还真见过,编译器那小子刚刚声明了它,给你。
引擎:哥们太够意思了!
引擎:作用域,还有个事儿。我需要为 c 进行赋值,foo RHS引用这个你见过吗?
作用域:这个也见过,编译器最近把它声名为一个函数
引擎: 好现在我来执行一下foo, 它最好是一个函数类型
引擎  作用域,还有个事儿。我需要为 a 进行LHS引用,这个你见过吗?
作用域:这个也见过,编译器最近把它声名为 foo 的一个形式参数了,拿去吧。
引擎:大恩不言谢,你总是这么棒。现在我要把 2 赋值给 a 。
引擎:哥们,不好意思又来打扰你。我要给b进行LHS引用, 你见过这个人嘛?
作用域:咱俩谁跟谁啊,再说我就是干这个。编译器那小子刚声明了它, 我给你
引擎:么么哒。能帮我再找一下对 a 的RHS引用吗?虽然我记得它,但想再确认一次。
作用域:放心吧,这个变量没有变动过,拿走,不谢。
引擎:能帮我再找一下对 a 和 b 的RHS引用吗?虽然我记得它,但想再确认一次
作用域:放心吧,这个变量没有变动过,拿走,不谢。
引擎:好, 现在我要返回 2 + 2 的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

现在来看引擎在作用域这个过程: LSH(写入内存):

c=, a=2(隐式变量分配), b=
1

RHS(读取内存)

读foo(2), = a, a ,b
(return a + b 时需要查找a和b)
1
2

最后对作用域链做一个总结,引用《你不知道的Javascript(上)》中的一张图解释

好, 你现在应该要在脑子里把作用域链想象成一栋楼,当前执行的作用域所处的位置就在一层,楼顶就是全局作用域。作用域内收集并维护由所有声明的标识符(变量),当调用函数时如果自己没有这个标识就向上一层查找(上一个作用域),直到顶楼(window)还没有话就停止。

最后来看看代码

let str = 'global' // 全局作用域
function outer() { // 第二层作用域
    let str = 'outer';
    return function inner() { // 第一层作用域 
        console.log(str); 
    }
}
let inner = outer();
inner(); // outer
1
2
3
4
5
6
7
8
9

# 参考

关注作者公众号

自学路上一起进步!

加入前端自学交流群

扫描二维码回复 加群 学习