JavaScript核心之闭包

559 阅读11分钟

闭包前奏

在讲解闭包之前,先铺垫一个知识,即作用域。在ES6之前,作用域只有全局作用域局部作用域。声明在函数外的所有变量都在全局作用域里,还包括着未通过var声明的函数内部变量,这些变量可以被网页中所有脚本和函数使用。局部作用域是函数在执行时创建的一个独立的作用域,在这个作用域里的变量,只可在此作用域内被访问使用。举一个例子。

假设你的家乡是一个全局作用域,全局作用域里有很多的变量,其中比如有你的邻居,家乡的景区,家乡的小河等等,而你是一个漂流在外的人,你代表着一个独立的作用域(局部,函数作用域)。你可以回到家乡,你知道那里的地址,你知道你的邻居,小河他们在哪,你可以去找他们,可以访问到他们,这就是全局变量皆可访问。而你在外面,家乡只知道有你的存在,但是不知道你的地址,不知道你的信息,所以他们找不到你,也联系访问不到你的信息,这就是局部作用域。外界访问不到,除非你告诉了他们你的地址,你的信息,他们就可以根据你提供给他们的信息,或者记号,来找到你,访问你。(闭包在这里就登场啦)。

简单的代码:

var a = 10; //声明在函数外,是一个全局变量
function f () {
    var b = 20; //函数内部声明,是一个局部变量
    c = 10; //没有var 关键字,虽然在函数内部,但是声明的依旧是全局变量
    console.log(a)  // 10  全局变量a可以被任意访问
}
console.log(c)  //10 因为这也是一个全局变量
console.log(b)  //b is not defined

因为本篇主讲闭包,所以ES6的块级作用域暂时不写出来啦。作用域的最后,有一个知识点,全局作用域的生命周期,是从被创建开始,直接这个页面被关闭,才会被销毁。而局部(函数)作用域内的局部变量,在未执行函数时,是不会被创建的,只有在函数执行时,才会创建一个作用域,在这个作用域内声明局部变量,待到函数执行完毕,局部变量也随之被销毁。所以函数的每次执行所创建的变量都是不一样的。

什么是闭包

刚刚说了作用域,那么我们先看看什么是闭包,然后再讲为什么用闭包。
其实上面我们已经提到了,让外界能够访问到内部的变量,是闭包的作用。那么闭包也就是把内部的变量提供一个接口,一个联系方式,访问地址,让外界能够找到它。即把函数内部的变量,返回到了外部,能让外部访问到它,使原有的函数里的内存得不到释放(这个之后会说,先不用管),这就形成了闭包。 下面是一段简单的闭包代码:

function father () {
    var a = 999;    //声明一个局部变量a
    function child (num) {  //函数内部声明一个函数,并且将函数返回了出去
        return a + num
    }
    return child
}
var add = father(); //此时add接收到了father的返回值child

这里要注意,father函数执行完毕了,按理说这个函数此时的局部变量应该被销毁,但是垃圾回收器会监测到father函数中有一个变量a跟随着child函数被保存在了外部add身上,所以这会导致father函数执行完后本该释放的内存得不到释放,这就形成了闭包。 外界通过返回的child函数,可以访问到没有被释放回收掉的变量a。(注意:这里大家可能有一个疑问, 大家所知的是函数在没有被执行时,也就是没有f()时,js解释器是直接跳过他的,不会去看他里面是什么。但是js垃圾回收器是直接从根对象上进行遍历,去寻找索引,执行的时候会发现这个函数虽然执行完毕了,但是他还有一个变量被引用,所以不能被标记进行清除,所以这个father函数执行时的产生的内存也就不会被释放。)

闭包进阶之解决循环问题

上面讲了什么是闭包,大家可能对闭包还是不太了解,比如我们为什么用闭包啊?他可以做什么啊?接下来我们来讲解他具体有什么用处。现在我们来先看一段代码:

function f () {
    var arr = [];   //声明一个数组
    for ( var i = 0; i < 10; i++ ){
        arr[i] = function () {  //数组每一项都是一个函数,打印i
            console.log(i)
        }
    }
    
    for ( var j = 0; j < 10; j++ ){
        arr[j]();   //输出10个10
    }
}
f();

我们的本意是想输出0 - 9,而最终却输出了10个10,这是什么原因呢?我们来分解一下代码。

 arr[i] = function () { console.log(i) }

首先是第一次循环,当i = 0时,条件为true,进入循环体。arr[0] = function,然后arr[1] = funciton,你看,arr中第i项,这都可以正确的进行赋值,为什么在后面执行时,函数内的i就不对了呢?其实原因上面也说了,当一个函数在未被执行时,也就是函数声明期间,JS解释器读到这一行,是直接跳过的,不会去看里面的代码。而i为函数f内的变量,每一次循环都会增加i的值,而到了下面,执行arr中的每个函数时,函数体是这样的:

