Fork me on GitHub

JavaScript 的作用域

什么是作用域

几乎所有编程语言的基本功能之一,就是能够存储变量的值,并能在之后对这个值进行修改。正是这种储存和访问变量值的能力将状态带给了程序。

JavaScript的作用域就是这样的一套规则,用来存储变量,并确定在何处、如何查找变量。

理解作用域

作用域的创建和设置发生在编译阶段,先简单了解一下代码的编译。

代码的编译

任何js代码片段,引擎在解释执行前,都要进行编译,通常在编译完成后会立马执行。整个编译过程大致分为三个阶段:

  • 分词/词法分析:将字符串分解为词法单元。
  • 解析/语法分析:将词法单元转换为AST树。
  • 代码生成:将AST转换为可执行代码,即一组机器指令。

整个编译过程涉及到:js引擎、编译器、作用域共同配合完成:

  • 引擎:负责整个js程序的编译及执行过程。
  • 编译器:主要负责语法分析、代码生成。在编译阶段会首先找到所有声明,并和相应的作用域绑定关联。告诉作用域声明变量,确定所有变量的定义位置,最后生成可执行代码。
  • 作用域:收集并维护由所有声明的标识符(变量)组成的一些列查询,并实施一套十分严格的规则,确定当前执行代码对标识符的访问权限。告诉引擎如何在当前及嵌套作用域中根据标识符名称进行变量查找。

编译、执行过程中作用域

下面大概模拟一下具体的编译过程发生了什么,拿var a = 2;这段代码来举例:

编译阶段:
编译器首先会将这段程序分解成词法单元,并解析成AST树。遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续编译。否则,会要求作用域在当前作用域集合中声明一个新的变量,并命名为a
接下来编译器会为引擎生成运行时所需代码,这些代码用来处理a = 2这个赋值操作。

执行阶段:
引擎运行时首先询问作用域,在当前作用域集合中是否存在变量a,如果存在就使用这个变量,否则,引擎会在上级作用域继续查找该变量,直到查到顶层全局作用域。如果找到就将2赋值给它,否则会抛出异常。

总结:
var a = 2;的变量赋值,js引擎会看成两个单独的声明,会执行两个动作,一个是编译阶段任务,一个是执行阶段任务。
首先编译器会在当前作用域中声明一个变量a。之后在执行阶段运行a = 2时,引擎会在作用域中查找(LHS查询),如果能找到就会对a赋值。

这也解释了变量提升带来的问题:

1
2
3
var a
console.log(a) // undefined
a = 2

作用域工作模型

分为词法作用域、动态作用域。

  • 词法作用域:在代码书写或定义时确定,关注函数在何处声明,作用域链基于作用域嵌套。
  • 动态作用域:在运行时确定,关注函数在何处调用,作用域链基于调用栈。

js编译器即采用词法作用域。

词法作用域(Lexical Scoping)

定义在词法阶段的作用域。是词法分析阶段确定的。由写代码时,将变量、函数和块作用域写在哪来决定的。函数的词法作用域只由函数的声明位置决定,跟在哪调用无关。根据声明的位置将变量分配给相应的作用域,并由嵌套关系决定了作用域的嵌套,即作用域链是基于作用域嵌套。因此当词法分析器处理代码时,通常会保持作用域不变。

词法作用域意味着作用域是函数的声明位置决定的。编译词法分析阶段基本能知道全部标识符在哪及如何声明的,从而能够预测在执行过程中如何对它们进行查找。而作用域气泡的结构和相互间位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符。

动态作用域(Dynamic Scoping)

比较少见

举例说明

综上所述,举个例子来说明:

1
2
3
4
5
6
7
var a = 1
function foo() {
var b = 2
console.log(a)
console.log(b)
}
foo() // 打印 a、b

怎么理解作用域是一套规则?此处这套规则就是,在foo的函数作用域可以查找b,沿作用域链在全局作用域可以查找a