1

引言

刚学习前端的时候,看到闭包这个词,总是一脸懵逼,面试的时候,问到这个问题,也是回答的含含糊糊,总感觉有层隔膜,觉得这个概念很神奇,如果能掌握,必将功力大涨。其实,闭包没有这么神秘,它无处不在。

一个简短的的问题

首先,来看一个问题。

请用一句话描述什么是闭包,并写出代码进行说明。

如果能毫不犹豫的说出来,并能给出解释,那下面文字对你来说就没有往下看的必要了。
就这个问题,结合我查阅的资料和经验,在这里简单的说一下,如果哪里有不对的,欢迎指正。

先回答上面的问题,什么是闭包。

闭包是一个概念,它描述了函数执行完毕后,依然驻留内存的现象。

代码描述:


function foo() {

    var a = 2;

    function bar(){
        console.log(a);
    }

    return bar;
}

var test = foo();
test(); //2

上面这段代码,清晰的展示了闭包。

函数 bar() 的词法作用域能够访问 foo()的内部作用域。然后我们将bar()函数本身当作一个值类型进行传递。上面这个例子,我们将bar() 所引用的函数对象本身作为返回值。

foo() 执行完毕之后, 其内部作用域并没有被销毁,因为bar()依然保持着对内部作用域的引用,拜bar()的位置所赐,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后的任何时间进行引用。这个引用,其实就是闭包。
也正是这个原因,test被实际调用的时候,它可以访问定义时的词法作用域,所以,才能访问到a.

函数传递也可以是间接的:

    var fn;
    function foo(){

        var a = 2;

        function baz() {
            console.log( a );
        }
        fn = baz; //将baz 分配给全局变量
    }

    function bar(){
        fn();
    }
    foo();
    bar(); //2

所以,无论通过何种手段将内部函数传递到其所在的词法作用域外,它都会持有对原始定义作用域的引用。也就是说,无论在什么地方执行这个函数,都会使用闭包。也是这个原因,我们才可以很方便的使用回调函数而不用关心其具体细节。

其实,在定时器,事件监听器,ajax请求, 跨窗口通信,Web Workers 或者任何其他的同步 或 异步任务中,只要使用了回调函数,实际上就是在使用闭包。

到这里,或许你已经对闭包有个大概的了解,下面我再举几个例子来帮你加深对闭包的认识。

几个更具体的例子

首先,就先看一下所谓的立即执行函数.

var a = 2;

(function IIFE() { 
   console.log(a); 
 })();

//2

这个立即执行函数通常被认为是经典的闭包例子,它可以正常工作,但严格意义上讲,它并不是闭包。
为什么呢?

因为这个IIFE函数并不是在它本身的词法作用域之外执行的。它在定义时所在的作用域中执行了。而且,变量a 是通过普通的词法作用域查找的,而不是通过闭包。

另一个用来说明闭包的例子是循环。

    <div class="tabs">
        <li class="tab">some text1</li>
        <li class="tab">some text2</li>
        <li class="tab">some text3</li>
    </div>
var handler = function(nodes) {

    for(var i = 0, l = nodes.length; i < l ; i++) {
        
        nodes[i].onclick = function(){

            console.log(i);

        }
    }
}

var tabs = document.querySelectorAll('.tabs .tab');
    handler(tabs);

我们预期的结果是log 0 ,1,2;

执行之后的结果却是是三个3;

这是为什么呢?

首先解释下这个3是怎么来的,

看一下循环体,循环的终止条件是 i < l , 首次条件成立时 i 的值是3 。
因此 ,输出显示的是循环结束时 i 的最终值。 根据作用域的工作原理,尽管循环中的函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上是只有一个i.

handler 函数的本意是想把唯一的i传递给事件处理器,但是失败了。
因为事件处理器函数绑定了 i本身 ,而不是函数在构造时的i的值.

知道了这个之后,我们可以做出相应的调整:

