深入理解JS闭包

一、前言

闭包是javascript里面比较重要的一个知识点,本文会先从执行环境、变量对象以及作用域链入手,然后结合三者来理解闭包的概念和用法,最后通过闭包引入js的垃圾回收机制。

二、执行环境的基本概念

1. 执行环境

在运行函数时生成,定义了变量或者函数有权访问的其他数据,包括全局执行环境和函数执行环境。当js代码执行的时候,就会进入不同的执行环境,当执行环境中的代码执行完毕之后,执行环境就会被销毁,其中的所有变量和函数也会一起被销毁,这些不同的执行环境构成了一个执行环境栈。

  • 全局执行环境
    js执行的默认环境,一般默认是window对象,所有的全局变量和函数都作为window对象的属性和方法存在。对于全局执行环境来说,当关闭浏览器的时候,环境就会被销毁。
  • 函数执行环境
    当执行一个js函数的时候,函数的环境被推入执行环境栈中,执行完以后栈再将执行环境推出,将控制权交给之前的执行环境。
  • 例子
    从执行环境的角度分析,输出z的时候,由于B方法已经执行完,执行环境里面的变量都被销毁了,执行环境从栈弹出了,此时控制权已经交给之前的执行环境了,因此输出z会报错。

function A(){

    function B(){

        var z = 3;

        console.log(z);  //3

    }

    B();

    console.log(z);  //z is not defined

}



A();



2. 变量对象/活动对象

  • 变量对象 VO
    每个执行环境都有一个相关联的变量对象,执行环境中所定义的变量和函数都保存在这个变量对象中,一般来说,变量对象中包含:函数的形参、函数声明、变量。
  • 活动对象 AO
    只有全局执行环境的变量对象允许通过属性名称直接访问。

在函数执行环境中,变量对象是不允许被直接访问的。因此,在进入函数执行环境时,就会创建一个活动对象,此时,就会由活动对象扮演变量对象的角色,实际上活动对象就是变量对象,只不过在全局执行环境中可以直接使用变量对象,而在函数执行环境中,需要创建活动对象,然后用活动对象代替变量对象。

3. 作用域链

当代码在一个环境中执行的时候,会创建变量对象的一个作用域链,作用域链保证了执行环境里所有变量和函数的有序访问,作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象作为变量对象,而作用域链中的下一个变量对象来自包含(外部)环境,再下一个变量对象则来自下一个包含环境。这样一直延续到全局执行环境,全局执行环境的变量对象始终都是作用域链中的最后一个对象。

  • 例子
    从作用域链的角度讲,输出z的时候,执行环境已经是在A函数内了,这时候输出z,它会先从A函数执行环境的变量对象中找,找不到的话就会去下一个作用域中找,如此类推,一直到全局执行环境都找不到,因此输出z会报错。

var x = 1;

function A(){

    var y = 2;

    function B(){

        var z = 3;

        console.log(y);  //2

        console.log(z);  //3

    }



    B();

    console.log(y);  //2

    console.log(z);  //z is not defined

}

A();

console.log(y);  //y is not defined



三、闭包

刚刚说到,函数里的变量在函数执行完以后就会被销毁,那我们能不能在函数作用域外面也能访问到函数里面的变量呢?答案是可以的,这就是我们所说的闭包了。

由于作用域链的结构,在外部作用域是无法访问内部作用域的变量的,为了解决这个问题,我们可以使用闭包,闭包是指能够访问另一个函数作用域的变量的函数,因此闭包的本质就是函数。

1. 结果缓存