function () { console.log(i) }

我们可以看到代码就是在控制台打印i的值。而i我们先在这个函数里面找,发现并没有i这个变量的声明,根据我们之前学的知识,我们会知道他会继续向上去找,然后进入了函数f的作用域中,发现了i这个变量。而此时我们可以发现函数f并没有执行完,因为我们还在arrj这一步呢,所以i也没有被销毁掉,此时的i的值是多少呢?没错,是之前我们循环过后,i的值已经是10了。很多人又会问,为什么不是9呢,因为i小于10呀,这是因为每次循环执行完i都会++,当i为9时,i进入循环体,又进行了一次循环,他并不知道自己下一次会大于等于10,所以他还是会继续++,然后进行判断,于是,i变成了10,在判断中i等于10了,所以循环终止,跳出for循环,于是我们才会看到输出了10个10的景象。

知道了原因,我们就来看看怎么解决他,看看下面这段代码:

function f () {
    var arr = [];
    for ( var i = 0; i < 10; i++ ){
        arr[i] = ( function (i) {
            return function () {
                console.log(i);
            }
        })(i)
    }
    for ( var j = 0; j < 10; j++ ){
        arr[j]();   // 0 - 9
    }
}

这里利用了立即执行函数,将i的值当做参数传进立即执行函数中,这样返回出来的函数,可向上访问的作用域就又多了一个,此时找到的i不再是函数f的了,而是立即执行函数里的。我们这里创建了10个立即执行函数,虽然他们执行完毕了,但是他们的内存都不会被销毁,因为他们的局部变量i被返回了出去,他们会被垃圾回收器标记上可访问标记,不会被清除。所以这里又有一个知识点,谨慎使用闭包,大量使用闭包或者使用不当,容易导致内存泄露,即大量的内存得不到释放,对脚本性能,处理速度和内存消耗都有影响。
其实上面的代码在学完ES6后,你会发现用let声明i,就能够轻松搞定这个问题。

闭包的作用

1. 属性私有化

我们先来看一段代码:
let limit = (function () {
    let privateNumber = "xxxxxyyyyy";   //假设这是一段你私人的一段密码,不能被改变
    let num = 18;   //一个限制的数字
    return {
        getPrivateNumber: function () { //读取privateNumber值
            return privateNumber
        },
        getNum: function () { //读取num值
            return num
        },
        setNum: function (newNum) { //允许改变num值
            num = newNum;
            return newNum;
        }
    }
})()
limit.getPrivateNumber()    //读取到privateNumber值:"xxxxxyyyyy"
limit.getNum()  //读取到num值:18
limit.setNum(20)    //修改num值:20
limit.getNum()  //读取到num值:20

console.log(limit.privateNumber) //undefined
console.log(privateNumber) //privateNumber is not defined

通过闭包我们可以看到,我们通过返回出来的对象,这个对象里面提供的方法,我们可以访问到函数内部的变量,并且可以根据自己的需求进行修改里面的值。并且除了接收返回值的limit外,其他的都不能访问到,这是闭包的第一个作用,属性私有化。
上面的代码讲一个实用性的场景,便于理解为什么要这么做。 privateNumber我们假设这是一个后台传给我们的一个值,是死的,不能更改的。如果放到全局作用域中,很容易就被更改。这就好比假设当用户在填写一个兑换码时,而这个兑换码你写在了全局,比如叫n,那用户在控制台随便修改一下n的值,一填写就true通过了,多尴尬啊。(当然,兑换码肯定没有在前端啦,只是举个例子)。于是我们把privateNumber放进了函数里,并提供一个接口去访问到它的值。这就是属性私有化,利用函数内部的作用域外部访问不到的特性,将其需要的变量封装在内部,使用闭包将其数据返回到外部,使原有内存得不到释放,不会被垃圾回收器清理。 num我们假设是一个限制数字,比如你的年龄大于num,可以观看一些特殊的东西啊等等。这里还封装了一个方法setNum,使其可以通过setNum方法去修改num的值,并且成功修改,也证明了闭包的确会使原作用域得不到释放。

2. 实现缓存

这个比较简单,比如我有一组数据,放在全局作用域下是不安全的,于是可以把它保存在一个函数内部。但是 我需要操作这组数据不止一次,而函数每次执行完都会销毁自己的Ao(执行期上下文),所以可以通过闭包来导致它的内存不会被释放来进行数据的缓存。

3. 模块化

