带你重生☞真正理解闭包概念

3,733 阅读13分钟

对于那些有一点 JavaScript 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作是某种意义上的重生,但是需要付出非常多的努力和牺牲才能理解这个概念。 ——《你不知道的JavaScript》

本文参考《JavaScript权威指南(第6版)》和《你不知道的JavaScript》,来给大家讲解闭包,看能不能带大家重生。

一 、闭包概念描述

《JavaScript权威指南》这样描述:

函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这就是叫闭包。

《你不知道的JavaScript》这样描述:

闭包是基于词法作用域书写代码时所产生的自然结果。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

从以上的描述。要真正理解闭包概念,要先深刻理解以下几个知识点,可以称为闭包前置知识点

二 、闭包前置知识点

1、作用域

《你不知道的JavaScript》这样描述:

作用域可以理解为一套规则,来定义变量存储在哪里,使用的时候怎么找到他们。

作用域是负责收集并维护由变量组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对变量的访问权限。

而我是这么理解的:作用域就是一个独立的对象,里面存储了变量,在对象中定义了一系列的规则,来限制外部访问里面的变量,来区分变量让不同作用域下同名变量不会有冲突。

如下图所示,红框区域就是一个作用域

2、作用域链

2.1 概念

作用域链可以理解为一个全局对象。在不包含嵌套的函数体,作用域链上有两个对象,第一个定义函数参数和局部变量的对象,第二个是全局对象。在一个嵌套的函数体内,作用域链上至少有三个对象。

举个栗子,如图所示这是不包含嵌套的函数体的作用域链

举个栗子,如图所示这是包含嵌套的函数体的作用域链

2.2 使用规则

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。

举个栗子,如图所示

比如要在作用域3中查找变量a的值,但是发现作用域3中没有变量a,就去作用域2中找,发现了变量a就停止了查找。

注意:在查找过程中不会跑去作用域4中查找。因为作用域4不是作用域3的外层嵌套作用域

2.3 创建规则

理解作用域链的创建规则对理解闭包是非常重要的

首先我们定义一个函数的时候,开始就创建并保存了一条作用域链,里面包含一个全局作用域对象,当函数被调用时,会创建一个新对象(作用域)来存储它的变量,并将这个对象添加到开始创建的作用域链上,同时创建一条新的表示调用函数的作用域的“链”。

仔细琢磨一下下面代码,就可以理解。

function foo(a){
	let b = a*3;
	function bar (c){
		console.log(a,b,c)
	}
	bar(b*2)
}
foo(2);//2,6,12
foo(3);//3,9,18

3、词法作用域

词法作用域是作用域的一个工作模型。

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你写代码时将变量和块作用域写在哪里来决定的。

举个栗子,如下图我把let b = a *3 写在foo(){...}这个函数作用域中,那么变量b的作用域就是foo(){...}这个函数作用域。

三、解释闭包

下面以一个非常典型的闭包例子来解释闭包。

function foo(){
    let a = 2;
    function bao(){
        console.log(a)
    }
    return bao
}
let bar=foo();
bar();

上面代码中,闭包是哪个,是foo(){...},还是bao(){...}。用Chrome断点调试一下就知道。

闭包是foo(){...}这个函数,再看一下计算机科学文献是怎么定义闭包的。

这个术语非常古老,是指函数中的变量可以被隐藏在作用域之内,因此看起来是函数将变量“包裹”起来。

上面foo(){...}将变量a隐藏在它的作用域内,从代码上看把变量a包含在函数内。

看到这里你也许会这么想,为什么下面的函数pyh不是闭包,它也把变量b包含在函数内。

function pyh(){
    let b = 2;
    console.log(b)
}

再读一下《JavaScript权威指南》中怎么表述闭包

函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这就是叫闭包。

注意上面说函数内的变量可以保存在函数作用域内,那么pyh函数内的变量b可以保存在pyh(){...}这个函数作用域内。

显然不能的,因为pyh函数执行后,pyh(){...}这个作用域会被销毁,自然变量b就不存在,不会被保存。

浏览器垃圾回收策略,规定如果一个对象没有被引用,会被当垃圾一样回收并销毁。

那怎么不让pyh(){...}作用域不会销毁,很简单,作用域也是个对象,让它被引用,就不会被销毁,这样作用域中的变量b自然也得以保存,这样就实现闭包。

