0%

关于作用域和作用域链的一些想法

作用域和作用域链在我学习 JavaScript 过程中曾经带给我很长时间的困惑,也曾经在CSDN总结过一篇博客,但当时好多想法在现在看来依然过于浅薄,所以想用这篇博客来梳理一下自己对于作用域和作用域链的一些全新认识和想法,也希望能帮助和我当初一样对此感到困惑的同学,知识浅薄,希望大家不吝指教。

作用域到底是什么

我认为对于作用域认识的关键在于跳出 JavaScript 以一种更高的维度去看它,几乎所有编程语言最基本的功能之一就是能够储存变量当中的值,并且能够在之后对这个变量进行访问和修改,事实上正是这种储存和访问变量值的能力将状态带给了程序,因此程序语言需要制定这样一套规则来存储变量,同时能够方便在日后访问和修改这些变量,而这套规则就是作用域,我们由根据这套规则在何时生成将其分为词法作用域动态作用域

在详细介绍两者之前我们先需要了解编程语言的编译过程:

  1. 分词/词法分析
  2. 解析/语法分析
  3. 代码生成

实际上大部分语言的编译过程远比这复杂,以 JavaScript 为例还有预编译、延迟编译等等过程,但是我们都可以将其简单看成由这三步构成,在了解了上述编译过程的基础上我们来介绍词法作用域动态作用域

词法作用域就是定义在词法阶段的作用域,换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里决定的,因此当词法分析器处理代码时会保持作用域不变,因此词法作用域也被称为静态作用域。与之相对的就是动态作用域,词法作用域的定义过程发生在代码的定义阶段,而动态作用域是在运行时动态确定的,我们通过如下示例进行区分:

