在本文中,记录着我对于作用域和闭包的一些总结,其中很多的部分是对于 《You Don’t Know JS》中提到的知识点的梳理和精华内容的提取。当然,最推荐大家亲自来阅读这本书,体会其中的奥秘。
理解什么是作用域
JS 代码的运行过程
尽管 JavaScript 一般被划分到 动态 或者 解释型 语言的范畴,但是其实它是一个编译型语言。不过得益于 JIT( Just-in-time) 的工作原理,使得 JavaScript 在编译的时候能够以很快的速度运行,具体 JIT 的解释,我们可以参考这篇文章:WebAssembly 系列(二)JavaScript Just-in-time (JIT) 工作原理
在理解了 JavaScript 代码在运行前需要编译之后,我们可以开始对 JavaScript 代码的运行机制中 作用域 的产生和特点来进行分析了:
为了更简单地理解 JavaScript 的编译和运行机制,我们使用一个简单的例子:var a = 2;
来进行演示:
在理解代码运行的时候,我们会接触下面三个概念:
- 引擎:负责从始至终的编译和执行 JavaScript 程序。
- 编译器:负责语法分析和代码生成。
- 作用域:收集并维护一张所有被声明的标识符(变量)的列表,并且确定当前代码对这些标识符的访问权限。
下面我们来看这三个部分在运行 var a = 2;
这段代码的时候是怎么配合的:
当你看到程序 var a = 2;
时,引擎 看到两个不同的语句,一个是 编译器 将在编译期间处理,一个是 引擎 将在执行期间处理的。
编译器 将会这样处理:
- 遇到
var a
,编译器 让 作用域 去查看作用域集合,变量a
是否已经存在。如果存在,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去声明一个称为a
的新变量。 - 然后 编译器 为 引擎 生成稍后要执行的代码,来处理赋值
a = 2
。引擎 运行时首先让 作用域 去查看在当前的作用域集合中是否存在a
的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看嵌套作用域 - 如果 引擎 最终找到一个变量,它就将值
2
赋予它。如果没有,引擎 就报出一个错误。
1 | 注意:在严格模式和宽松模式中,对于找不到变量的处理有所不同 |
总结来说:对于一个变量赋值,发生了两个不同的动作:第一,编译器 声明一个变量 a
(如果先前没有在当前作用域中声明过),第二,当执行时,如果找到 a
的话,引擎 在 作用域 中查询这个变量并给它赋值。
所以,我们认为,作用域是根据变量名称查找变量的一套规则
作用域嵌套
当 一个代码块或函数被嵌套在另一个代码块或函数中 时,就发生了作用域被嵌套。如果在直接作用域中找不到一个变量的话,引擎 就会咨询外层作用域,如此直到找到这个变量或者到达最外层作用域(也就是全局作用域)。
词法作用域
概念:词法作用域是在词法分析时被定义的作用域。就是在写程序时,变量和作用域的块儿写在哪决定的,并且 大多数 时候词法分析器处理代码之后,变量的作用域是不变的。
变量的查找
作用域查找会在找到第一个匹配的标识符时停止。所以在多层嵌套的时候,定义相同名字的标识符可以产生 遮蔽。
改变词法作用域
我们在上面提到了,大多数 时候词法分析器处理代码之后,变量的作用域是不变的。当然,这里说的是大多数情况,因为我们可以使用代码在运行时 修改 词法作用域。
1 | 注意:欺骗词法作用域会导致性能下降 |
eval
我们可以看一个例子:
1 | eval("var a = 2;"); |
引擎在运行这段代码的时候,会将 var a = 2;
这段代码本来就在那里一样来处理,导致当前作用域中又创建了一个变量 a
,修改了已经存在的词法作用域。
1 | function foo(str, a) { |
可以看到,当前词法作用域中创建了一个新的变量 b
,遮蔽 了外层的 b
。
1 | 注意:eval(...) 通常被用在执行动态创建的代码,比如,编程联系的时候,用户添加的一部分功能的实现 :) |
1 | 再注意,在严格模式下,eval(...) 在运行的时候有自己的词法作用域,所以在其中的声明不会修改所在的作用域。 |
with
1 | 注意:with 语句在 |
为了显示 with
的作用,我们可以看一下以下的例子:
1 | function foo(obj) { |
with
语句的作用是:将一个对象视为一个词法作用域。
with
内部正常的 var
声明不会在这个块的作用域(对象)中声明变量,而是在 with
所处的函数函数作用域中添加对象,如果是 a = 2
,则隐式创建一个全局的对象,太可怕了!
提升
我们听说过 JS 中的变量声明提升和函数声明提升,从而导致代码产生了偏离本意的结果。那么,为什么会产生这种结果呢?这是由于 编译器的编译过程和引擎的执行过程是两个阶段而导致的。
考虑一个代码段:
1 | console.log( a ); |
当我们看到 var a = 2
; 时,我们可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a;
和 a = 2;
。第一个语句是在 编译 阶段被处理的。第二个语句是在 执行 阶段运行的。
所以我们的代码是按照以下的流程处理的:
1 | var a; |
这个过程就像变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端,这就产生了“提升”这个名字。
1 | 注意:每个作用域都会进行提升操作,作用域内声明的变量会被提升到该作用域的顶端 |
函数表达式不会提升,只有函数声明才会被提升
函数会首先被提升,然后才是变量。
函数作用域和块作用域
什么是函数作用域和块作用域,我就不再赘述了,这里我想要再提及的,是 ES6 中 let
关键字。
let
关键字相比于 var
关键字,有一些额外的功能,比如:
- 不存在变量提升
- 暂时性死区
- 不允许重复声明
我们这里谈论的是,let
关键字在作用域方面的影响,通过以下的例子展现:
let
关键字将变量绑定在所在的任意作用域中。
所以我们可以看以下的例子:
1 | if (foo) { |
可以表示成为:
1 | if (foo) { |
let 循环
1 | for (let i=0; i<10; i++) { |
将 i
绑定在了 for
循环的作用域中,而且将 i
绑定在了循环的每一个迭代中。:
1 | { |
作用域闭包
闭包的定义:一个函数即使被传递在定义自己的词法作用域之外,也保持着定义自己的词法作用域的引用。
1 | function foo() { |
在这个例子中,bar
被传递到了定义自己的词法作用域之外执行,但是它依然可以找到定义自己的词法作用域中的变量。
循环和闭包
1 | for (var i=1; i<=5; i++) { |
虽然所有这5个函数在各个循环迭代中分别定义的,但是它们 都封闭在一个共享的全局作用域上,事实上只有一个 i
。
所以,我们有以下几种方法来解决这个问题:
模拟块级作用域
IIFE 会通过声明并立即执行一个函数来创建一个作用域
1 | for (var i=1; i<=5; i++) { |
或者
1 | for (var i=1; i<=5; i++) { |
创建块级作用域
1 | for (let i=1; i<=5; i++) { |
实际上这段代码可以解释为:
1 | { |
模块
1 | function CoolModule(id) { |
模块模式要具备两个必要条件:
- 必须有一个外部的封闭函数,而且该函数必须至少被调用一次(每次创建一个新的模块实例)。
- 封闭函数必须至少返回一个内部函数,这样这个内部函数才能在私有作用域中形成闭包,并且可以访问或修改私有状态。
严格与非严格模式相关
这里记录的是《作用域与闭包》中严格模式和非严格模式中的一些区别,详细的区别点在文章中都可以找到解释:
- 在严格模式中,在给变量赋值的时候
a = 2;
,禁止自动或者隐式创建全局变量,而是返回一个 ReferenceErrot 异常。在非严格模式下,如果在赋值的时候没有找到该变量,会隐式创建一个全局变量 - 函数必须先声明再使用(不能隐式创建一个全局变量)
- 严格模式下,
eval(...)
在运行的时候有自己的词法作用域,所以在其中的声明不会修改所在的作用域。在非严格模式下,eval(...)
函数创建的变量可能会影响到所在的作用域 - 严格模式下 不能 使用
with
语句。