var handler = function(nodes) {

    var helper = function(i){
        return function(e){
            console.log(i); // 0 1 2
        }
    }

    for(var i = 0, l = nodes.length; i < l ; i++) {
        
        nodes[i].onclick = helper(i);
    }
}

在循环外创建一个辅助函数,让这个辅助函数在返回一个绑定了当前i的值的函数,这样就不会混淆了。

明白了这点,就会发现,上面的处理就是为了创建一个新的作用域,换句话说,每次迭代我们都需要一个块作用域.

说到块作用域,就不得不提一个词,那就是let.

所以,如果你不想过多的使用闭包,就可以使用let

var handler = function(nodes) {

    for(let i = 0, l = nodes.length; i < l ; i++) {
        
        //nodes[i].index = i;

        nodes[i].onclick = function(){

            console.log(i); // 0 1 2


        }
    }
}

jQuery中的闭包

先来看个例子


     var sel = $("#con"); 
     setTimeout( function (){ 
         sel.css({background:"gray"}); 
     }, 2000);

上边的代码使用了 jQuery 的选择器,找到 id 为 con 的元素,注册计时器,两秒之后,将背景色设置为灰色。

这个代码片段的神奇之处在于,在调用了 setTimeout 函数之后,con 依旧被保持在函数内部,当两秒钟之后,id 为 con 的 div 元素的背景色确实得到了改变。应该注意的是,setTimeout 在调用之后已经返回了,但是 con 没有被释放,这是因为 con 引用了全局作用域里的变量 con。

以上的例子帮助我们了解了更多关于闭包的细节,下面我们就深入闭包世界探寻一番。

深入理解闭包

首先看一个概念-执行上下文(Execution Context)。

执行上下文是一个抽象的概念,ECMAScript 规范使用它来追踪代码的执行。它可能是你的代码第一次执行或执行的流程进入函数主体时所在的全局上下文。

clipboard.png

在任意一个时间点,只能有唯一一个执行上下文在运行之中。

这就是为什么 JavaScript 是“单线程”的原因,意思就是一次只能处理一个请求。

一般来说,浏览器会用来保存这个执行上下文。

是一种“后进先出” (Last In First Out) 的数据结构,即最后插入该栈的元素会最先从栈中被弹出(这是因为我们只能从栈的顶部插入或删除元素)。

当前的执行上下文,或者说正在运行中的执行上下文永远在栈顶。

当运行中的上下文被完全执行以后,它会由栈顶弹出,使得下一个栈顶的项接替它成为正在运行的执行上下文。

除此之外,一个执行上下文正在运行并不代表另一个执行上下文需要等待它完成运行之后才可以开始运行。

有时会出现这样的情况,一个正在运行中的上下文暂停或中止,另外一个上下文开始执行。暂停的上下文可能在稍后某一时间点从它中止的位置继续执行。

一个新的执行上下文被创建并推入栈顶,成为当前的执行上下文,这就是执行上下文替代的机制。

clipboard.png

当我们有很多执行上下文一个接一个地运行时——通常情况下会在中间暂停然后再恢复运行——为了能很好地管理这些上下文的顺序和执行情况,我们需要用一些方法来对其状态进行追踪。而实际上也是如此,根据ECMAScript的规范,每个执行上下文都有用于跟踪代码执行进程的各种状态的组件。包括:

  • 代码执行状态:任何需要开始运行,暂停和恢复执行上下文相关代码执行的状态
    函数:上下文中正在执行的函数对象(正在执行的上下文是脚本或模块的情况下可能是null)

  • Realm:一系列内部对象,一个ECMAScript全局环境,所有在全局环境的作用域内加载的ECMAScript代码,和其他相关的状态及资源。

  • 词法环境:用于解决此执行上下文内代码所做的标识符引用。

  • 变量环境:一种词法环境,该词法环境的环境记录保留了变量声明时在执行上下文中创建的绑定关系。

模块与闭包

现在的开发都离不开模块化,下面说说模块是如何利用闭包的。

