3.1 闭包

对于JavaScript程序员来说,闭包(closure)是一个难懂又必须征服的概念。闭包的形成与变量的作用域以及变量的生存周期密切相关。下面我们先简单了解这两个知识点。

3.1.1 变量的作用域

变量的作用域,就是指变量的有效范围。我们最常谈到的是在函数中声明的变量作用域。

当在函数中声明一个变量的时候,如果该变量前面没有带上关键字var,这个变量就会成为全局变量,这当然是一种容易造成命名冲突的做法。

另外一种情况是用var关键字在函数中声明变量,这时候的变量即是局部变量,只有在该函数内部才能访问到这个变量,在函数外面是访问不到的。代码如下:

        var func = function(){
            var a = 1;
            alert ( a );     // 输出: 1
        };
        func();
        alert ( a );     // 输出:Uncaught ReferenceError: a is not defined

在JavaScript中,函数可以用来创造函数作用域。此时的函数像一层半透明的玻璃,在函数里面可以看到外面的变量,而在函数外面则无法看到函数里面的变量。这是因为当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象为止。变量的搜索是从内到外而非从外到内的。

下面这段包含了嵌套函数的代码,也许能帮助我们加深对变量搜索过程的理解:

        var a = 1;

        var func1 = function(){
            var b = 2;
            var func2 = function(){
              var c = 3;
              alert ( b );     // 输出:2
              alert ( a );     // 输出:1
            }
            func2();
            alert ( c );    // 输出:Uncaught ReferenceError: c is not defined
        };

        func1();

3.1.2 变量的生存周期

除了变量的作用域之外,另外一个跟闭包有关的概念是变量的生存周期。

对于全局变量来说,全局变量的生存周期当然是永久的,除非我们主动销毁这个全局变量。

而对于在函数内用var关键字声明的局部变量来说,当退出函数时,这些局部变量即失去了它们的价值,它们都会随着函数调用的结束而被销毁:

        var func = function(){
            var a = 1;      // 退出函数后局部变量a将被销毁
            alert ( a );
        };

        func();

现在来看看下面这段代码:

        var func = function(){
            var a = 1;
            return function(){
              a++;
              alert ( a );
            }
        };

        var f = func();

        f();    // 输出:2
        f();    // 输出:3
        f();    // 输出:4
        f();    // 输出:5

跟我们之前的推论相反,当退出函数后,局部变量a并没有消失,而是似乎一直在某个地方存活着。这是因为当执行var f = func();时,f返回了一个匿名函数的引用,它可以访问到func()被调用时产生的环境,而局部变量a一直处在这个环境里。既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。在这里产生了一个闭包结构,局部变量的生命看起来被延续了。

利用闭包我们可以完成许多奇妙的工作,下面介绍一个闭包的经典应用。假设页面上有5个div节点,我们通过循环来给每个div绑定onclick事件,按照索引顺序,点击第1个div时弹出0,点击第2个div时弹出1,以此类推。代码如下:

        <html>
            <body>
              <div>1</div>
              <div>2</div>
              <div>3</div>
              <div>4</div>
              <div>5</div>
            <script>

              var nodes = document.getElementsByTagName( 'div' );
              for ( var i = 0, len = nodes.length; i < len; i++ ){
                  nodes[ i ].onclick = function(){
                      alert ( i );
                  }
              };

            </script>
            </body>
        </html>

测试这段代码就会发现,无论点击哪个div,最后弹出的结果都是5。这是因为div节点的onclick事件是被异步触发的,当事件被触发的时候,for循环早已结束,此时变量i的值已经是5,所以在div的onclick事件函数中顺着作用域链从内到外查找变量i时,查找到的值总是5。

解决方法是在闭包的帮助下,把每次循环的i值都封闭起来。当在事件函数中顺着作用域链中从内到外查找变量i时,会先找到被封闭在闭包环境中的i,如果有5个div,这里的i就分别是0,1,2,3,4:

        for ( var i = 0, len = nodes.length; i < len; i++ ){
            (function( i ){
              nodes[ i ].onclick = function(){
                  console.log(i);
              }
            })( i )
        };