在这个例子里面,我们可以看到,虽然x是局部变量,但是执行A和B的时候,x的值依然是递增的,因此可以得知x在test方法执行完以后并没有被销毁,这似乎跟刚刚讲到的执行环境的概念有冲突,但实际上这就是闭包的一个特点,我们不妨从上面提到的执行环境和变量对象来对这个例子进行解析:

  • 连续执行A的时候,x的值一直在递增,是因为add函数被赋值给了全局变量A,导致add函数一直存在内存中,而add函数又依赖于父函数test,所以test函数也会一直存内存中不被回收,所以x虽然是局部变量,但是由于test函数一直存在,所以x所在的执行环境也一直存在,因此x会一直递增。
  • 连续执行B的时候,x的值又从1开始递增,是因为执行B的时候,生成的执行环境是一个新的执行环境,此时执行环境对应的变量对象也是一个新的变量对象,x就会从1开始递增。
  • 连续执行A和执行B的时候,x的值是相互独立地递增,是因为A和B执行的时候生成的执行环境和变量对象不一样,所以x的值并不是指同一个,也就相互独立了。

function test(){

    var x = 1;

    var add = function(){

        x++;

        console.log(x);

    }

    return add;

}



var A = test();

A();  //2

A();  //3

var B = test();

B();  //2

A();  //4



再来看下面的例子,跟上面的区别就是var B = A;这时候分别执行A和B会发现,x的值不再相互独立了,这是因为执行环境、变量对象和作用域链都是在运行函数时生成的,在这个例子里面,test只执行了一次,所以只生成了一个执行环境,所以全局变量A和B的作用域都指向同一个变量对象。


function test(){

    var x = 1;

    var add = function(){

        x++;

        console.log(x);

    }

    return add;

}



var A = test();

A();  //2

A();  //3

var B = A;

B();  //4

A();  //5



2. 匿名自执行函数

在这个例子里面,我们希望得到的结果是,每次执行都输出对应下标的值,但实际上这里输出的全部都是10,为什么会出现这种情况呢,我们不妨也从执行环境的角度去解析一下,在执行var A = test()的时候,仅仅是把函数数组arr传给了A而已,实际上并没有执行arr里面的函数,真正执行是在下面的console.log( A[0]() ),因此这时候才会生成新的执行环境,而此时执行环境关联的变量对象里,i值已经是循环结束后的10了,所以后面执行的时候拿到的i值都是10。


function test(){

    var arr = [];

    for(var i = 0; i < 10; i++){

        arr[i] = function(){

        return i;

        }

    }

    return arr; 

}



var A = test();

console.log( A[0]() );  //10

console.log( A[1]() );  //10

console.log( A[6]() );  //10



解决方法其实也很简单,在下面的例子里面,我们在循环里使用了立即执行函数,因此这里实际上是在循环了就执行了,执行了10次,每次都把对应的i传进去,生成新的执行环境,与之关联的变量对象的num值就是传进去的i值,然后给外部调用,这时候console.log( A[0]() )才会得到对应的下标,因为这时候的变量对象对应的是循环的时候生成的执行环境的,也就能拿到循环里对应的i值了,还是那句话,执行环境、变量对象和作用域链都是在运行函数时生成的。


function test(){

    var arr = [];

    for(var i = 0; i < 10; i++){

        arr[i] = (function(num){

            return function(){

                return num;

            }

        })(i);

    }

    return arr; 

}



var A = test();

console.log( A[0]() );  //0

console.log( A[1]() );  //1

console.log( A[6]() );  //6



3. 封装

类似于Java中的私有方法,js也可以使用闭包在内部封装一些私有的变量和方法,这种做法实际上就是设计模式中的模块模式,例如下面的代码里面,我们只需要把setValue和getValue暴露出去,外部就只能通过这两个方法来操作内部的变量了,这样子不仅有利于代码的管理,而且也避免了内部变量污染公共部分代码。


var counter = (function(){

    var num = 0;

    function count(value){

      num += value;

    }

    return {

        setValue: function(i){

            count(i);

        },

        getValue: function(){

            console.log(num);

        }

    }   

})();



counter.getValue();  //0

counter.setValue(1);

counter.setValue(3);

counter.getValue();  //4

counter.setValue(-1);

counter.getValue();  //3



4. 实现类和继承

通过闭包也可以实现这样一种继承,定义counter这个计数器就像一个类一样,实例化一个counter对象后就能访问它的方法,同理,由于闭包的特性,里面的私有变量和私有方法外部不能直接访问,起到一个保护的作用,另外,test也在能继承counter的方法的同时,给自己添加私有方法。


