• 作者:老汪软件技巧
  • 发表时间:2024-09-13 11:01
  • 浏览量:

在JavaScript中,最让人捉摸不透的就是闭包,很多小伙伴对闭包的理解都是很模糊的,今天小路就来探索一下闭包的奥秘,看完这篇文章,希望你对闭包的困惑不再存在。

闭包到底是什么?

在我们书写代码的过程中,闭包一直存在着,只是我们没有意识到闭包的存在。

一个函数在引用了外部作用域的变量,那么就产生了闭包。

下面看一下这段代码。

var a = 1;
function foo () {
    console.log(a)
}
foo()

在console.log语句这一行打上一个断点,调试一下代码:

很明显的看到,在执行代码的过程中,系统已经自动创建一个闭包,里面存放这变量a,而这个a正是函数foo引用的外部作用域的数据。这也证实上面所说的。

不过这种系统自动创建的闭包,在程序执行完后,就会自动销毁。因为这个函数声明与调用都是在同一个作用域下,而平常我们见识到的闭包都是函数声明与调用不在同一个作用域下。比如下面的代码:

function outer () {
    var out = 1;
    function inner () {
        console.log(out)
    }
    return inner;
}
var innerFunc = outer()
innerFunc()

上面的inner函数声明是在outer函数这个作用域下,但是调用却是在全局作用域下。一般函数调用完就会销毁自己创建的内存空间,它里面存储的变量就不会再被访问到,但是上面代码不一样,outer函数调用完之后,返回的inner函数在全局作用域中的变量innnerFunc接受,而且inner函数里面引用到了outer函数里面的变量out,导致outer函数被调用完,依然能够在外部访问到变量。而这就是因为闭包的存在,延长了变量的生命周期。

再在console.log语句上打一下断点调试一下:

很明显可以看到,的确创建了闭包,里面存储的是变量out,值为1。

为什么外部函数定义的变量在函数返回后,不会被自动回收呢?闭包到底是如何做到这一切的呢?这就不得不说一下执行上下文和作用域链了,只能弄懂这两项东西,才不能彻底弄懂闭包机制。

执行上下文什么是执行上下文

执行上下文就是代码在执行前创建的一块内存空间,里面存储着代码运行所需要的数据,为代码运行提供支持。

函数运行前创建的上下文称为函数执行上下文,全局代码执行前会创建全局执行上下文。

每一个执行上下文都会关联着一个变量对象,代码执行所需要的数据都存储在这个变量对象中。

一般函数执行上下文对应的变量对象,简称为VO。正在执行的函数上下文对应的变量对象 称为活动对象,简称为AO。

全局执行上下文对应的变量对象称为全局变量对象,简称为GO。

执行上下文栈

之前说了代码的执行都会创建执行上下文,那么这些执行上下文会放到哪里呢?

执行上下文会放到一个栈结构的内存空间里面,称为执行上下文栈。栈这种数据机构,有后进先出的特点。

以下面代码为例:

function A() {
    console.log("A1");
    function B() {
        console.log("B");
    }
    B();
    console.log("A2");
}
A();

以上代码如果要画执行上下文栈的情况就是这样:

代码运行的步骤如下:

全局代码运行前,创建全局上下文,并将其压入到执行上下文栈中,接着调用函数A,创建函数A执行上下文,压入栈中,在函数A中又调用log函数(A1),将其创建的执行上下文压入栈中,完成之后此上下文出栈函数B执行上下文入栈,log函数(B)上下文入栈,log函数(B)返回后,此上下文出栈log函数(A2)入栈再出栈,至此函数A调用完毕,函数A上下文出栈,只剩下全局上下文。代码运行结束,全局上下文出栈变量对象

上面说到每个执行上下文都关联着一个变量对象,为代码的运行提供数据支持。

全局执行上下文的变量对象(GO)存储着在全局作用域下定义的变量,函数和对象,还包括全局对象。在浏览器环境下,全局对象是window。

因为用var声明的变量会成为全局对象的属性,一般情况下也可以将GO就视为window对象。

函数执行上下文的变量对象(VO)存储着在该函数定义下的变量、函数与对象。

以下面代码为例,执行上下文和变量对象的关系如下图所示。