1
2
3
4
5
6
7
8
9
function foo() {
console.log(a); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();

上面示例中调用 foo 函数打印的 a 为 2,这是因为词法作用域让 foo 函数内部的 a 通过 RHS 引用(如果查找变量的目的是对变量进行赋值,这个过程就称为 LHS 引用,相反,如果查找的目的是获取变量的值,这个过程就被称为 RHS 引用)获取到了全局作用域中的 a,因此控制台输出 2,而如果 JavaScript 中的作用域为动态作用域的话 foo 函数在执行时将会输出 3,这是因为动态作用域并不会关心函数和作用域是如何声明以及在何处声明的,只关心它们在何处被调用,因此在动态作用域中由于 foo 函数在 bar 函数中被调用,因此 RHS 引用获取到了 bar 函数的作用域中的 a。

当一个块或函数嵌套在另一个块或函数中时就发生了作用域的嵌套,也就产生了作用域链,此时如果在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或抵达最外层作用域(也就是全局作用域)为止。

至此我们进行总结:作用域本质上是编程语言储存及访问变量的具体规则,这个规则为编程语言带来了状态,JavaScript 与大多数编程语言相同选择了词法作用域,也被称为静态作用域,作用域定义在词法分析阶段,与函数在何处调用无关而仅仅只由我们在写代码时将变量和块作用域写在哪里决定。那么这个规则是如何实现的呢?

作用域是怎么实现的

为了介绍作用域是如何实现的我们需要引入编程语中的另一个语法概念:上下文,上下文是一段程序运行时所需要的最小数据集合,大家对于这个描述也看到了作用域与上下文之间的本质区别,作用域关注的是标识符(变量)的可访问性,而上下文指代的是整体环境,同时对于大部分编程语言来说作用域在编译过程中的词法分析阶段生成(词法作用域),并且不会改变,而上下文在运行时确定,随时可以改变,因此也被称为执行上下文,执行上下文与作用域两者之间拥有着本质性的不同,总而言之,执行上下文是评估和执行 JavaScript 代码的环境的抽象概念,也就是 JavaScript 执行一段代码时的运行环境,每当 JavaScript 代码在运行的时候,它都是在执行上下文中运行,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等等,而这其中查找变量、对象及函数的规则就是作用域了,下面我们详细介绍 JavaScript 中的执行上下文:

一、执行上下文在何时创建

在开始 JavaScript 中执行上下文何时被创建的话题之前我们先关注另外一个问题:变量提升,所谓变量提升,是指在代码执行过程中,JavaScript 引擎把变量声明部分和函数的声明部分提升到代码开头的“行为”,变量提升后会给变量设置默认值,这个默认值就是我们熟悉的 undefined。对于变量提升我们已经很熟悉,那么我们来讨论它背后到底是怎么实现的?

从概念的字面意思来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这并不准确,实际上变量和函数声明在代码里的位置是不会改变的,变量提升实际上是变量和函数声明在编译阶段被 JavaScript 引擎放入内存中。与传统的编译语言例如 C、C++ 等不同,JavaScript 并不会先被编译生成第三方脚本然后运行,事实上 JavaScript 作为一门解释型语言,它是在宿主环境直接解释执行,比如下载完一个 js 文件,JavaScript 会先编译这个 js 文件(这个过程中 JavaScript 引擎为了提升性能并不会编译文件内 JavaScript 函数,而是等到函数被调用时才进行编译),变量以及函数声明这些变量提升的内容在这个阶段保存在执行上下文中变量环境的对象中,一段 JavaScript 代码在经过编译后会生成两部分内容:执行上下文可执行代码。我们以如下代码示例:

1
2
3
4
5
6
showName();
console.log(myname);
var myname = 'kyleezhang';
function showName() {
console.log('my name is kyleezhang');
}

代码在经过编译后分为两个部分:

1
2
3
4
5
6
7
8
9
10
// 保存在执行上下文中变量环境中的变量提升部分
var myname = undefined;
function showName() {
console.log('my name is kyleezhang');
}

// 可执行代码
showName();
console.log(myname);
myname = 'kyleezhang';

至此,我们发现执行上下文是 JavaScript 执行一段代码时的运行环境,其在 JavaScript代码 的编译阶段生成,与此同时会有部分变量与函数因为“变量提升”规则被 JavaScript 引擎解析保存到执行上下文的变量环境中。根据 JavaScript 代码的类型执行上下文可分为下面三种:

  • 全局执行上下文 — 这是默认或者说基础的上下文,当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份,任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置 this 的值等于这个全局对象。
  • 函数执行上下文 — 每当一个函数被调用时,函数体内的代码会被编译,并创建函数执行上下文,函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。总而言之当我们调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等等。
  • eval 函数执行上下文 — 执行在 eval 函数内部的代码也会有它属于自己的执行上下文,但在开发过程中不提倡使用 eval 函数。

二、执行上下文由什么构成

前文中我们提到“变量提升”的变量都会被保存到执行上下文的变量环境中,那么执行上下文到底由什么构成呢?事实上从 ES3 到 ES2018,不同的 ECMAScript 标准中执行上下文的构成也在不断的变化:

在 ES3 标准中执行上下文(virable context)定义了变量函数有权访问的其他数据,决定了它们各自的行为,每个执行上下文都有一个与之关联的变量对象(variable object),环境中定义的所有变量与函数都保存在这个对象中。当某个函数被创建时会创建一个预先包含全局变量对象和外层函数对象变量对象的作用域链,这个作用域链被保存在函数内部的[[Scope]]属性中,当函数被调用时,JavaScript 执行引擎会为函数创建一个执行上下文,然后复制函数的[[Scope]]属性中的变量对象构建起执行上下文中的作用域链(scope chain)。此后,又有一个活动对象(本质上还是变量对象,不过是记录当前执行函数中定义的变量及函数)被创建并被推入执行环境作用域链的前端,我们以下面代码为例:

1
2
3
4
5
6
7
8
9
10
11
function compare(value1, value2) {
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
}

var result = compare(5, 10);

以上代码先定义了compare 函数,然后又在全局作用域中调用了它,当调用compare函数时会先创建一个包含arguments、value1 和 value2 的活动对象。全局执行环境的变量对象(包含 result 和 compare)在compare 执行环境的作用域链中则处于第二位,具体如下图所示:

显然在 ES3 中作用域链本质上更类似于一个指向变量对象的指针列表,它只是引用但不实际包含变量对象,当函数在执行过程中访问一个变量时就会沿着作用域链中搜索具有相应名字的变量,这也对应了 JavaScript 中作用域具体规则的实现,然后当前函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象),然后再接着执行下一个函数。

