Javascript基础系列之闭包
一、什么hi闭包
当通过调用外部函数返回的内部函数后,即使外部函数已经执行结束了,但是被内部函数引用的外部函数的变量依然会保存在内存中,我们把引用了其他函数作用域变量的函数和这些被引用变量的集合,称为闭包(Closure),闭包是这些东西共同的组合。
简单来说,闭包就是指一个函数可以访问另一个函数作用域内的变量。在JavaScript中,每个函数都是一个闭包,因为它们都可以访问自己的作用域内的变量,以及外层函数作用域内的变量
二、如何实现闭包
闭包是指一个函数可以访问它定义时所在的词法作用域以及全局作用域中的变量。在JavaScript中,闭包可以通过函数嵌套和变量引用实现。
function outerFunction() {
let outerVariable = '我在outer函数里!';
function innerFunction() {
console.log(outerVariable);
}
return innerFunction;
}
const innerFunc = outerFunction();
innerFunc(); // 输出: 我在outer函数里!
在上面的代码示例中,innerFunction
引用了outerVariable
,因此JavaScript引擎会保留outerFunction
的作用域链,以便innerFunction
可以访问outerVariable
function a(){
function b(){
var bb = 888
console.log(aa); //输出:666
}
var aa = 666
return b
}
var demo = a()
demo()
在上面的代码示例中,a
函数定义了一个名为aa
的变量和一个名为b
的函数,b
函数引用了aa
变量,因此JavaScript引擎会保留a
函数的作用域链,b
函数可以访问a
函数的执行上下文,b
函数内用到了外部函数a
的变量aa
,在a
函数调用结束后该函数执行上下文会销毁,但会保留一部分留在内存中供b
函数使用,这就形成了闭包。也就是说:当内部函数引用外部函数的变量时,外部函数的作用域链将被保留在内存中,以便内部函数可以访问这些变量。这种函数嵌套和变量共享的方式就是闭包的核心概念。当一个函数返回另一个函数时,它实际上返回了一个闭包,其中包含了原函数定义时的词法作用域和相关变量。
三、什么是作用域链
全局代码存储其变量的地方叫做变量对象(VO),函数存储其变量的叫活动对象(AO),VO 和 AO 都是在预编译时确定其内容,然后在代码运行时被修改值。
每一个函数都有一个 [[Scopes]]
属性,其存储的是这个函数运行时的作用域链,除了当前函数的 AO,作用域链的其他部分都会在其父函数预编译时添加到函数的 [[Scopes]]
属性上(因为父函数也需要预编译后才能确定自己的AO),所以 js 的作用域是词法作用域。
// 1: global.VO = {t}
let t = 111
function fun(){
// 3: fun.AO = {a,b}
let a = 1
let b = 2
function fun1() {
// 5: fun1.AO = {c}
let c = 3
}
// 4: fun1.[[Scopes]] = [...fun.[[Scopes]], fun.AO]
}
// 2: fun.[[Scopes]] = [global.VO]
fun()
上面代码在 `fun()` 被调用前,会立即预编译 `fun` 函数,这一步会得到 `fun` 的活动对象(AO),然后运行 fun 函数,在执行到 `let a = 1` 的时候,会将变量对应到 a 属性改成 1。后面也是一样。`[[Scopes]]` 就像一个数组一样,每一个函数的 `[[Scopes]]` 中都存在当前函数的 AO 和上级函数的 `[[Scopes]]`。在函数运行时会优先取距离当前函数 AO 近的变量值,这就是作用域的就近原则。
但是最新的 V8 中已经发生了变化(Chrome 中已经可以看到这些变化),在为一个函数绑定词法作用域时,并不会粗暴的直接把父函数的 AO 放入其 `[[Scopes]]` 中,而是会分析这个函数中会使用父函数 AO 中的哪些变量,而这些可能会被使用到的变量会被存储在一个叫做 `Closure` 的对象中,每一个函数都有且只有一个 `Closure` 对象,最终这个 `Closure `将会代替父函数的 AO 出现在子函数的 `[[Scopes]]` 中
闭包对象
在V8中每一个函数执行前都会进行预编译,预编译阶段都会执行3个重要的字节码
- CreateFunctionContext 创建函数执行上文
- PushContext 上下文入栈
- CreateClosure 创建函数的闭包对象
也就是说,每一个函数执行前都会创建一个闭包,无论这个闭包是否被使用,那么闭包中的内容是什么?如何确定其内容?
Closure
跟[[Scopes]]
一样会在函数预编译时被确定,区别是当前函数的[[Scopes]]
是在其父函数预编译时确定, 而Closure
是在当前函数预编译时确定(在当前函数执行上下文创建完成入栈后就开始创建闭包对象了)。 当 V8 预编一个函数时,如果遇到内部函数的定义不会选择跳过,而是会快速的扫描这个内部函数中使用到的本函数 AO 中的变量,然后将这些变量的引用加入Closure
对象。再来为这个内部函数函数绑定[[Scopes]]
,并且使用当前函数的Closure
作为内部函数[[Scopes]]
的一部分。
注意:每一次遇到内部声明的函数/方法时都会这么做,无论其内部函数/方法的声明嵌套有多深,并且他们使用的都是同一个 Closure
对象。并且这个过程 是在预编译时进行的而不是在函数运行时
// 1: global.VO = {t}
var t = 111
// 2: fun.[[Scopes]] = [global.VO]
function fun(){
// 3: fun.AO = {a,b},并创建一个空的闭包对象fun.Closure = {}
let a = 1,b = 2,c = 3
// 4: 遇到函数,解析到函数会使用a,所以 fun.Closure={a:1} (实际没这么简单)
// 5: fun1.[[Scopes]] = [global.VO, fun.Closure]
function fun1() {
debugger
console.log(a)
}
fun1()
let obj = {
// 6: 遇到函数,解析到函数会使用b,所以 fun.Closure={a:1,b:2}
// 7: method.[[Scopes]] = [global.VO, fun.Closure]
method(){
console.log(b)
}
}
}
// 执行到这里时,预编译 fun
fun()
1、2发生在全局代码的预编译阶段,3、4、5、6、7发生在 fun 的预编译阶段
fun1 执行时的作用域链是这样的:[fun1.AO, fun.Closure, global.VO]
我们可以看到 fun1
的作用域链中的确不存在 fun.AO
,而是存在 fun.Closure
。并且 fun.Closure
中的内容是 a
和 b
两个变量,并没有 c
。这足以证明所有子函数使用的是同一个闭包对象
细心的你会发现 Closure
在 method
的定义执行前就已经包含 b
变量,这说明 Closure
在函数执行前早已确定好了,还有一点就是 Closure
中的变量存储的是对应变量的引用地址,如果这个变量值发生变化,那么 Closure
中对应的变量也会发生变化(后面会证明)。
而且这里 fun1
并没有返回到外部调用形成网络上描述的闭包(网络上很多说法是需要返回一个函数才会形成闭包,很显然这也是不对的),而是直接在函数内部同步调用。
**结论:每一个函数都会产生闭包,无论 闭包中是否存在内部函数 或者 内部函数中是否访问了当前函数变量 又或者 是否返回了内部函数,因为闭包在当前函数预编译阶段就已经创建了。
**
四、闭包优缺点
优点
-
保护单例实例,避免被外部意外修改。
闭包可以实现变量的私有化,这为单例模式的实现提供了基础。下面我们通过示例代码展示如何使用闭包实现单例模式
class Singleton { private static instance: Singleton; private constructor() { // 私有构造函数,防止外部直接实例化 } static getInstance(): Singleton { if (!Singleton.instance) { Singleton.instance = new Singleton(); } return Singleton.instance; } public property = "This is a singleton instance"; } const instance1 = Singleton.getInstance(); const instance2 = Singleton.getInstance(); console.log(instance1 === instance2); // 输出 true,说明只创建了一个实例
在这个示例中,我们使用了一个自执行函数表达式(IIFE),创建了一个只能被访问一次的私有变量
instance
。getInstance
方法用于获取单例对象,如果instance
未被创建,它会调用createInstance
方法创建一个实例并将其赋值给instance
。当再次调用getInstance
时,将返回已创建的实例 总结:通过上面的示例代码我们不难看出这种模式的优缺点。以闭包实现的单例模式具有以下优点:- 保护单例实例,避免被外部意外修改。
- 实现了懒加载,只有在真正需要时才创建实例。
它的缺点也非常明显:
- 违反单一职责原则,因实例对象要可能会承担多个职责。这可能会导致代码的复杂度和维护成本的增加。
- 由于闭包的特性,可能会导致内存占用较高
-
实现了懒加载,只有在真正需要时才创建实例。
-
做缓存 函数一旦被执行完毕,其内存就会被销毁,而闭包的存在,就可以保有内部环境的作用域
function foo(){
var myName ='张三'
let test1 = 1
const test2 = 2
var innerBar={
getName: function(){
console.log(test1);
return myName
},
setName:function(newName){
myName = newName
}
}
return innerBar
}
var bar = foo()
console.log(bar.getName()); //输出:1 张三
bar.setName('李四')
console.log(bar.getName()); //输出:1 李四
这里var bar = foo() 执行完后本来应该被销毁,但是因为形成了闭包,所以导致foo执行上下文没有被销毁干净,被引用了的变量myName、test1没被销毁,闭包里存放的就是变量myName、test1,这个闭包就像是setName、getName的专属背包,setName、getName依然可以使用foo执行上下文中的test1和myName。 5. 模块化编程 闭包还可以用于实现模块化编程。模块化编程是一种将程序拆分成小的、独立的、可重用的模块的编程风格。闭包可以用于封装模块的私有变量和方法,以便防止其被外部访问和修改。例如:
const myModule = (function() {
let privateVariable = '我是私有的!';
function privateMethod() {
console.log(privateVariable);
}
return {
publicMethod: function() {
privateMethod();
}
};
})();
myModule.publicMethod(); // 输出: 我是私有的!
在上面的代码示例中,myModule实际上是一个立即执行的匿名函数,它返回了一个包含publicMethod的对象。在函数内部,定义了一个私有变量privateVariable和一个私有方法privateMethod。publicMethod是一个公共方法,它可以访问privateMethod,但是无法访问privateVariable。这种方式可以用于实现简单的模块化编程
6. 封装私有变量
闭包可以用于封装私有变量,以防止其被外部访问和修改。封装私有变量可以一定程度上防止全局变量污染,使用闭包封装私有变量可以将这些变量限制在函数内部或模块内部,从而减少了全局变量的数量,降低了全局变量被误用或意外修改的风险。
在下面这个例子中,调用函数,输出的结果都是1,但是显然我们的代码效果是想让count
每次加一的。
function add() {
let count = 0;
count++;
console.log(count);
}
add() //输出1
add() //输出1
add() //输出1
一种显而易见的方法是将count
提到函数体外,作为全局变量。这么做当然是可以解决问题,但是在实际开发中,一个项目由多人共同开发,你不清楚别人定义的变量名称是什么,这么做有点冒险,有什么其他的办法可以解决这个问题呢?
function add(){
let count = 0
function a(){
count++
console.log(count);
}
return a
}
var res = add()
res() //1
res() //2
res() //3
在上面的代码示例中,add
函数返回了一个闭包a,其中包含了count
变量。由于count
只在add
函数内部定义,因此外部无法直接访问它。但是,由于a
函数引用了count
变量,因此count
变量的值可以在闭包内部被修改和访问。这种方式可以用于封装一些私有的数据和逻辑
缺点
-
内存泄漏 说到闭包那么就不得不说内存泄漏,首先我们要搞清楚为什么会内存泄漏?
所谓闭包产生的内存泄漏就是因为闭包对象
Closure
无法被释放回收,那么什么情况下Closure
才会被回收呢?
这当然是在没有任何地方引用 Closure
的时候,因为 Closure
会被所有的子函数的作用域
链 [[Scopes]]
引用,所以想要 Closure
不被引用就需要所有子函数都被销毁,从而导致所有子函数的 [[Scopes]]
被销毁,然后 Closure
才会被销毁。
常见的说法是必须返回的函数中使用的自由变量才会产生闭包,也就是下面这样:
function fun(){
let arr = Array(10000000)
return function(){
console.log(arr);// 使用了 arr
}
}
window.f = fun()
即使返回的的函数没有访问自由变量,只要有任何一个函数将 arr 添加到闭包对象 Closure
中,arr 都不会正常被销毁,所以下面两段代码都会产生内存泄漏
function fun(){
let arr = Array(10000000)
function fun1(){// arr 加入 Closure
console.log(arr)
}
return function fun2(){}
}
window.f = fun()// 长久持有fun2的引用
因为 fun1
让 arr
加入了 Closure
,fun2
又被 window.f
持有引用无法释放,因为 fun2
的作用域链包含 Closure
,所以 Closure
也无法释放,最终导致 arr
无法释放产生内存泄漏
function fun(){
let arr = Array(10000000)
function fun1() {// arr 加入 Closure
console.log(arr)
}
window.obj = {// 长久持有 window.obj.method 的引用
method(){}
}
}
fun()
同理是因为 window.obj.method
作用域链持有 fun1
的 Closure
引用导致 arr
无法释放
那么如何将 arr = null
会不会让 arr
被释放呢?答案是会。这里有人可能会疑惑了:
Closure.arr = arr
将 arr
加入到 Closure
,然后将 arr = null
,这为什么会让 Closure.arr
发生变化呢?
这说明将变量加入到 Closure
并不是简单的 Closure.arr = arr
的过程,这是一个引用传递,也就是说 Closure.arr
存储的是对变量 arr
的引用,当 arr
变化时 Closure.arr
也会发生变化。这对于 js 来说可能有点难实现,但是 c++ 借助指针的特性要实现这一点是轻而易举的。
上面我们简单的介绍了一下闭包产生内存泄漏的根本原因是因为 Closure
被其所有子函数的作用域链引用,只要有一个子函数没有销毁,Closure
就无法销毁,导致其中的变量也无法销毁,最终产生了内存泄漏。
如何查看内存泄漏?
打开Chrome浏览器的控制台的 Performance monitor,看到 JS heap size 变化曲线了吗?如果它不断上升并且你 点击 Memory 中这个垃圾回收的按钮后它依然没有下降到正常值,那么你的代码大概率是发生了内存泄漏。 现在我执行了一段上面的demo,可以看到内存大小是上升了一个量级
过了一段时间发现他并没有下降的趋势,即使我手动点击垃圾回收按钮,内存也没有回到最开始的正常值,很明显,这就是内存泄漏
demo实例:
let theThing = null;
let replaceThing = function () {
let leak = theThing;
function unused () {
if (leak){}
};
theThing = {
longStr: new Array(1000000),
someMethod: function () {
}
};
};
let index = 0;
while(index < 100){
replaceThing()
index++;
}
发现上面代码发生内存泄漏的原因是因为 someMethod
,因为 theThing
是全局变量导致 someMethod
无法释放最终导致 replaceThing
的 Closure
无法释放。但是 replaceThing
的 Closure
中存在什么呢?
let leak = theThing;
function unused () { // leak 加入 Closure
if (leak){}
};
存在 leak
,又因为 leak
指向的是 theThing
的值,虽然首次执行 replaceThing
时 theThing
是 null
,但是第二次执行 replaceThing
时 theThing
就变为了一个存在大对象的对象了。
- 因为
Closure
无法释放导致其中的leak
变量也无法释放,导致theThing
无法释放 theThing
会导致someMethod
无法释放从而导致Closure
无法释放
可能你已经看了几遍,最终开始看出了问题。没错,这是一个循环,theThing
导致 Closure
无法释放,Closure
又导致另一个 theThing
无法释放......
这段代码参数内存泄漏的原因可以是因为一环扣一环的引用引起的,我们把第 i
次 replaceThing
执行时产生的 leak
叫做 leaki
,theThing
叫做 theThingi
, Closure
叫做 Closurei
,如果这个函数执行3次,那么它的引用链路应该是这样的:
theThing3(全局作用域) -> someMethod3 -> Closure3 -> leak3 -> theThing2 -> someMethod2 -> Closure2 -> leak2 -> theThing1 -> someMethod1 -> Closure1 -> leak1 -> theThing0 -> null
可见
replaceThing
每执行一次这个链路中就会多一个 theThing
,因为 theThing.longStr
上一个大对象导致内存飙升并且无法回收(引用的源头总是全局的 theThing )。
最粗暴的解决方法肯定是将全局 theThing
变为 null
,这如同切断水流的源头一样。
但是在 replaceThing
的最后将 leak = null
也可以打破这个微妙的引用链路。因为这可以让 Closure
中的 leak
也变为 null
从而失去对 theThing
的引用,当在下一次执行 replaceThing
时会因为 theThing = xxx
导致原来的 theThing
失去最后的引用而回收掉,这也会让 theThing.someMethod
和 Closure
可以被回收.
let theThing = null;
let replaceThing = function () {
let leak = theThing;
function unused () {
if (leak){}
};
theThing = {
longStr: new Array(1000000),
someMethod: function () {
}
};
leak = null // 解决问题
};
let index = 0;
while(index < 100){
replaceThing()
index++;
}
最终结论:
- 每一个函数在执行之前都会进行预编译,预编译时会创建一个空的闭包对象。
- 每当这个函数预编译时遇到其内部的函数声明时,会快速的扫描内部函数使用了当前函数中的哪些变量,将可能使用到的变量加入到闭包对象中,最终这个闭包对象将作为这些内部函数作用域链中的一员。
- 只有所有内部函数的作用域链都被释放才会释放当前函数的闭包对象,所谓的闭包内存泄漏也就是因为闭包对象无法释放产生的。
- .及时释放闭包:手动调用闭包函数,并将其返回值赋值为null,这样可以让闭包中的变量及时被垃圾回收器回收
- 使用立即执行函数:在创建闭包时,将需要保留的变量传递给一个立即执行函数,并将这些变量作为参数传递给闭包函数,这样可以保留所需的变量,而不会导致其他变量的内存泄漏
转载自:https://juejin.cn/post/7251106409793159223