JavaScript 关于作用域的理解
如果你对作用域、作用域链、词法作用域等概念还傻傻分不清楚,那就看看这篇文章吧。了解作用域相关知识,也有助于理解闭包、执行上下文等JS
核心知识。跟随小呆的视角,一起来复习一下吧。
知识点
- 理解JavaScript的执行过程
- 理解什么是作用域 & 作用域链
- 理解什么是词法作用域
理解JavaScript的执行过程
在说作用域之前,我们要知道JavaScript的执行过程是分为两个阶段的:代码的编译阶段和代码的执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。
作用域和执行上下文是完全不同的两个概念。一个在代码的编译阶段发生,一个在代码的执行阶段发生。
编译阶段
词法分析
编译器首先会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元(token)。
1 |
|
以上面的代码为例,通常会被分解成词法单元:如 var
, a
, =
, 2
, ;
,这些词法单元组成了一个词法单元流数组。
1 |
|
语法分析
把词法单元流数组装换成一个由元素逐级嵌套所组成的代表程序语法结构的树,这个树被称为“抽象语法树”(AST)。
1 |
|
代码生成
将AST转换为可执行代码的过程被称为代码生成。简单来说就是有某种方法可以将var a = 2;
的AST转化为一组机器指令,用来创建一个叫做a的变量(包括分配内存等),并将一个值储存在a中。
在代码生成的这一过程中,编译器会查找作用域是否已经有一个名称为a
的变量存在于同一作用域集合中,如果是,编辑器会忽略该声明,继续进行编译。否则,它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a
。
什么是作用域
好了,到这里,我们知道了代码在编译阶段所经历的步骤。其中在代码生成阶段,提到了作用域,它似乎很重要,我们来看看什么是作用域:
在《你不知道的JavaScript——上》中,对于作用域的定义是这样的:
作用域是根据名称查找变量的一套规则,这套规则用来管理
JS
引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找
在初学JavaScript
的时候,我们总能听到全局变量,局部变量这样的词。而这个变量到底是全局变量还是局部变量,其实就取决于它书写在哪个作用域中。
在JavaScript
中,作用域分为以下几种:
- 全局作用域:脚本模式运行所有代码的默认作用域
- 函数作用域:由函数创建的作用域
- 块级作用域:用一对花括号
{}
创建出来的作用域(这个仅针对于let
或const
声明的变量) - 模块作用域(仅node):模块模式中运行代码的作用域
1 |
|
全局作用域和函数作用域很好理解,容易出错的其实是块级作用域。注意观察上面的代码,c
变量虽然在{}
中声明,但是它其实是一个全局作用域下的变量。而只有let
或const
声明的变量,才具有块级作用域。
作用域嵌套
JavaScript
的作用域有一个最明显的特征就是:作用域可以嵌套,子作用域可以访问父作用域,但是反过来不行!那什么是作用域嵌套呢?我们来看一下定义:
当一个块或函数嵌套在另外一个块或函数中,就发生了作用域嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
1 |
|
我们看上面的代码,函数child
声明在函数foo
,此时就发生了作用域嵌套。在child
函数内部,我们可以使用父作用域foo
函数内声明的变量b
以及父作用域的父作用域(全局作用域)内声明的变量a
。但是反过来,我们在全局作用域里打印子作用域foo
函数内部声明的变量b
,却发生了报错。那为什么子作用域可以访问父作用域,但是反过来则不行呢?这里就要说到作用域链。
什么是作用域链
了解过执行上下文的同学应该知道,我们所说的变量,其实是定义在词法环境中Environment Record
(环境记录器)[ES3
版本为VO
变量对象]上的一个属性,与环境记录器并存的还有一个Reference the outer environment
(指向外部词法环境的引用)[ES3
版本为scope
],这个指向外部词法环境的引用,其实指向的就是父作用域中的词法环境。这样一层一层的向上引用,就形成了一个链式的结构,也就是作用域链。
所以变量的查找过程,其实就是在当前作用域以及顺着作用域链往外查找的一个过程,直到全局作用域。如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。
1 |
|
回到第一张图可以发现,执行上下文是在代码的执行阶段发生的,而作用域链是在执行上下文的创建阶段生成的。奇不奇怪,明明作用域是在代码的编译阶段确定的,为什么作用域链却在执行阶段确定呢?(对执行上下文和作用域链的创建不明白的同学可以看理解执行上下文这篇文章。)
其实这就是作用域和作用域链容易混淆的一个点:作用域是一套规则,而作用域链是这套规则的具体实现。
什么是词法作用域
作用域共有两种主要的模型:词法作用域和动态作用域。JavaScript的作用域遵循的是词法作用域模型。
上面我们理清了作用域和作用域链的关系,紧接着另一个容易混淆的名词就扑面而来——词法作用域。前面我们说了JavaScript代码在编译阶段有一个过程叫做词法化,《你不知道的JavaScript》一书中对词法作用域的定义如下:
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时,会保持作用域不变(大部分情况下是这样的)。
也就是说,词法作用域在代码的编译阶段就确定了。所以,无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数声明时所处的位置来决定。
所以,词法作用域只是作用域的一个工作模型。
小结
说到这里,我们来对上面的概念做个小结:
- JavaScript的编译阶段分为:词法分析 -> 语法分析 -> 代码生成
- 作用域是一套规则,它用来管理
JS
引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找 - JavaScript的作用域是在代码的编译阶段确定的。
- 词法作用域只是作用域的一种工作模型,由代码书写时的位置来决定
- 作用域链是在代码的执行阶段完成的,它是作用域规则的具体实现。
- 函数被调用时,会激活函数执行上下文,在执行上下文的创建阶段,具体实现作用域链。
引用
本文内容参考了以下书籍,感兴趣的同学可以购买正版图书进行阅读。
《你不知道的JavaScript——上卷》——作者:KYLE SIMPSON