前言
继上一篇文章 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' 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 // <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) This 代码执行: 闭包 词法作用域: 什么是词法作用域? 什么是动态作用域?
等这些内容都属性之后,再结合本文或其他大佬的文章,闭包就自然而然就懂了。如果跳过以上内容,直接看闭包,我认为是很难理解的,即使好像当时看懂了,但很快就会忘了。
The end.