但是在 ES6 中一切又有所不同,JavaScript 引擎创建执行上下文主要分为以下三步:

  • this 值的绑定
  • 创建词法环境
  • 创建变量环境

this的绑定:

在全局执行上下文中 this 的值指向全局对象(在浏览器中,this 引用 Window 对象)。

在函数执行上下文中 this 的值只取决于该函数的调用方式,this 值默认绑定全局对象(严格模式下指向 undefined),如果函数以对象属性的方式调用那么 this 值指向该对象,如果函数通过 call、apply 直接绑定 this 值那么 this 值指向传入对象,如果该函数通过 new 操作符构造调用,那么函数内 this 值指向新创建的对象。

创建词法环境:

官方的 ES6 文档把词法环境定义为:

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。

简单来说词法环境的内部由两部分构成:环境记录器和一个外部环境的引用:

  1. 环境记录器是存储变量和函数声明的实际位置,本质上是一种持有标识符——变量映射的结构(这里的标识符指的是变量/函数的名字,而变量是对实际对象或原始数据的引用)。
  2. 外部环境的引用意味着它可以访问其父级词法环境。

词法环境有两种类型:

  • 全局环境:全局执行上下文中的词法环境,全局环境的外部环境引用是 null。它拥有内建的 Object/Array 等、在环境记录器内的原型函数(关联全局对象,比如 window 对象)还有任何用户定义的全局变量。
  • 函数环境:函数执行上下文中的词法环境,在函数环境中,函数内部用户定义的变量存储在环境记录器中。并且引用的外部环境可能是全局环境,或者任何包含此内部函数的外部函数。

实际上所有词法环境本质上都是相同的,其全局环境与函数环境的区分主要是环境记录器的不同,环境记录器一共有如下五种:

  1. Declarative Environment Records
  2. Object Environment Records
  3. Function Environment Records
  4. Global Environment Records
  5. Module Environment Records

在全局环境中,环境记录器是 Object Environment Records(即对象环境记录器),用来定义出现在全局上下文中的变量和函数的关系,在函数环境中,环境记录器是 Declarative Environment Records(即声明式环境记录器),用来存储变量、函数和参数。

注意:对于函数环境,声明式环境记录器还包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。

抽象地讲,词法环境在伪代码中看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
},
outer: <null>
}
}

FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
},
outer: <Global or outer function environment reference>
}
}

创建变量环境:

变量环境同样是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系,所以它有着上面定义的词法环境的所有属性。

在 ES6 中,词法环境组件和变量环境之间的主要不同就是前者被用来存储函数声明和变量(let 和 const)绑定,而后者只用来存储 var 变量绑定,以下面代码示例:

1
2
3
4
5
6
7
8
9
10
let a = 20;
const b = 30;
var c;

function multiply(e, f) {
var g = 20;
return e * f * g;
}

c = multiply(20, 30);

解析生成的执行上下文如下所示:

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
42
43
44
45
46
GlobalExectionContext = {

ThisBinding: <Global Object>,

LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>
},

VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
c: undefined,
}
outer: <null>
}
}

FunctionExectionContext = {
ThisBinding: <Global Object>,

LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>
},

VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}

大家应该注意到了变量 a、b 和 c、g 之间的区别,大家都知道 let、const 声明的变量并不会变量提升,事实上这种说法并不十分准确,let 和 const 声明的变量依然会发生变量声明提升,不过相较于 var 声明的变量它并不会把变量的值初始化为 undefined,这就是为什么你可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 let 和 const 的变量会得到一个引用错误。

下面我们以如下示例来分析ES6中执行上下文的创建及执行过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a);
console.log(b);
}
console.log(b);
console.log(c);
console.log(d);
}
foo()

