关于闭包的一些理解
序言
又到了一年一度的 "金三银四" 求职季,几乎每个公司都会在前端面试时问到关于 闭包
的问题。下面我讲一下我自己关于它的一个理解,可能不全面,希望能作为同学们的一个参考,有错误的地方也希望在评论区指正,共同进步!
作用域
在我们了解闭包前来看一段简单的代码在浏览器运行时的示意图:
(图一)
我们知道js在浏览器中总共有两种类型--全局变量和局部变量,全局执行环境被认为是window
对象,因此所有全局变量和函数都是作为window
对象的属性和方法创建的。图中的num = 1
和getNum
在全局状态下声明,那么它们自然就指向ECS
中的main()
。接下来是函数 getNum
,在我们定义函数时,首先是用函数名创建的一个变量,创建函数对象保存该函数的定义,函数名变量再引用函数对象,函数对象用隐藏的scope
属性引用window
。
我们可以在浏览器 debug 看下执行的情况:
(图二)
(图三)
我们可以从上面两张图中可以看出全局变量num
和 函数getNum
里的局部变量num
的执行情况,同时也帮我们把函数里的this
指向展示了出来,这里就不扩展this
。所以我们调用一个函数时,就会向ECS
添加本次函数调用的记录,为本次函数调用创建函数作用域对象,也就是 活动对象(AO),在活动对象中创建所有局部变量,并且自动引用函数的scope
指向的父级作用域。
作用域链
作用域链: 当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。它的用途是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。
从图一和图二中我们能看到,当我们执行num++
时,是以优先局部变量num = 10
为对象执行,如果当前的执行环境中没有声明局部变量num
,那么它就会沿着作用域链逐级向上到全局执行环境,全局执行环境的变量始终是作用域链中的最后一个对象。
在js执行环境里,内部环境可以用作用域链访问到外部环境,但外部环境无法访问内部环境中的变量和函数,当我们最后调用getNum
后,getNum
里定义的所有变量和函数定义也随之销毁,在函数外部全局作用域里打印的num
只能在全局环境里找num
变量,然后打印出num = 1
ok,以上就是一个普通函数执行的过程。假如现在有个需求,要给杰伦的世界巡回演唱会写一个售票系统,记录总共卖出去多少张门票。
以上图的方举例,当我卖一张票,那么我的num
参数就要做一个 +1
的操作,但是这个值如果在 getNum
里作为局部变量操作,那甲方指定是不给我们好果汁吃。如果声明在外部,可以满足需求,但全局变量就会有被污染的可能,也不可行。那么这种时候就需要用到闭包函数。
什么是闭包
红宝书(javascript高级程序设计-第三版) 里的解释是:有权访问另一个函数作用域中的变量和函数。创建闭包的常见方式,就是在一个函数内部创建另一个函数。
MDN上的解释是:一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。
闭包的作用
闭包的作用主要就是兼顾全局变量和局部变量的优点:能重用变量,又不使其被污染,形成一个私有环境,
根据以上的需求,对上面的getNum
函数进行简单的改造:
function outer() {
let ticket = 0
return function () {
ticket++
console.log(ticket);
}
}
let getNum = outer();
getNum()
getNum()
ticket = 1
getNum()
getNum()
在浏览器debug看下这段代码的运行情况
(图四)
从图四中我们可以看到咱们当前的门票已经卖了 4张 了,并且即使全局插入一个ticket
参数也不会污染我们内部的参数。
我们用一个示意图来表示下当前这段代码的大概意思:
(图五)
我们声明outer
函数,它创建了一个活动对象ticket
,又创建一个function
,它的scope
指向outer
的AO
,outer
将function
返回给了getNum
, 于是就形成了闭包。
(图六)
当我们执行下一个getNum
时,它会优先找自己的AO
,自己的AO
没有ticket
,然后找向outer
。outer
有了,然后执行+1.
那为什么在中间插入ticket
不会干扰结果呢?因为在整条作用域链里,outer
里始终都有ticket
参数,执行过程中既然都找到要的参数了,就不会再继续向后找,所以也就不会影响执行结果。
闭包的缺点
从上面的例子可以看出,闭包的优点是重用变量,保护变量不被污染,那显而易见的缺点就是无法在调用后释放,占用我们的内存空间。
为什么说占用空间呢?我们可以在图五里看到内存里一直开辟着当前这个闭包的空间,浏览器的垃圾回收无法对其清除。
(图七)
解决办法:一旦不再使用闭包,应及时释放。
以上面的demo为例,将保存内层函数的变量getNum
赋值为 null
,断开外部变量与内层函数的引用关系,释放outer
的AO
,由于AO
的释放,就销毁掉outer
的闭包关联,内存里就剩下getNum = null
,后面的事就交由浏览器的垃圾回收来处理。
(图八)
闭包的应用
除了上面例子提到的卖票,排队取号场景,还有常见的节流和防抖,柯里化函数等,Vue
开发者最常见的应该还是Vue
里的data
函数,在pinia
里的函数式写法以及Vue
和React
的各种hooks
函数。
// pinia
const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})
总结
- 什么是闭包:重用变量,又保护变量不受污染的一种机制。
- 什么时候用:柯里化,模拟私有方法,回调函数。
- 闭包的缺点:外层函数调用后,外层函数的函数作用域对象无法释放,造成执行环境里的内存泄漏。
- 如何解决:a. 将保存内层函数的变量赋值为
null
; b. 在DOM
上绑定事件里的回调函数及时销毁或采用事件委托的方式减少内存开销;
参考文献:
- javascript高级程序设计-第三版 - 变量,作用域和内存问题
- MDN-闭包
转载自:https://juejin.cn/post/7209625823580717112