怎么让它被引用?可以通过作用域链来实现。正如上面所说函数对象可以通过作用域链相互关联起来

对pyh函数进行改造一下

function pyh(){
    let b = 2;
    function bao(){
        console.log(b)
    }
    bao();
}

我们用作用域的创建规则来讲解一下pyh(){...}作用域怎么被引用。

pyh函数定义时创建了一条作用域链A,调用时将pyh(){...}作用域添加到作用域链A上,bao函数定义时创建了一条作用域链B,调用时将bao(){...}作用域添加到作用域链B,同时创建一条表示函数调用作用域的“链”,将作用域链A和作用域链B连在一起,相当pyh(){...}作用域嵌套了bao(){...}作用域。

这时候,bao函数中的console.log(b)执行时会去寻找变量b,发现bao(){...}作用域中没有,就会根据作用域链的使用规则pyh(){...}作用域中寻找,找到后使用其中的变量b,这就对pyh(){...}作用域进行引用,导致pyh函数调用后pyh(){...}作用域本来是会被销毁,但是它被bao函数引用了,导致无法销毁得以保存,自然作用域中的变量b也得以保存,这时pyh函数就变成了闭包。

当然pyh函数不是一个完整的闭包,它只运用到闭包规则的一部分,这部分是闭包规则的核心,非常重要。

返回最上面那个典型的闭包例子foo函数,给大家解释一下foo函数怎么形成闭包。

在foo函数外部定义变量bar来存储foo函数返回的结果。foo函数定义时创建一条作用域链A,调用时foo(){...}作用域被添加到作用域链A,bao函数定义时创建了一条作用域链B,调用时将bao(){...}作用域添加到作用域链B,同时创建一条表示函数调用作用域的“链”,将作用域链A和作用域链B连在一起,相当foo(){...}作用域嵌套了bao(){...}作用域。

在foo函数执行完毕时候,返回bao函数,并赋值到外部变量bar上,当执行bar();时,相当调用bao函数,bao函数中的console.log(a)执行时会去寻找变量a,发现bao(){...}作用域中没有,就会根据作用域链的使用规则foo(){...}作用域中寻找,找到后使用其中的变量a,这就对foo(){...}作用域进行引用,导致foo函数调用后foo(){...}作用域本来是会被销毁,但是它被bao函数引用了,导致无法销毁得以保存。

大家注意了,bao函数调用后bao(){...}作用域会被销毁,这时候foo(){...}作用域的引用就会消失,也会被销毁。但是,但是foo函数返回值是bao函数,被外部变量bar引用了,被赋值给外部变量bar,这就导致foo(){...}作用域是无法销毁,那么作用域foo(){...}中的变量a就可以得以保存。这是foo函数就形成了一个闭包,foo函数把变量a包裹起来

理解闭包过程中,要切记一点,函数调用结束后,在函数定义时创建的作用域链式不会马上消失的。

四、再次解释闭包

上面是通过作用域链来解释闭包,大家看起来是不是云里雾里的。其实闭包没那么神秘,难以理解。

在《你不知道的JavaScript》中写的特别好。

JavaScript中闭包无处不在,你只需要能够识别并拥抱它,闭包是基于词法作用域书写代码时所产生的自然结果。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

还是以一个非常典型的闭包例子来解释闭包。

function foo(){
    let a = 2;
    function bao(){
        console.log(a)
    }
    return bao
}
let bar=foo();
bar();

首先,我们很清楚知道bao函数的内部作用域3能够访问bao函数的词法作用域2。

然后bao函数被当作foo函数的返回值。在foo函数执行后,其返回值(也就是内部的bao函数)赋值给变量bar并调用bar(),实际上是调用了内部的bao函数。这时bao函数在自己定义的词法作用域2以外的地方(作用域3)执行。

在foo函数执行后,其内部作用域2通常会被销毁,因为浏览器垃圾回收器会将不被引用的对象回收销毁。 事实上foo函数的内部作用域2依然存在,没有被回收。谁在引用这个内部作用域2?是bao函数中变量a在引用。

当变量bar被实际调用(调用内部bao函数),它可以访问bao函数定义时的词法作用域2,因此它可以访问变量a。这时bao函数在定义时的词法作用域2以外的地方被调用。仍然可以继续访问定义时的词法作用域。 这就是闭包。

