继上一篇文章 JavaScript 脚本编译与执行过程简述,再来介绍一下 JavaScript 中神奇的“闭包”(Closure)。
闭包是基于词法作用域书写代码时所产生的自然结果。
JavaScript 语言是采用了词法作用域。一般情况下,函数、变量的作用域在编写的时候已经确定且不可改变的。除了 eval
、with
之外,它们会在运行的时候“修改”词法作用域,但实际项目中,几乎很少用到它们,欺骗词法作用域会有性能问题,我们可以忽略。
还有,千万别把 this
跟作用域混淆在一起,this
与函数调用有关,可以说是“动态”的。而作用域是静态的,跟函数怎样调用没关系。词法作用域也被叫做“静态作用域”。
若对词法作用域、执行上下文、变量对象、作用域链等内容不熟悉的话,建议先学习相关知识。到时回来再看闭包的时候,就非常容易理解了。
- JavaScript 深入系列 15 篇(冴羽)
- JavaScript 专题(Veda)
一、概念
无论网上文章,还是各类书籍,对闭包的定义都不尽相同。列举几个:
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'
fn()
}
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 //