在讲模块化前,先看一段代码:
var person = {
        name: "xy",
        age = 18
    };
......
//很多的代码
var person = "666";  

此时我们可以发现,我们又一次的声明了person。因为中间写了很多的代码,所以很可能忘记了之前已经声明过这个变量。这时如果有很多的方法模块依赖于上面的object类型的person,那么会引发很多的报错。并且现在都是团队配合开发,几个乃至几十个前端工程师合力写一个项目,或者某一块功能,等到后期代码合并时,大家定义在全局中的变量极有可能产生冲突。于是,聪明的前端工程师们想出了各种办法来试着解决这个问题,比如就有模块化这个思想。

上面仅仅只是模块化产生的一个原因之一,即变量命名冲突问题。而模块化能解决的不止这些,我大致分成了三类:

3-1. 命名冲突

这也就是刚刚提到的,模块化可以很好解决变量名冲突。

3-2. 可维护性好

每个代码块都是一个模块,每个模块都是独立的。每个模块应该尽量与外部的代码保持一定的距离,以便于独立对其进行改进和维护。这样发现问题,可以很轻松的知道是哪里出了问题,哪个模块出了问题,只需要在对应的模块进行维护即可,远比一堆代码块混在一起要好维护的多。

3-3. 复用性强,高复用

我特别喜欢把自己的代码封装起来,并且封装的特别细,总是一个大功能包着无数个小组件。也就是将代码一级一级的往下分,比如下面这段代码:

let dom = (function () {
    const boxDom = function () {
        return (
            `
            <div class = "box">
                ${ headerDom() }
                ${ mainDom() }
                ${ footerDom() }
            </div>
            `
        )
    }
    const headerDom = function () {
        return (
            `
            <header>...</header>
            `
        )
    }
    const mainDom = function () {
        return (
            `
            <div class = "main">
                ${ main_inputDom() }
                ${ main_inputDom() }
            </div>
            `
        )
    }
    const footerDom = function () {
        return (
            `
            <footer>...</footer>
            `
        )
    }
    const main_inputDom = function () {
        return (
            `
            <input type="text">
            `
        )
    }
    return boxDom
})()
dom()   //最后的dom是这样的
/*
    <div class = "box">
        <header>...</header>
        <div class = "main">
            <input type="text">  
            <input type="text">
        </div>
        <footer>...</footer>
    </div>
*/

举了一个例子,把每一个功能点都尽量让他的子模块去完成,一层一层的往下去分,这样的代码,想复用的时候直接引用一下就行了。

模块化思想是一个很重要的东西,从最早的一个函数就是一个模块,到将变量、函数方法都封装在对象上,不过封装在对象上依旧还是暴露了私有变量。所以又出现了立即执行函数写法,这样你可以在函数内部使用局部变量,而不会覆盖掉同名的全局变量,但是你仍然能够访问到全局变量,并且在模块外部无法修改我们没有暴露出来的变量、函数,所以我们也就有了私有变量。 不过虽然使用这些使得代码更加灵活,便于维护,但是还是没有解决根本问题:所需要的依赖还是得外部提前提供、还是增加了全局变量。后面就出现了新的,CommonJS,cmd,amd,ES6模块化。(在这里不多做描述了)。

垃圾回收机制

大家都知道,每次函数执行完后,其作用域都会被销毁,那么你知道是怎么被清除的吗?其实,在js中,有一个垃圾回收器,你可以把它当做一个函数,他会在你每次声明一个变量时被触发。首先,他执行的第一步,就是先判断内存够不够,如果内存充足,那么直接抛出,让js引擎继续执行。如果判断出内存不够时,js垃圾回收器会被启动,将主程序暂时停掉,不允许往下执行。
js回收器主要是用一种叫标记清除的算法来回收内存,这个阶段分为两个。第一个是标记阶段,首先在js主程序中有这样两个东西,一个是一个程序,一个方法,主要的功能职责就是用来创建新对象,或读写内存里的内容。而另一个是根对象,也就是这个程序能够直接访问到的对象,一般指全局变量。js垃圾回收器会从这根对象上开始进行遍历,将所有的能够被访问到的,全部贴上一个标记,代表可访问状态,而第二个阶段是清除,垃圾回收器会从堆内存里从头到尾全部遍历一遍,把没有被标记为可访问状态的内存回收释放掉,然后再把其他被标记为可访问状态的标记给清除,等待垃圾回收器的下一次执行。垃圾回收器不会一直执行,它会等待累计直到内存不足时,才会挂起程序,暂停,待垃圾回收器执行完后,程序才可以继续执行。

(待我慢慢更新哦...帮忙点个赞加收藏哦~)