前言
继上一篇文章 JavaScript 脚本编译与执行过程简述,再来介绍一下 JavaScript 中神奇的“闭包”(Closure)。
闭包是基于词法作用域书写代码时所产生的自然结果。
JavaScript 语言是采用了词法作用域。一般情况下,函数、变量的作用域在编写的时候已经确定且不可改变的。除了
eval
、
with
之外,它们会在运行的时候“修改”词法作用域,但实际项目中,几乎很少用到它们,欺骗词法作用域会有性能问题,我们可以忽略。
还有,千万别把
this
跟作用域混淆在一起,
this
与函数调用有关,可以说是“动态”的。而作用域是静态的,跟函数怎样调用没关系。词法作用域也被叫做“静态作用域”。
若对词法作用域、执行上下文、变量对象、作用域链等内容不熟悉的话,建议先学习相关知识。到时回来再看闭包的时候,就非常容易理解了。
正文
一、概念
无论网上文章,还是各类书籍,对闭包的定义都不尽相同。列举几个:
-
MDN:闭包是指那些能够访问自由变量的函数。
-
《JavaScript 高级程序设计》:闭包指的是那些引用了另一个函数作用域中变量的函数。
-
《你不知道的 JavaScript》:闭包是代码块和创建该代码块的上下文中数据的结合。
讲实话,我也不知道以上哪个说法更贴切、更符合。当了解
作用域链
之后,就很容易理解闭包了。
上面提到了
自由变量
一词,
自由变量(Free Variable):是指在函数中使用的,但既不是函数参数,也不是函数的局部变量的变量。
var a = 1
function foo() {
var b = 2 // b 不是自由变量
console.log(a) // a 是自由变量
foo()
在 ECMAScript 中,闭包指的是:
-
从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
-
从实践角度:以下函数才算是闭包:
-
-
即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)。
-
在代码中引用了自由变量。
就我个人认为,闭包不是一个函数,它是一种机制,用于访问自由变量。闭包不是 JavaScript 中专有术语,在上世纪很早就被提出来了,在其他语言(如 Ruby 语言)中,闭包可以是一个过程对象,一个 Lambda 表达式或者是代码块。
二、Chrome 眼中的闭包
其实上面概念可能很多人都不理解,但问题不大,我们先看看 Chrome 眼中的闭包是长怎么样的。
举个例子:
function foo() {
var a = 1
function bar() {
console.log(a)
return bar
var f = foo()
f() // 1
相信很多人都知道,函数
foo
就是一个闭包,通过 Chrome 断点调试可以从视角感知。
但是我们稍微修改一下,
var a = 1
function foo() {
function bar() {
console.log(a)
return bar
var f = foo()
f()
此时
a
是全局上下文的变量,尽管对于函数
bar
来说
a
属于自由变量,但它不是
foo
函数上下文内声明的变量,因此
foo
就不是闭包。
总结:在函数
A
(如
foo
)中存在某个函数
B
(如
bar
,且必须是在
A
中定义的),且
B
内至少引用了
A
中的一个“变量”,那么函数
A
就是一个闭包。
请注意,与函数
B
的调用方式没关系。无论
B
是在
foo
内部被调用,还是作为返回值返回,然后在别处调用。
再看一个例子:
function foo() {
var b = () => {
// 由于 b 是箭头函数,内部没有 arguments 对象,
// 所以这个 arguments 对象是 foo 中变量对象的一员,
// 因此 foo 也是一个闭包。
console.log(arguments)
return b
var f = foo('foo')
f() // { 0: 'foo', length: 1 }
上述这个示例,是为了提醒
B
对
A
中的某个“变量”(指变量、函数、
arguments
、形参等)的引用,不仅仅是通过
var
、
function
、
let
、
const
、
class
等关键字显式声明的,还可以是
arguments
对象、形参。换句话说,就是 AO 中的所有变量。
再看,下面示例中
foo
是闭包吗?
function foo(fn) {
var a = 'local'
function bar() {
console.log(a)
var a = 'global'
foo(bar) // "global"
答案是 NO。前面总结过一个函数要成为闭包,该函数(
foo
)内部必须存在另外一个函数(
fn
),且
fn
内需要
foo
中的某个变量。那不正好引用了
foo
中的变量
a
吗?显然,这理解是错误的。
根据词法作用域可知,函数
bar
的作用域链
[[scope]]
在声明时就已确定且不可变,只含
GlobalContext.VO
,因此当查找自由变量
a
时,当
bar
的 AO 内查不到,下一步是前往全局对象下查找,于是就找到了
a
其值为
"global"
。所以
fn
内部对
foo
构成不了引用,因此
foo
就不是闭包。
若到这里,对闭包还是懵懵懂懂的,这块引用的内容,请跳过。
突然间,我好像明白了为什么函数内部缺省声明关键字的变量(如
a = 1
),在执行时才将其视为全局变量。
假设将其作为函数上下文的变量,要怎么做:
-
假设将其视为当前函数执行上下文的一个变量,那么 JS 引擎在进入执行上下文时,初始化工作量实在太多了,要通篇扫描当前上下文的声明语句和赋值语句,还要判别赋值语句是单纯地给已有变量赋值,还是上面提到的缺省声明情况。显然很影响效率和性能。
-
如果不通篇扫描,在执行代码的时候再更新到 AO 上,那么又会破坏 JavaScript 的词法作用域。似乎就变成了“动态作用域”。
但如果将其视为全局上下文的一个变量,上面的额外的工作都省了。但注意,它与全局声明的变量有些区别,前者可以被删除,而后者无法删除(原因可看
这里
)。在严格模式下对这种“隐式”声明全局变量的行为作为禁止,并抛出
SyntaxError
。不确定是不是因为这个原因而被禁的。
这个是突然灵光一闪的,所以也 Mark 下来了。
综上所述,Chrome 浏览器眼中的闭包应该是这样的:
在某个函数
A
中存在另一个函数
B
(函数
B
必须是在函数
A
中定义的),而且
B
内至少引用了
A
中的一个变量,那么当
B
在任意地方被调用时,函数
A
就是一个闭包。
其实,我认为概念不是很重要的...
三、更多示例
前面的示例,都相对比较简单和清晰的。再看多几个吧。
关于 Chrome 浏览器调试,在 Source 选项卡进行断点调试时,可以看到作用域、闭包的变化。
CallStack: 调用栈
Scope: 当前执行上下文的作用域链
Local // 当前 AO/VO 对象,但不完全是,我们也可以看到 this 指向
Block // 包含块级作用域 let、const、class 的变量
Closure // 闭包
modules // ESM 模块
Script // <script> 内所有 let、const、class 声明的变量
Global // 即 window,通过 var function 声明的全局变量,会放在这里
示例一
请问以下示例会不会产生闭包?(这道题不是考你
this
指向哈,别搞错了)
var name = 'Frankie'
var obj = {
name: 'Mandy',
sayHi: function () {
return function () {
console.log(this.name)
obj.sayHi()()
答案是 NO。我们可以在控制台看到。
然后再修改下,当
obj.sayHi()
返回的匿名函数被调用时,存在对
obj.sayHi
方法的引用。因此
obj.sayHi
就是一个闭包。
var name = 'Frankie'
var obj = {
name: 'Mandy',
sayHi: function () {
var _this = this
return function () {
console.log(_this.name)
obj.sayHi()()
我们知道箭头函数内部不存在
this
,因此无论
obj.sayHi()
返回的匿名箭头函数怎样调用,最终
this
都指向
obj
对象。但我的疑问在于,以下示例会不会产生闭包?
var name = 'Frankie'
var obj = {
name: 'Mandy',
sayHi: function () {
return () => {
console.log(this.name)
// console.log(arguments) // arguments 会产生闭包,而 this 是不会的
obj.sayHi()()
答案还是 NO。其实我这里是有个疑问的,按道理箭头函数不存在
arguments
和
this
对象,若在监听函数内访问这两个对象,都应该产生闭包。
但事实是,
this
引用不会使得
sayHi
称为闭包。但是若箭头函数内引用了
arguments
对象,则会产生闭包。
这一点要注意下!
示例二
经典面试题,哈哈!
for (var i = 1; i <= 3; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}
上述示例打印结果是:
3、3、3
(时间间隔一秒)。如果要每间隔一秒分别输出:
1、2、3
,怎么处理?解决方案很简单。
解决方法一:
给
setTimeout
披一个函数,即多一层作用域。
for (var i = 1; i <= 3; i++) {
(function fn(i) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
})(i)
}
我就不打断点了,直接从执行过程分析:
1. 全局代码开始执行
ECStack = [ GlobalContext.VO ]
2. 开始执行 for 循环,fn 的函数上下文初始化如下,
FunctionalContext<fn> = {
AO: {
arguments: {
0: 1,
length: 1
i: 1,
timer: ƒ timer()
Scope: [ GlobalContext.VO, AO ],
this: undefined
当 timer 声明的时候,它的 [[scope]] 就确定了,即 FunctionalContext<fn>.Scope
由于 fn 内部存在一个函数 timer,且 timer 中的 i 引用了 fn 中的 AO 变量,
因此 fn 形成闭包。
3. 后面两次循环同理...
4. 一秒后,会调用 timer 函数,然后进入 timer 函数执行上下文,并初始化:
FunctionalContext<timer> = {
AO: {
arguments: {
length: 0
Scope: [ GlobalContext.VO, FunctionalContext<fn>.AO, AO ],
this: undefined
执行 timer 内部代码时,要查找 a 变量,首先当前 AO 没有,
接着往 FunctionalContext<fn>.AO 上面找,于是就找到了 a 为 1。
然后 timer 执行完毕。
5. 又过了一秒,又会触发 timer 函数,过程同上。
6. 但注意每次循环执行的 fn 函数都不是同一个函数哦,它们原先执行上下文的 AO 对象
被保存至 timer 函数 [[scope]] 里面了。
因此,每次执行 timer 函数的时候,i 都是不一样的。
7. 所以按照这样去改造的话,就能每间隔一秒分别输出:1、2、3
解决方法二:使用
let
来声明变量
i
。
首先请注意
for
语句两种方式的区别,如下:
/* 全局作用域 */
for (let i = 1; ;/* 块级作用域 1 */) {
/* 块级作用域 2 */
/* 全局作用域 */
for (var j = 1; ;/* 全局作用域 */) {
/* 全局作用域 */
}
for (let i = 1; i <= 3; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}
三个作用域的体现:
四、我认为的闭包
在我的理解里
, 每个 JavaScript 函数都是闭包。或者说某个函数引用了其作用域外的变量,那么这个函数可以被认为是闭包。
尽管 Chrome 浏览器不认同我的说法,也不会影响我理解和使用闭包,因为我已经知道了作用域链与闭包直接相关。
然后插一段(fei)话,不想看的话直接跳到下一节。
我在学习闭包的之前,先整体了 JS 整个加载、编译和执行过程。其实学习还是其他,都应该从宏观和微观的角度分析。它们的过程是循序渐进的。
我猜,可能还有挺多有一定经验的 JSer 不知道 JS 脚本是按块加载的。按块加载什么意思?比如我们的网页有两个 JS 脚本(即两对
<script>
标签)。JS 引擎会先对其中一块进行编译与执行的过程,完成之后,才开始对下一个脚本进行编译与执行。假设你不了解,可能误以为 JS 引擎会通篇扫描所有脚本的语法,然后再按顺序(或不按顺序)执行。这是不对的。
因此学习闭包也是一样的道理,请先了解 JavaScript 代码从编译到执行的过程。
编译阶段:
执行阶段:
执行上下文栈
出栈/入栈
创建执行上下文
初始化执行上下文:
变量对象/活动对象(VO/AO):
创建时就确定函数的 [[scope]](要学好闭包,这玩意要弄明白)
作用域链(Scope Chain)
代码执行:
词法作用域:
什么是词法作用域?
什么是动态作用域?
等这些内容都属性之后,再结合本文或其他大佬的文章,闭包就自然而然就懂了。如果跳过以上内容,直接看闭包,我认为是很难理解的,即使好像当时看懂了,但很快就会忘了。
The end.