第一步调用 foo 函数前先编译并创建执行上下文,函数内部通过 var 声明的变量被存放到变量环境中,通过 let 声明的变量在编译阶段被存放到词法环境中,此处需要注意在函数体内部块作用域中 let 声明的变量并没有被存放到词法环境中。这一步生成的执行上下文如下图所示(为了清楚展示执行上下文中的变量声明,只展示变量环境与词法环境中的环境记录器):

第二步:继续执行代码,当执行到代码块里面时,变量环境中的 a 的值已经被设置为1,词法环境中 b 的值已经被设置成了 2,此时函数的执行上下文如图所示:

从图中就可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,因此示例中在函数体内块作用域中声明的变量的 b 与函数作用域中声明的变量 b 都是独立的存在。
前文我们提到词法环境中的环境记录器是 Declarative Environment Records 类型,即声明式环境记录器,其内部实际上维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域内部的变量压到栈顶;当该块级作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。

第三步:当代码执行到作用域块中的 console.log(a) 时,就需要在词法环境和变量环境中通过 RHS 引用来查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有找到那么继续在变量环境中查找,如果在当前执行上下文没有找到那么会沿着当前执行上下文中外部环境引用 outer 指针指向的外部执行上下文中继续查找

第四步:当函数体内块作用域执行结束之后,其内部变量就会从词法环境的栈顶弹出,此时执行上下文如下图所示:

第五步:当foo函数执行完毕后执行栈将foo函数的执行上下文弹出。

在标准ES2018中执行上下文的定义又发生进一步的变更,除了 this value、lexial environment、variable environment 外还新增了 code evaluation state(标记代码执行位置)、Function(执行的任务是函数时使用,表示正在被执行的函数)、ScriptOrModule(执行的任务是脚本或者模块时使用,表示正在被执行的代码)、Realm(使用的基础库和内置对象实例)、Generator(仅生成器上下文有这个属性,表示当前生成器),由于目前浏览器端还没有具体标准的实现,此处不再展开讲解。

三、总结

至此我们来总结 JavaScript 中的作用域和执行上下文之间到底是什么关系?

我觉得我们首先需要认识到作用域与作用域链是所有编程语言的基础,它们的存在提供给编程语言存储和访问变量的能力,因此作用域和作用域链是编程语言学习绕不开的语法概念,JavaScript中 的作用域为词法作用域,即作用域只由代码中函数或变量声明的位置决定,变量查询在当前作用域查找不到对应变量的情况下会继续向外层词法作用域查找,词法作用域层层嵌套形成 JavaScript 中的作用域链。

但是,作用域与作用域链仅仅只是语法概念,其在不同编程语言中的实现是不同的,对于 JavaScript 来说作用域及作用域链的变量查询是通过存储在浏览器内存中的执行上下文实现的,JavaScript 中的执行上下文在代码执行前的编译阶段生成,执行上下文内词法环境和变量环境中的环境记录器分别保存通过 let、const 声明的变量以及通过 var 声明的变量,外部环境引用 outer 指针指向其外层词法作用域的执行上下文,在代码执行过程中如果需要查找变量首先会在当前执行上下文中的词法环境中从上而下在不同栈中查找,如果在词法环境中未能访问到对应到对应变量则会查找变量环境,如果在当前执行上下文未能找到则会继续在 outer 指向的执行上下文中查找,至此 JavaScript 基于执行上下文实现了作用域及作用域链对应的变量储存及查询的对应规则。

对于大部分编译型语言来说其作用域往往也采用的是词法作用域,在代码的编译过程中的词法分析阶段生成,而上下文在代码执行过程中动态创建,因此两者之间存在着本质上的不同,但是对于 JavaScript 来说,其独特的边解释边执行使得 JavaScript 实际上是在代码执行前的编译阶段过程生成,往往这个过程可能只有几微秒,但是这无法掩盖其在编译阶段生成的本质,因此 JavaScript 中可以利用执行栈与执行上下文的机制来实现作用域与作用域链中变量的储存与查找的对应规则。

参考资料

极客时间《浏览器工作原理与实践》专栏

极客时间《重学前端》专栏

《JavaScript高级程序设计》第三版

《你不知道的Javascript》

欢迎关注我的其它发布渠道