function outer () {
    var out = 1;
    function inner () {
        var a = 1;
        console.log(out, a)
    }
    return inner;
}
var innerFunc = outer()
innerFunc()

上图是执行完outer函数后,执行上下文栈中的情况,下一步开始执行inner函数。

正常情况下,outer函数返回后,其创建的执行上下文会销毁,连带着其关联的AO对象也会销毁,但是因为inner函数引用了outer函数作用域的变量,导致outer函数的变量对象不会被销毁,这就是闭包现象。

如下图所示:

这么一看好像outer函数的vo对象,并没有指针指向它,垃圾回收器应该会自动回收这一块内存空间。其实不然,这是因为上面的图并没有画完整,完整的图就又需要一个新的知识点:作用域链。

作用域链

在JavaScript中,作用域链是一种访问变量的机制,它是由一系列执行上下文变量对象组成的链式结构,保证了当前作用域对符合访问权限的变量和函数的有序访问。

上面的话可能不好理解,看完下面的解释就明白了。

当访问一个变量时,会先从当前作用域开始查找变量,如果此作用域不存在,那么会接着查找父作用域,还未找到就会继续向上查找,直至找到全局作用域才停止。

这就是代码运行时查找变量的规则,那么编译器是如何实现这一套规则的呢?

函数中有一个隐藏属性,称为[[scope]]属性,它指向的是创建该函数时AO(或者GO)每一个AO对象也会有一个特殊的属性,指向创建此对象的函数

所以之前的代码完整的图如下:

这张图很好地解释了outer函数关联的AO对象为什么没有被回收。inner函数对应的AO对象可以通过inner函数的[[scope]]属性找到outer函数上下文对应的AO对象,而outer函数对应的AO对象又可以通过outer函数的[[scope]]属性找到GO。这就好像一条链条传下来一样,这就是作用域链的真实体现。inner函数引用outer函数的变量out,通过上图关系可以看出outer函数对应的AO对象是被引用着的,自然不会导致它被垃圾回收器回收。

此外,可以看出inner执行上下文中的作用域链就是如下所示:

看了上图,是不是对作用链是由一系列上下文变量对象构成的链式结构,这句话有了更清晰的认识。

闭包的应用场景

了解过后闭包的原理之后,我们接下来就看一下闭包的使用场景。

私有化数据

闭包最常用的场景就是私有化数据,能够避免命名冲突和变量污染。以下面代码为例:

var init = (function () {
    var privateA = 1;
    return function () {
        console.log(privateA)
    }
})()
init();

上面代码中在立即执行函数中定义一个私有化数据privateA,然后被另一个匿名函数引用,由于闭包的存在,privateA不会在外层的匿名函数调用完就被销毁,这样就做到了privateA只能被内部的匿名函数访问,不能被全局作用域的其他函数访问。如果把变量都定义在全局作用域内,容易导致命名冲突的变量污染的问题。而上述代码利用闭包,将变量privateA保留在一个封闭空间内,就可以避免上述问题。

封装模块

以下面代码为例:

function counterModule () {
    var count = 1;
    function increase () {
        return count++;
    }
    function decrease () {
        return count--;
    }
    function print () {
        console.log(count)
    }
    return {
        increase,
        decrease,
        print
    }
}
const counter = counterModule()
counter.increase()  // 2
counter.decrease()  // 1
counter.print()   // 1
const counter2 = counterModule()
const counter3 = counterModule()
counter2.increase() 
counter2.print()  // 2
counter3.increase() 
counter3.print()  // 2

在上述代码中,简单封装了一个模块函数:一个私有变量:count,三个基本操作:添加、删除和打印。三个基本操作函数都引用了外部作用域的变量count,产生了闭包,共享变量count,实现了数据和操作的封装。此外调用多次counterModule产生的模块,各个模块之间是相互隔离的,也就是每个模块引用的count是不相同,因为它们在调用时都重新创建了新的上下文对象,count变量都是重新定义的。

循环与闭包

以下代码是闭包的一道经典问题。

for(var i = 0; i < 10; i++) {
    setTimeout(function () {
        console.log(i)
    })
}

以上代码打印的内容是什么呢?

很多小伙伴们都以为打印的内容是0-9,其实不然,这只不过是我们期待打印的内容。真实打印的内容是10个10。这是为什么呢?

