闭包
初步理解闭包
接触 JavaScript 的闭包概念前,我在阅读部分前端项目代码时,下意识带着阅读 C++ 代码时的思维惯性来分析 JavaScript 代码,从而造成对一些变量的值,做出错误判断。经过系统性学习后了解到,JavaScript 中作用域的机制与 C++ 有很大区别,其中相关联的一个重要机制便是闭包(Closure)。
闭包又称词法闭包或函数闭包,是在支持头等函数(即函数可以像普通变量那样被传递和赋值)的语言中实现词法绑定的一种技术。
闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包含约束变量(该函数内部绑定的符号),也要包含自由变量(在函数外部定义但在函数内被引用)。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被锁定,这样即便脱离捕捉时的上下文,它也能照常运行。这种设计突破了 C++ 等 OOP 语言中对局部变量作用域的限制。捕捉时对于值的处理,可以是值拷贝,也可以是名称引用。
简单来说,闭包就像一个小背包,里面装着两样东西:一是一个特定的函数,二是这个函数需要用到的一些外部变量的集合。这些外部变量有时候是在函数外面定义的,但在函数里面被使用。因为有了这个“小背包”,即便是在函数原来的环境之外,它也能像在原环境一样正常运行。
举个例子:
|
|
第一眼看上去,也许不能直观地看出这段代码能够正常运行。在一些编程语言中,一个函数中的局部变量仅存在于此函数的执行期间。一旦 makeFunc()
执行完毕,我们可能会认为 name
变量将不能再被访问。然而,代码仍按预期运行,这便是闭包的效果。
在 JavaScript 中,函数会形成闭包。在本例中,myFunc
是执行 makeFunc
时创建的 displayName
函数实例的引用。displayName
的实例维持了一个对它的词法环境(变量 name
存在于其中)的引用。因此,当 myFunc
被调用时,变量 name
仍然可用,其值 Mozilla
就被传递到alert
中。
闭包和匿名函数经常被用作同义词。但严格来说,匿名函数就是字面意义上没有被赋予名称的函数,而闭包则实际上是一个函数的实例,也就是说它是存在于内存里的某个结构体。如果从实现上来看的话,匿名函数如果没有捕捉自由变量,那么它其实可以被实现为一个函数指针,或者直接内联到调用点,如果它捕捉了自由变量那么它将是一个闭包;而闭包则意味着同时包括函数指针和环境两个关键元素。在编译优化当中,没有捕捉自由变量的闭包可以被优化成普通函数,这样就无需分配闭包结构体。
闭包的实现
实现闭包的关键在于理解函数作用域和词法作用域。在 JavaScript 中,函数可以嵌套定义,内部函数可以访问外部函数的变量。当内部函数被返回并在外部函数的作用域之外调用时,即使外部函数的执行上下文已经结束,内部函数仍然可以访问外部函数的变量,这就形成了闭包。例如:
|
|
-
定义一个外部函数
在这个例子中,
outerFunction
是外部函数,它有一个局部变量outerVariable
和一个内部函数innerFunction
。innerFunction
可以访问outerVariable
,即使是在outerFunction
执行完成后。 -
返回内部函数
外部函数返回内部函数,是创建闭包的关键步骤,因为它允许内部函数在外部函数执行完毕后仍然存在。
-
在外部调用内部函数
调用外部函数,并将返回的内部函数赋值给一个变量。然后,你可以在外部函数的作用域之外调用这个内部函数。此时,闭包已经形成,内部函数依然可以访问外部函数的变量。
在 JavaScript 中,闭包是自然而然形成的,任何函数都能成为闭包。而在 C++ 中,并没有直接被称为“闭包”的概念,但可以通过 Lambda 表达式和函数对象实现类似闭包的功能。
C++11 引入的 Lambda 表达式可以捕获变量的副本(值捕获)或引用(引用捕获),从而在函数对象的生命周期内保持对这些变量的访问,例如:
|
|
闭包的应用
1. 封装私有变量
在 JavaScript 中,闭包通常用于封装私有变量,提供一个公共的方法来访问和修改私有变量,从而实现封装和隐藏数据(这点类似于 OOP 语言中对于 private
成员的访问控制),例如:
|
|
这对于创建具有私有状态的对象非常有用,可以减少外部对内部状态的依赖和意外修改。比如在一个 Web 应用中,可能需要创建一个用户对象,这个对象需要保存用户的信息,但是我们不希望这些信息可以被外部直接修改,以防止数据被意外篡改。
2. 创建模块
在 JavaScript 中,模块化可以帮助我们将代码划分为独立的功能块,每个模块有其私有的作用域,以便组织代码,避免全局命名空间污染,同时提高代码的可维护性和复用性。通过闭包,我们可以创建模块,其中的变量和函数默认是私有的,如果想要外部访问,需要显式地暴露接口。
|
|
这里,myModule
是一个具有公共方法 publicMethod
的对象,而 _privateVar
和 _privateMethod
都是只能在模块内部访问的私有成员。
3. 在循环中使用闭包解决 var 问题
在循环中使用 var
声明变量时,由于 var
的作用域是函数作用域,循环结束后变量的值为最后一次迭代的值。通过闭包,我们可以为每次迭代创建一个新的作用域。
|
|
这里,通过创建一个立即执行函数表达式(IIFE),为每次循环创建了一个新的作用域,从而确保循环内部的延时函数能够访问到各自迭代的正确值。在实际开发中,经常需要在循环中进行异步操作,如设置超时、发送网络请求等。使用闭包可以确保异步操作中引用的变量值是正确的,而不是循环结束时的最终值。
闭包的问题
闭包在为模块化、数据封装和异步编程等高级功能提供便利的同时,也存在一些需要格外注意的问题。
-
内存占用,如前文所记录的,“闭包实际上是一个函数的实例,是存在于内存中的一个结构体”,因此其绑定的外部变量会一直保存在内存中,直到闭包自身被销毁。这意味着如果闭包长时间不被销毁,它绑定的变量也无法被 GC 回收,可能导致内存泄漏。
所以,如果一个闭包不再被使用,确保断开与外部变量的连接,比如将闭包赋值为
null
,以便垃圾回收机制可以正确回收不再使用的内存。 -
性能问题,使用闭包可以使代码结构更清晰,但也可能带来性能开销。创建闭包比普通函数调用更复杂,需要更多的时间和内存。尤其是在高性能要求的场景下(如大量的DOM操作、游戏开发等),过度使用闭包可能会导致页面响应变慢或卡顿。
-
变量作用域问题,闭包内部能访问到其外部函数的变量,这在很多时候是方便的,但也可能导致不小心修改了这些外部变量的值。特别是在使用循环和异步编程时,如果不正确地理解闭包作用域,很容易写出逻辑错误的代码。
建议避免在循环中创建闭包,尤其是对于绑定事件处理器或设置定时器时,避免在循环中直接创建闭包。可以考虑使用其他技术,比如事件委托,或者使用
let
关键字声明循环变量,因为let
有块级作用域特性。如果闭包引用了 DOM 元素,并且 DOM 元素也引用了这个闭包(比如,通过事件监听器),确保在不需要它们时,移除事件监听器并断开引用。
|
|