先看一个实际中的例子。
这是一个统计模块,看一下代码:


    define("components/webTrends", ["webTrendCore"], function(require,exports, module) {
    
    
        var webTrendCore = require("webTrendCore");  
        var webTrends = {
             init:function (obj) {
                 var self = this;
                self.dcsGetId();
                self.dcsCollect();
            },
    
             dcsGetId:function(){
                if (typeof(_tag) != "undefined") {
                 _tag.dcsid="dcs5w0txb10000wocrvqy1nqm_6n1p";
                 _tag.dcsGetId();
                }
            },
    
            dcsCollect:function(){
                 if (typeof(_tag) != "undefined") {
                    _tag.DCSext.platform="weimendian";
                    if(document.readyState!="complete"){
                    document.onreadystatechange = function(){
                        if(document.readyState=="complete") _tag.dcsCollect()
                        }
                    }
                    else _tag.dcsCollect()
                }
            }
    
        };
    
      module.exports = webTrends;
    
    })

在主页面使用的时候,调用一下就可以了:


var webTrends = require("webTrends");
webTrends.init();

在定义的模块中,我们暴露了webTrends对象,在外面调用返回对象中的方法就形成了闭包。

模块的两个必要条件:

  • 必须有外部的封闭函数,该函数必须至少被调用一次

  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

性能考量

如果一个任务不需要使用闭包,那最好不要在函数内创建函数。
原因很明显,这会 拖慢脚本的处理速度,加大内存消耗 。

举个例子,当需要创建一个对象时,方法通常应该和对象的原型关联,而不是定义到对象的构造函数中。 原因是 每次构造函数被调用, 方法都会被重新赋值 (即 对于每个对象创建),这显然是一种不好的做法。

看一个能说明问题,但是不推荐的做法:


    function MyObject(name, message) {
    
      this.name = name.toString();
      this.message = message.toString();
      
      this.getName = function() {
        return this.name;
      };
    
      this.getMessage = function() {
        return this.message;
      };
    }

上面的代码并没有很好的利用闭包,我们来改进一下:


    function MyObject(name, message) {
      this.name = name.toString();
      this.message = message.toString();
    }
    
    MyObject.prototype = {
      getName: function() {
        return this.name;
      },
      getMessage: function() {
        return this.message;
      }
    };

好一些了,但是不推荐重新定义原型,再来改进下:


function MyObject(name, message) {
    this.name = name.toString();
    this.message = message.toString();
}

MyObject.prototype.getName = function() {
       return this.name;
};

MyObject.prototype.getMessage = function() {
   return this.message;
};

很显然,在现有的原型上添加方法是一种更好的做法。

上面的代码还可以写的更简练:


    function MyObject(name, message) {
        this.name = name.toString();
        this.message = message.toString();
    }
    
    (function() {
        this.getName = function() {
            return this.name;
        };
        this.getMessage = function() {
            return this.message;
        };
    }).call(MyObject.prototype);

在前面的三个示例中,继承的原型可以由所有对象共享,并且在每个对象创建时不需要定义方法定义。如果想看更多细节,可以参考对象模型

闭包的使用场景:

  • 使用闭包可以在JavaScript中模拟块级作用域;

  • 闭包可以用于在对象中创建私有变量。

闭包的优缺点

优点:

  • 逻辑连续,当闭包作为另一个函数调用的参数时,避免你脱离当前逻辑而单独编写额外逻辑。

  • 方便调用上下文的局部变量。

  • 加强封装性,第2点的延伸,可以达到对变量的保护作用。

缺点:

  • 内存浪费。这个内存浪费不仅仅因为它常驻内存,对闭包的使用不当会造成无效内存的产生。

结语

前面对闭包做了一些简单的解释,最后再总结下,其实闭包没什么特别的,其特点是:

  • 函数嵌套函数

  • 函数内部可以访问到外部的变量或者对象

  • 避免了垃圾回收

欢迎交流,以上 ;-)

参考资料

让我们一起学习JavaScript闭包吧

弄懂JavaScript的作用域和闭包

Closures


皮小蛋
8k 声望12.8k 粉丝

积跬步,至千里。