var counter = function(){

    var num = 0;

    function count(value){

      num += value;

    }

    return {

        setValue: function(i){

            count(i);

        },

        getValue: function(){

            console.log(num);

        }

    }   

}



var A = new counter();

A.getValue(); //0

A.setValue(3);

A.getValue(); //3



var test = function(){}

//继承count

test.prototype = new counter();

//添加私有方法

test.prototype.newFn = function(){

    return this.getValue();

}



var B = new test();

B.getValue(); //0

B.setValue(2);

B.getValue(); //2

B.newFn();  //2



四、垃圾回收机制

Javascript具有自动垃圾回收机制(GC:Garbage Collecation),也就是说,执行环境会负责管理代码执行过程中使用的内存。前面提到,局部变量会在函数执行完以后被销毁,实际上这个销毁的过程就是js引擎中的垃圾回收器自动释放内存的过程,垃圾回收一般有两种实现方法:标记清除法、引用计数法。

  • 标记清除法

垃圾收集器在运行的时候会给储存在内存中所有的变量都加上标记(当然都可以加上任何方式标记)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了,最后,垃圾收集器完成内存清楚工作,销毁那些带标记的值并回收他们所占用的内存空间。

解读一下,上面的过程其实就是分为了两步:

  1. 标记:垃圾回收器在运行的时候会给内存中的所有变量都打上标记(统一都默认标记为需要销毁),去掉“环境中的变量”、“被环境中的变量引用的变量(闭包)”的标记(真正需要用到的就去掉标记)
  2. 清除:垃圾回收器把被剩下有标记的变量销毁,并回收所占用的内存空间。
  • 引用计数法

当声明了一个变量并将一个引用类型赋值给该变量时,则这个值的引用次数就是1,如果同一个值又被赋给另一个变量,则该值的引用次数加一。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减一。当这个值的引用次数变为0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,他就会释放那些引用次数为零的值所占用的内存,具体如下:


function test(){

    var a = {}; //引用1次

    var b = a;  //引用2次

    var c = a;  //引用3次



    var b = null; //引用2次

}



但是引用计数法会有一个缺陷,就是无法解决循环引用的问题,例如下面的a和b都有一个属性分别指向对方,那么就会出现循环引用的情况,对于自身而言,他们的引用次数都不等于0,内存空间得不到回收,但是对于外部而言,他们并没有跟外部有任何的联系,实际上是没用的变量。这时候,上面提到的标记清除法就可以解决循环引用的问题了,因为标记清除法在标记的阶段就已经会对这两个循环引用的变量打上可清除的标记。


function test(){

    var a = {}; //a引用1次

    var b = {}; //b引用1次



    a.obj = b; //b引用2次

    b.obj = a; //a引用2次

}



五、小结

本文先是讲解了执行环境、变量对象以及作用域链,可以得知:

  • 执行环境、变量对象和作用域链都是在运行函数时生成。

  • 执行环境中的所有代码执行完以后,执行环境被销毁,保存在其中的变量和函数也随之销毁(全局执行环境到页面关闭时销毁)。

然后结合三者来理解闭包的概念和用法,综上所述,闭包的优缺点如下:

优点:

  • 保护函数内的变量安全,加强了封装性。

  • 可以用闭包模拟Java中的私有方法,设计一些私有的方法和变量。

缺点:

  • 增大内存使用量,使用不当很容易造成内存泄露。

最后,我们通过闭包引入js的垃圾回收机制,其垃圾回收机制的实质就是判断变量是否需要,不需要的话就销毁并回收内存。

引用计数法

判断依据:对象有没有其他对象引用到它,如果没有就被垃圾回收机制回收。

缺点:处理不了循环引用的情况,两个循环引用的变量不会被回收。

标记清除法(主流浏览器)

判断依据:对象是否可以获取,从全局对象开始,找所有从全局对象开始引用的对象,然后继续找这些对象引用的对象,如此类推就找到所有可以获得的对象和不能获得的对象,用标记区分,把不能获得的对象销毁并回收内存。

缺点:会造成内存碎片。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值