大家对于闭包的理解可能有很大的区别,今天我们来捋一捋。
什么是闭包
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起,这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。—— MDN
那么词法环境又是什么?词法环境包含了什么内容?为什么是定义时的词法环境?
我们经常说的闭包、作用域链、执行上下文、this 值,其实几乎都在说一件事:函数的执行过程。 接下来我们一起了解这些概念,看看能不能在它们身上找到答案。
执行上下文
JavaScript 标准经过多年不断的变化,执行上下文包含的内容也发生了巨大的变化,很多同学还在沿用 ES3 的定义,每个社区的说法也不太一样。最新标准可见 ECMA-262, 11th edition, June 2020
ES3 从 1999 年发布,到 2009 年 ES5 发布时经过了 10 年。中间的 ES4 因为特性变动太大,ECMA 内部意见没统一,反对的人很多,所以放弃了发布。因此 ES3 也是我们熟知的一个版本。
我们来看看执行上下文的每个版本都有哪些内容。
ES3
ES3 执行上下文包含三个部分:
- scope:作用域,也常常被叫做作用域链。
- variable object:变量对象,用于存储变量的对象。
- this value:this 值。
ES5
改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。
- lexical environment:词法环境,当获取变量时使用。
- variable environment:变量环境,当声明变量时使用。
- this value:this 值。
ES2018
到了这个版本,变化已经非常大了,this 值被归入 lexical environment,并且增加了不少内容。
- lexical environment:词法环境,当获取变量或者 this 值时使用。
- variable environment:变量环境,当声明变量时使用。
- code evaluation state:用于恢复代码执行位置。
- Function:执行的任务是函数时使用,表示正在被执行的函数。
- ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
- Realm:使用的基础库和内置对象实例。
- Generator:仅生成器上下文有这个属性,表示当前生成器。
ECMA 262 规定的术语了解完,和你知道的有不一样的地方吗?欢迎留言。下一步该主角上场了,我们来看看函数执行过程。
函数执行过程
我们看以下的这段 JavaScript 代码:
this.a = 2;
var b = {}
let c = 1
复制代码
要想正确执行它,我们需要知道以下信息:
- var 把 b 声明到哪里;
- b 表示哪个变量;
- b 的原型是哪个对象;
- let 把 c 声明到哪里;this 指向哪个对象。
这些信息就需要执行上下文来给出了,这段代码出现在不同的位置,甚至在每次执行中,会关联到不同的执行上下文,所以,同样的代码会产生不一样的行为。
函数执行时来来回回的调用更复杂,我们来看以下代码,有 3 个 js 文件:
main.js 里可以访问变量 i,同时调用了函数 foo,foo 可以访问变量 x,函数 foo 里面又调用了函数 foo2,foo2 可以访问变量 y。
foo2 在 foo 中调用,但是不能访问 x,这也是为什么闭包是函数和函数定义时的词法环境组成,y 是 foo2 定义时的词法环境,x 是 foo2 执行时的词法环境。
- 蓝色的代码可以访问变量 i
- 黄色的代码可以访问变量 x
- 紫色的代码可以访问变量 y
- 这种由能访问到不能访问再到能访问,就是栈的特性。函数调用本身是存在栈里,而能访问到的变量也可以通过栈来描述。
执行上下文栈
通过上面的例子我们知道,每一句代码执行时要访问的变量都保存在一个地方,这个地方就是执行上下文,多个执行上下文会形成的一个集合,我们把这个结构称为执行上下文栈,见下图。
这个栈的栈顶元素就是我们执行一行代码时能访问到的执行上下文,它有一个特殊的名字叫做 Runing Execution Context。代码执行所需要的所有信息都能从 Runing Execution Context 中获取到。这里面会有啥呢?变量、this、函数执行到的位置、声明一个空对象所需要的原型 ...... 都在这里面。切换执行上下文的主要场景就是函数的调用,调用函数、函数执行完都会切换执行上下文。
执行上下文 (ES2018)
每当控制从与当前运行的执行上下文关联的可执行代码转移到与该执行上下文不关联的可执行代码时,就会创建一个新的执行上下文。新创建的执行上下文被推送到堆栈上,成为正在运行的执行上下文。上面的例子中 foo 调用 foo2 时便会创建一个新的执行上下文,并且成为 Runing Execution Context。
不同的执行上下文的组成部分不一样,所有执行上下文都包括以下部分:
- code evaluation state
- Function
- Realm
- ScriptOrModule
ECMAScript Code Execution Contexts 再额外包括:
- LexicalEnvironment
- VariableEnvironment
Generator Execution Contexts 再额外包括:
- Generator
执行上下文纯粹是一种规范机制,不需要与ECMAScript实现的任何特定构件相对应。ECMAScript代码不可能直接访问或观察执行上下文。
现在我们知道了执行上下文的运行机制,以及执行上下文中包括了什么,也大概知道代码 let c = 1
时的 c 声明到哪里了,接下来了解一下具体的存储方式,我们仔细研究一下 Lexical Environments 这个重要设备。
Lexical Environments
前面我们有提到 this、变量声明会保存在 Lexical Environments 里,除此之外还有 super()、new.target 等等。
词法环境由环境记录(Environment Record)和对外部词法环境的可能空引用组成。通常,词法环境与 ECMAScript 代码的一些特定的语法结构相关联,比如 FunctionDeclaration、 BlockStatement 或 TryStatement 的 Catch 子句,每次评估这些代码时都会创建一个新的词法环境。
Environment Records
-
Declarative Environment Records
每个 Declarative Environment Record 都与一个 ECMAScript 程序作用域相关联,该程序作用域包含变量、常量、 let、类、模块、导入或函数声明。Declarative Environment Record 绑定由其范围内的声明所定义的一组标识符,譬如
c:1
,这就是let c = 1
声明的地方。 -
Object Environment Records
-
Function Environment Records
-
Global Environment Records
-
Module Environment Records
平时使用最多的是 Declarative Environment Records,一对大括号 {} 的代码块会生成块级作用域,会生成 Declarative Environment Records,函数会生成 Function Environment Records。
重新认识闭包
根据闭包的金典定义,闭包由两部分组成:
- 环境部分
- 环境
- 标识符列表
- 表达式部分
在 JavaScript 标准中并没有出现过闭包这个术语,但是,我们却不难根据古典定义,找到 JavaScript 对应的闭包组成部分。
- 环境部分
- 环境:函数的词法环境(执行上下文的一部分)
- 标识符列表:函数中用到的未声明的变量
- 表达式部分:函数体
在 JavaScript 里面,每个函数都会包含词法环境,因为上面有介绍到 ECMAScript Code Execution Contexts 再额外包括 LexicalEnvironment,函数体就是闭包的表达式部分。标识符列表就是函数中使用的未声明的变量。
举个例子:
函数 foo2 定义时外面有个 var y = 2
,那么不管这个 foo2 是通过参数、export、import 传到哪里,它都会带上 y = 2
这个变量,这个变量保存在 Environment record 里面。这就是一个闭包。这个也是我们的 Environment records 能形成链式结构的关键设施。
我们再来看一个更复杂的例子:
函数 foo3 是 foo2 返回的一个箭头函数,箭头函数使用了 foo2 定义的 z = 3
这个变量,所以 z:3
被保存在 Environment Record 里面。而 foo2 的 Environment Record y:2
被当作 z:3
的上级保存了下来,这个就是 Environment Records 形成的链式结构。在早期版本中叫 Scope Chain (ES3,第 10.1.4 Scope Chain and Identifier Resolution 中有介绍),在 ES2018 里面已经不这么说了。
因为 foo3 是箭头函数,所以 this 值也被保存了下来了,加上 z:3
和外层的 y:2
一共有 3 条记录,所以箭头函数可以访问 this、z、y 这 3 个变量。
这就是闭包和作用域链的机制。这里我们容易产生一个常见的概念误区,有些人会把 JavaScript 执行上下文,或者作用域(Scope,ES3 中规定的执行上下文的一部分)这个概念当作闭包。实际上 JavaScript 中跟闭包对应的概念就是“函数”。
有一点需要特别注意,宿主环境不同,看到的闭包不一样,譬如下面这个例子:
var i = 1
var foo = function () {
console.log(i)
debugger
}
foo()
复制代码
Chrome 浏览器中会生成闭包,在 node 中不会生成闭包,所以我们做试验的时候要特别注意,不能依靠宿主环境的表现来判断。
既然每个函数都可能会生成闭包,那 JavaScript 有多少种函数呢,不同函数的 this 值怎么确定的?我们下期再来讨论。
最后思考一下,下面这段代码会生成多少个闭包?
var i = 1
var foo = function () {
var x = 2
var y = 3
;(function () {
console.log(x, i)
})()
var foo2 = function() {
var z = 4
console.log(y)
return () => {
console.log(x, y, z, i)
}
}
return foo2()
}
foo()()
复制代码
如有错误欢迎指出,欢迎一起讨论!