因为,你没有遇到一个好老师,告诉你闭包的本质。本质的东西往往都是通俗易懂的,你只需要花费几分钟,就能彻底消化你自己摸索了几年都掌握不好的一个小知识。
这篇文章,我先用用一个简短的章节,来通过本质学习闭包,看看你能学好不。
在 JavaScript 中,大多数情况下,函数就是一个作用域。在理解闭包之前,我们需要对作用域有一个非常准确的认知。
如果没有作用域,那么我们所有声明的变量、函数等都在全局中了,此时在多人协作或者复杂的项目中,不同的功能之间极大概率会出现命名重复,相互干扰等重大问题。因此,我们需要引入作用域机制,用于隔离不同功能块,避免他们相互影响。
作用域的本质就是隔离
但是,这种隔离是一种绝对隔离,如下所示,函数 a 与函数 b 之间,他们的内部变量是无法相互访问的。
1function a() {}23function b() {}
问题就在于,这种绝对隔离覆盖不了所有场景。许多时候,我们又希望作用域之间的变量,是可以相互访问的。因此,我们需要隔离,我们也需要共享。那我们应该怎么办呢?
一个简单的办法就是把你希望他们相互访问的变量放到全局去
1var m = 2023function a() {4console.log(m)5}67function b() {8console.log(m)9}
这样,他们就能共同访问同一个变量。但是,我们需要尽量避免往全局中放变量,因为依然可能会相互干扰,所以把变量往全局放不是一个靠谱的方案。那又该怎么办?
一个简单的方案,就是再给他们套一个父级函数,并将他们想要共同访问的变量定义在父级作用域。
10function p() {20let m = 2030function a() {40console.log(m)50}6070function b() {80console.log(m)90}10}
这样,我们就达到了目标。这,就是闭包。我们用一句话来总结一下这个现象:闭包的本质就是:局部数据共享。
**局部:**指的是函数作用域,非全局。
**数据:**指的是 m
**共享:**指的是不同作用域之间能共同访问。
这样理解,闭包就非常简单了,以后,我们凡事看到有局部数据共享的地方,就一定是闭包在起作用。
闭包本身就是为了解决作用域隔离的前提下,需要局部数据共享的场景而出现的一个技术方案
因此,我们再来看看如下几个例子巩固一下这个结论。
1function init() {2var name = "Mozilla"; // name 是一个被 init 创建的局部变量3function displayName() {4alert(name); // 使用了父函数中声明的变量5}6displayName();7}8init();
这个例子中,作用域 init 与作用域 displayName 共享数据 name
,因此闭包存在。
再来一个例子
10function makeFunc() {20var name = "Mozilla";30function displayName() {40alert(name);50}60return displayName;70}8090var myFunc = makeFunc();10myFunc();
这个例子中,作用域 makeFunc 与作用域 displayName 共享数据 name
,因此闭包存在
我们要特别注意,特别容易理解错的地方是,闭包是否存在,只与是否存在局部数据共享有关,与函数是否存在返回值,返回方式,返回函数的调用方式无关。
最后一个例子,makeAdder 与 匿名函数共享数据 x,因此闭包存在。
10function makeAdder(x) {20return function (y) {30return x + y;40};50}6070var add5 = makeAdder(5);80var add10 = makeAdder(10);9010console.log(add5(2)); // 711console.log(add10(2)); // 12
我们把刚才最开始那个共享 m 的例子稍作改动,感受一下是不是很熟悉,你有信心在这个基础之上把他扩展成一个发布订阅模块吗?
10function p() {20var count = 03040function get() {50return count60}7080function set(value) {90count = value10}1112return { get, set }13}1415const {get, set} = p()
最后,我们再来分析一下 MDN 上,针对闭包的定义
一个函数,表示一个作用域。
周边环境的状态,他这里特别说明了是词法环境,在 js 中,词法环境,就是词法作用域,所以这里表达的是另外一个作用域。
用通俗一点的方式来说,就是,一个函数,与另外一个函数的引用组合在一起,就是闭包。这里讲的就是局部数据共享
因此,总结下来说就是,作用域是为了隔离,闭包是为了在隔离的基础之上实现局部数据共享。
通过我这样的讲解,你理解了没?理解了之后,接下来再开始闭包的基础学习吧!
在 JavaScript 中闭包是一个特殊的对象。
凡是没有将闭包,定义为对象的说法,都是错误的。
词法作用域并非 JavaScript 特有的说法,通常我们也直接称之为作用域。词法作用域表达的是一个静态关系,通常情况下,我们在代码编写时,语法规范就已经确定了词法作用域的作用范围。它具体体现在代码解析阶段,通过词法分析确认。
JavaScript 的词法作用域通过函数的 [[Scopes]] 属性来具体体现。而函数的 [[Scopes]] 属性,是在预解析阶段确认。
词法作用域是为了明确的告诉我们,当前的上下文环境中,能够访问哪些变量参与程序运行。在函数的执行上下文中,除了自身上下文中能够直接访问的声明之外,还能从函数体的 [[Scopes]] 属性访问其他作用域中的声明。
一个简单的例子
10const g = 102030function foo() {40let a = 10;50let b = 20;6070function bar() {80a = a + 1;90const c = 30;1011return a + b + c;12}1314console.dir(bar)15return bar16}1718foo()
仅从语法上,我们就可以知道,函数 bar 能访问的声明,除了自身声明的变量 c 之外,还能访问 foo 声明的变量 a 与 b,以及全局声明的变量 g。最后还能访问整个全局对象。
能够访问自身的变量 c,具体体现为当前函数上下文中创建的 Local 对象。而其他的,则全部都体现在函数的 [[Scopes]] 属性中。如图。
在上面例子里,函数 bar 的 [[Scopes]] 中,有一个特殊的对象,Closure,就是我们将要学习的闭包对象。
从概念上来说,闭包是一个特殊的对象,当函数 A 内部创建函数 B,并且函数 B 访问函数 A 中声明的变量等声明时,闭包就会产生。
例如上面的例子中,函数 foo 内部创建了函数 bar,并且在 bar 中,访问了 foo 中声明的变量 a 与 b,此时就会创建一个闭包。闭包是基于词法作用域的规则产生,让函数内部可以访问函数外部的声明。闭包在代码解析时就能确定。
从具体实现上来说,对于函数 bar 而言,闭包对象「Closure (foo)」的引用存在于自身的 [[Scopes]] 属性中。也就是说,只要函数体 bar 在内存中持久存在,闭包就会持久存在。而如果函数体被回收,闭包对象同样会被回收。
「此处消除一个大多数人的误解:认为闭包在内存中永远都不会被回收,实际情况并不是这样的」
通过前面的函数调用栈章节我们知道,在预解析阶段,函数声明会创建一个函数体,并在代码中持久存在。但是并非所有的函数体都能够持久存在。上面的例子就是一个非常典型的案例。函数 foo 的函数体能够在内存中持久存在,原因在于 foo 在全局上下文中声明,foo 的引用始终存在。因此我们总能访问到 foo。而函数 bar 则不同,函数 bar 是在函数 foo 的执行上下文中声明,当执行上下文执行完毕,执行上下文会被回收,在 foo 执行上下文中声明的函数 bar,也会被回收。如果不做特殊处理,foo 与 bar 产生的闭包对象,同样会被回收。
微调上面的案例,多次调用 foo 的返回函数 bar 并打印 a 的值。
10const g = 102030function foo() {40let a = 10;50let b = 20;6070function bar() {80a = a + 1;90console.log(a)10const c = 30;1112return a + b + c;13}14console.dir(bar)15return bar16}1718// 函数作为返回值的应用:此时实际调用的是 bar 函数19foo()()20foo()()21foo()()22foo()()
分析一下执行过程。
当函数 foo 执行,会创建函数体 bar,并作为 foo 的返回值。foo 调用完毕,则对应创建的执行上下文会被回收,此时 bar 作为 foo 执行上下文的一部分,自然也会被回收。那么保存在 foo.[[Scopes]] 上的闭包对象,自然也会被回收。
因此,多次执行 foo()(),实际上是在创建多个不同的 foo 执行上下文,中间与 bar 创建的闭包对象,始终都没有被保存下来,会随着 foo 的上下文一同被回收。因此,多次执行 foo()(),实际上创建了不同的闭包对象,他们也不会被保留下来,相互之间也不会受到影响。如图
这个过程,也体现了 JavaScript 边执行边解析的特性
而当我们使用一些方式,保留了函数体 bar 的引用,情况就会发生变化,微调上面的代码如下:
10const g = 102030function foo() {40let a = 10;50let b = 20;6070function bar() {80a = a + 1;90console.log(a)10const c = 30;1112return a + b + c;13}14console.dir(bar)15return bar16}1718// 在全局上下文中,保留 foo 的执行结果,也就是 内部函数 bar 的引用19var bar = foo()2021// 多次执行22bar()23bar()24bar()
分析一下,微调之后,代码中,在全局上下文使用新的变量 bar 保存了 foo 的内部函数 bar 的引用。也就意味着,即使 foo 执行完毕,foo 的上下文会被回收,但是由于函数 bar 有新的方式保存引用,那么即使函数体 bar 是属于 foo 上下文的一部分,它也不会被回收,而会在内存中持续存在。
因此,当 bar 多次执行,其实执行的是同一个函数体。所以函数体 bar 中的闭包对象「Closure (foo)」也是同一个。那么在 bar 函数内部修改的变量 a,就会出现累加的视觉效果。因为他们在不停的修改同一个闭包对象。
再次微调
10const g = 102030function foo() {40let a = 10;50let b = 20;6070function bar() {80a = a + 1;90console.log(a)10const c = 30;1112return a + b + c;13}14console.dir(bar)15return bar16}1718// 在全局上下文中,保留 foo 的执行结果,也就是 内部函数 bar 的引用19var bar1 = foo()2021// 多次执行22bar1()23bar1()24bar1()2526// 在全局上下文中,保留 foo 的执行结果,也就是 内部函数 bar 的引用27var bar2 = foo()2829// 多次执行30bar2()31bar2()32bar2()
调整之后我们观察一下。
虽然 bar1 与 bar2 都是在保存 foo 执行结果返回的 bar 函数的引用。但是他们对应的函数体却不是同一个。foo 每次执行都会创建新的上下文,因此 bar1 和 bar2 是不同的 bar 函数引用。因此他们对应的闭包对象也就不同。所以执行结果就表现为:
闭包的产生非常简单,只需要在函数内部声明函数,并且内部函数访问上层函数作用域中的声明就会产生闭包
闭包对象真实存在于函数体的 [[Scopes]] 属性之中
闭包对象是在代码解析阶段,根据词法作用域的规则产生
闭包对象并非不能被垃圾回收机制回收,仍然需要视情况而定
透彻理解闭包的真实体现,要结合引用数据类型,作用域链,执行上下文和内存管理一起理解
接下来我们要继续修改上面的例子,来进一步理解闭包。
10function foo() {20let a = 10;30let b = 20;4050function bar() {60a = a + 1;70console.log('in bar', a)80let c = 30;9010function fn() {11a = a + 1;12c = c + 113console.log('in fn', a)14}1516console.dir(fn)17return fn18}1920console.dir(bar)21return bar()22}2324var fn = foo()25fn()26fn()27fn()
函数 foo 中声明函数 bar, 函数 bar 中声明函数 fn。
函数 bar 中访问 函数 foo 中声明的变量 a。显然,此时能生成闭包对象 「Closure (foo)」 函数 fn 中访问 函数 foo 中声明的变量 a,与函数 bar 中声明的变量 c。此时也能生成闭包对象「Closure (foo)」与 「Closure (bar)」
我们会发现,bar.[[Scopes]] 中的闭包对象「Closure (foo)」与 fn.[[Scopes]] 中的闭包对象 「Closure (foo)」是同一个闭包对象。
输入结果如下图所示:
闭包对象 foo 中的变量 a 的值,受到 bar 与 fn 操作共同影响。
下面这些例子中,是否有闭包产生
1function add(x) {2return function _add(y) {3return x + y;4}5}67add(2)(3); // 5
10var name = "window";2030var p = {40name: 'Perter',50getName: function () {60return function () {70return this.name;80}90}10}1112var getName = p.getName();13var _name = getName();14console.log(_name);