按照《你不知道的JavaScript》中描述闭包可以这样描述:

当bao函数可以记住并访问所在的词法作用域2时,就产生了闭包(foo函数),bao函数不在词法作用域2中被调用仍然可以访问词法作用域2。

这样描述闭包是不是清楚了很多,不要特意去想如何实现闭包,闭包就是基于词法作用域书写代码时所产生的自然结果。

五、闭包的应用

1、私有化全局变量

说起闭包的作用,我不禁想起我第一次接触闭包的场景。那时在做个轮播图,需要一变量来存储点击按钮的次数,当时想都没想就在全局这么写

var prevCount = 0;
var nextCount = 0;

在后面审核代码时候,就挨训了,经理就问我一句话,如果其它地方的变量名跟这一样,那怎么办?你用闭包把这段代码重新改造一下。

接着就去看闭包,结果看的云里雾里的,只能再问经理。经理随口就说用立即执行函数。

<div id="prev">上一张</div>
<script>
    (function() {
        var prevCount = 0;
        var nextCount = 0;
        function prev() {
            //轮播的代码
            prevCount++;
            console.log(prevCount)
        }
        $('#prev').click(prev)
    })()
</script>

这样变量prevCount和变量nextCount就变成wheel私有的。

2、外部访问函数内部变量

众所周知,外部是访问不到函数内部的变量。

function foo(){
    let a ='我是foo函数内部的变量a'
}
console.log(a);//Uncaught ReferenceError: a is not defined

那么怎么在外部访问到foo函数内部的变量a,闭包带你实现。

function foo(){
    let a ='我是foo函数内部的变量a'
    function bao(){
        return a
    }
    return bao
}
let b=foo();
console.log(b());//我是foo函数内部的变量a

也许你会觉得这个没必要用的闭包,这样就行

function foo(){
    let a ='我是foo函数内部的变量a'
    return a
}
let b=foo();
console.log(b);//我是foo函数内部的变量a

那么如果你要修改foo函数内部的变量a呢?

function foo(){
    let a ='我是foo函数内部的变量a'
    function bao(c){
        a = c
        return a
    }
    return bao
}
let b=foo();
console.log(b('我修改了foo函数内部的变量a'));//我修改了foo函数内部的变量a

3、构建私有作用域

一个很经典的例子,就是for循环中闭包应用。

var arr=[]
for(var i = 0; i<10;i++){
    arr[i]=function(){
        console.log(i)
    }
}
arr[6]()

上面arr[6]()输出的是10,而不是6,那么要怎么做才输出6。

在块级作用域出现前,我们使用闭包构建私有作用域解决。

var arr=[]
for(var i = 0; i<10;i++){
    (function(i){
        arr[i]=function(){
            console.log(i)
        }
    })(i)
}
arr[6]()

4、模块输出

function module() {
    let n = 0;
    function get(){
    	console.log(n)
    }
    
    function set(){
    	n++;
    	console.log(n)
    }
    return {
    	get:get,
    	set:set
    }
}
let a = module();
let b = module();
a.get();//0
a.set();//1
b.get();//0

a和b都用于自己的私有作用域,互不影响

六、闭包的副作用

1、在函数中使用定时器,形成闭包,导致内存泄露

function foo(){
    var a =1
    setInterval(function(){
        console.log(a)
    },2000)
}
foo()

以上在foo函数中使用了定时器,是foo函数成为闭包,本来foo函数执行后变量a会被回收销毁,但是定时器中调用函数有引用到变量a,导致变量a无法被销毁一直存在内存中。应该使用个外部变量赋值定时器,以便停止。

let timer = null;
function foo(){
    var a =1
    timer=setInterval(function(){
        console.log(a)
    },2000)
}
foo();
clearInterval(timer)

2、闭包返回被外部变量引用,导致内存泄露

function foo() {
	var a = 1

	function bao() {
		console.log(a)
	}
	return bao
}

let bar = foo();
bar();
bar = null;

以上在foo函数中返回了bao函数,foo函数又把执行结果,赋值给变量bar,执行bar(),即是执行函数bao,而函数bao对变量a有引用,导致foo函数执行后变量a,不能释放,导致内存泄露。

可以通过将bar = null,断开变量bar对变量a的引用,释放变量a。