根据同样的道理,我们还可以编写如下一段代码:

        var Type = {};
        for ( var i = 0, type; type = [ 'String', 'Array', 'Number' ][ i++ ]; ){
            (function( type ){
              Type[ 'is' + type ] = function( obj ){
                  return Object.prototype.toString.call( obj ) === '[object '+ type +']';
                  }
              })( type )
        };

        Type.isArray( [] );     // 输出:true
        Type.isString( "str" );     // 输出:true

3.1.3 闭包的更多作用

这一小节我们将通过几个例子,进一步讲解闭包的作用。因为篇幅所限,这里仅例举少量示例。在实际开发中,闭包的运用非常广泛。

1.封装变量

闭包可以帮助把一些不需要暴露在全局的变量封装成“私有变量”。假设有一个计算乘积的简单函数:

        var mult = function(){
            var a = 1;
            for ( var i = 0, l = arguments.length; i < l; i++ ){
              a = a * arguments[i];
            }
            return a;
        };

mult函数接受一些number类型的参数,并返回这些参数的乘积。现在我们觉得对于那些相同的参数来说,每次都进行计算是一种浪费,我们可以加入缓存机制来提高这个函数的性能:

        var cache = {};

        var mult = function(){
            var args = Array.prototype.join.call( arguments, ', ' );
            if ( cache[ args ] ){
              return cache[ args ];
            }
            var a = 1;
            for ( var i = 0, l = arguments.length; i < l; i++ ){
                a = a * arguments[i];
            }

            return cache[ args ] = a;
        };

        alert ( mult( 1,2,3 ) );     // 输出:6
        alert ( mult( 1,2,3 ) );     // 输出:6

我们看到cache这个变量仅仅在mult函数中被使用,与其让cache变量跟mult函数一起平行地暴露在全局作用域下,不如把它封闭在mult函数内部,这样可以减少页面中的全局变量,以避免这个变量在其他地方被不小心修改而引发错误。代码如下:

        var mult = (function(){
            var cache = {};
            return function(){
              var args = Array.prototype.join.call( arguments, ', ' );
              if ( args in cache ){
                  return cache[ args ];
              }
              var a = 1;
              for ( var i = 0, l = arguments.length; i < l; i++ ){
                  a = a * arguments[i];
              }
              return cache[ args ] = a;
            }
        })();

提炼函数是代码重构中的一种常见技巧。如果在一个大函数中有一些代码块能够独立出来,我们常常把这些代码块封装在独立的小函数里面。独立出来的小函数有助于代码复用,如果这些小函数有一个良好的命名,它们本身也起到了注释的作用。如果这些小函数不需要在程序的其他地方使用,最好是把它们用闭包封闭起来。代码如下:

        var mult = (function(){
            var cache = {};
            var calculate = function(){   // 封闭calculate函数
              var a = 1;
              for ( var i = 0, l = arguments.length; i < l; i++ ){
                  a = a * arguments[i];
              }
              return a;
            };

            return function(){
              var args = Array.prototype.join.call( arguments, ', ' );
              if ( args in cache ){
                  return cache[ args ];
              }
              return cache[ args ] = calculate.apply( null, arguments );
          }
        })();

2.延续局部变量的寿命

img对象经常用于进行数据上报,如下所示:

        var report = function( src ){
            var img = new Image();
            img.src = src;
        };

        report( 'http://xxx.com/getUserInfo' );

但是通过查询后台的记录我们得知,因为一些低版本浏览器的实现存在bug,在这些浏览器下使用report函数进行数据上报会丢失30%左右的数据,也就是说,report函数并不是每一次都成功发起了HTTP请求。丢失数据的原因是img是report函数中的局部变量,当report函数的调用结束后,img局部变量随即被销毁,而此时或许还没来得及发出HTTP请求,所以此次请求就会丢失掉。

现在我们把img变量用闭包封闭起来,便能解决请求丢失的问题:

        var report = (function(){
          var imgs = [];
          return function( src ){
              var img = new Image();
              imgs.push( img );
              img.src = src;
          }
       })();

3.1.4 闭包和面向对象设计

过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。通常用面向对象思想能实现的功能,用闭包也能实现。反之亦然。在JavaScript语言的祖先Scheme语言中,甚至都没有提供面向对象的原生设计,但可以使用闭包来实现一个完整的面向对象系统。

下面来看看这段跟闭包相关的代码:

        var extent = function(){
            var value = 0;
            return {
              call: function(){
                  value++;
                  console.log( value );
              }
            }
        };

        var extent = extent();

        extent.call();     // 输出:1
        extent.call();     // 输出:2
        extent.call();     // 输出:3