首先setTimeout里面注册的匿名函数是异步执行的,它需要得到同步代码完成之后,注册的函数才会执行。

在每次迭代中,定义的匿名函数都会引用全局作用域下的循环变量i,也就是说每个匿名函数要打印的变量都是同一个变量i。那么等循环结束后,变量i变成10,此时同步代码结束,异步代码开始执行,也就是匿名函数开始执行,打印的变量i就是全局作用域下的i,那么就只能打印出10了。

下面某一次迭代过程为例,执行上下文栈的内容如下图所示:

匿名函数是定义下全局作用域下的,引用的变量i自然也是全局作用域下的i。等到匿名函数执行的时候,会创建执行上下文对象,当执行打印语句时,会去沿着自己的作用链去查找,在自己的变量对象中没有找到变量i,那么就会通过[[scope]]属性找到它被定义时的执行上下文的变量对象,也就是GO,打印出来的结果就是10了。每个匿名函数打印的过程都和上面的过程一样。解决方案

上面的问题是因为闭包引起的,那么也可以用闭包解决。下面的闭包代码就很好地解决问题了:

for (var i = 0; i < 10; i++) {
    (function (i) {
        setTimeout(() => {
            console.log(i)
        })
    }(i))
}

上面的代码为什么可以解决问题了呢?

在每次迭代过程中,定时器外部包了一层立即执行函数(IIFE),IIFE接收一个参数i,而这个立即执行函数每次执行时,都会将迭代中的循环变量i的值作为参数传递进去。导致的结果就是,每次定时器注册的匿名函数引用的变量就变成了立即执行函数中的参数值了,而这个参数值随着每次迭代都是不一样的。那么以后匿名函数打印的结果就是每一次迭代过程中的变量i的值了。

以某一次打印的结果为例,执行上下文栈的内容如下图所示:

可以当定时器注册的匿名函数执行时,即使立即执行函数已经返回,但是它创建的AO对象还在内存中。匿名函数打印变量i时,会从它的作用链中查找,会找到匿名函数的A0对象中的参数i,并将这个i作为结果输出。

另一种解决方案:在ES6有了新的关键字let,并且由let声明的变量会行程新的作用域块级作用域。

for(let i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log(i)
    })
}

由let声明的循环变量有一点特殊,在每次迭代中,相当于会在循环体中重新声明一个变量,值为每次迭代的值。代码类似于:

for(let i = 0; i < 10; i++) {
    let j = i;
    setTimeout(() => {
        console.log(j)
    })
}

由于块作用域的存在,定时器注册的函数匿名函数引用的变量是每次迭代过程中重新声明的循环变量。(可以看做是j)

内存泄露

由于闭包会将函数在定义时所处的作用域给保留下来,那么不恰当的使用闭包,会导致闭包使用的内存空间一直无法被垃圾回收器回收,导致内存泄露,需要及时将不使用的变量置为null。看下面代码:

        for(let i = 0; i < 10; i++) {
            var element = document.getElementById('element')
            setTimeout(function () {
                console.log(element.id)
            })
        }

上述代码,因为闭包的存在,到时element这个对象的内存迟迟没有释放,而dom元素占据的内存空间往往是比较大的,需要及时回收。优化代码:

        for(let i = 0; i < 10; i++) {
            var element = document.getElementById('element')
            var id = element.id
            setTimeout(function () {
                console.log(id)
            })
            element = null
        }

这样一来,定时器注册的匿名函数只引用了id,由于匿名函数还是持有了外部作用域的引用,这样还是无法销毁element的内存空间,所以需要将element置为null。

总结当一个函数引用了外部函数的数据,就是产生闭包,通常是在嵌套函数中实现。闭包会保留函数在定义时的作用域,即使外部函数已经返回,闭包产生的内存空间依然可以在后续被其他函数和变量访问。闭包可以实现数据私有化,保存变量状态、避免命名冲突和变量污染,但是不恰当的使用闭包会导致变量无法被垃圾回收器回收,导致内存泄露,需要及时将不会使用的变量置为null。闭包是基于作用域链实现的,而作用域链存储的就是一系列执行上下文对应的变量对象的集合。