如果换成面向对象的写法,就是:

        var extent = {
            value: 0,
            call: function(){
              this.value++;
              console.log( this.value );
            }
        };

        extent.call();     // 输出:1
        extent.call();     // 输出:2
        extent.call();     // 输出:3

或者:

        var Extent = function(){
            this.value = 0;
        };

        Extent.prototype.call = function(){
            this.value++;
        };
            console.log( this.value );

        var extent = new Extent();

        extent.call();
        extent.call();
        extent.call();

3.1.5 用闭包实现命令模式

在JavaScript版本的各种设计模式实现中,闭包的运用非常广泛,在后续的学习过程中,我们将体会到这一点。

在完成闭包实现的命令模式之前,我们先用面向对象的方式来编写一段命令模式的代码。虽然还没有进入设计模式的学习,但这个作为演示作用的命令模式结构非常简单,不会对我们的理解造成困难,代码如下:

        <html>
            <body>
              <button id="execute">点击我执行命令</button>
              <button id="undo">点击我执行命令</button>
          <script>

        var Tv = {
          open: function(){
              console.log( '打开电视机' );
          },
          close: function(){
              console.log( '关上电视机' );
          }
        };

        var OpenTvCommand = function( receiver ){
          this.receiver = receiver;
        };

        OpenTvCommand.prototype.execute = function(){
          this.receiver.open();    // 执行命令,打开电视机
        };

        OpenTvCommand.prototype.undo = function(){
          this.receiver.close();    // 撤销命令,关闭电视机
        };

        var setCommand = function( command ){
          document.getElementById( 'execute' ).onclick = function(){
              command.execute();     // 输出:打开电视机
          }
          document.getElementById( 'undo' ).onclick = function(){
              command.undo();     // 输出:关闭电视机
          }
        };

        setCommand( new OpenTvCommand( Tv ) );

          </script>
          </body>
        </html>

命令模式的意图是把请求封装为对象,从而分离请求的发起者和请求的接收者(执行者)之间的耦合关系。在命令被执行之前,可以预先往命令对象中植入命令的接收者。

但在JavaScript中,函数作为一等对象,本身就可以四处传递,用函数对象而不是普通对象来封装请求显得更加简单和自然。如果需要往函数对象中预先植入命令的接收者,那么闭包可以完成这个工作。在面向对象版本的命令模式中,预先植入的命令接收者被当成对象的属性保存起来;而在闭包版本的命令模式中,命令接收者会被封闭在闭包形成的环境中,代码如下:

        var Tv = {
            open: function(){
              console.log( ’打开电视机’ );
            },
            close: function(){
                console.log( ’关上电视机’ );
            }
        };

        var createCommand = function( receiver ){

            var execute = function(){
                return receiver.open();    // 执行命令,打开电视机
            }

            var undo = function(){
                return receiver.close();    // 执行命令,关闭电视机
            }

            return {
                execute: execute,
                undo: undo
            }

        };

        var setCommand = function( command ){
            document.getElementById( 'execute' ).onclick = function(){
                command.execute();     // 输出:打开电视机
            }
            document.getElementById( 'undo' ).onclick = function(){
                command.undo();    // 输出:关闭电视机
            }
        };

        setCommand( createCommand( Tv ) );

3.1.6 闭包与内存管理

闭包是一个非常强大的特性,但人们对其也有诸多误解。一种耸人听闻的说法是闭包会造成内存泄露,所以要尽量减少闭包的使用。

局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。从这个意义上看,闭包的确会使一些数据无法被及时销毁。使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量放在闭包中和放在全局作用域,对内存方面的影响是一致的,这里并不能说成是内存泄露。如果在将来需要回收这些变量,我们可以手动把这些变量设为null。

跟闭包和内存泄露有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些DOM节点,这时候就有可能造成内存泄露。但这本身并非闭包的问题,也并非JavaScript的问题。在IE浏览器中,由于BOM和DOM中的对象是使用C++以COM对象的方式实现的,而COM对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄露在本质上也不是闭包造成的。

同样,如果要解决循环引用带来的内存泄露问题,我们只需要把循环引用中的变量设为null即可。将变量设置为null意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。