JavaScript中的闭包

322 阅读4分钟

闭包,相信第一次接触到这个词的同学,都会觉得一脸懵逼,这是啥玩意儿,好高端的样子。看看书看看报,闭包似乎没有那么难。

在认识闭包之前,更应该认识与闭包息息相关的执行上下文作用域链

闭包的概念

闭包简单粗暴的说就是能够访问其他函数内部变量的函数,更确切的说,闭包不是指某一个具体的东西,而是指由这个函数以及创建创该函数的词法环境(包含了创建时所能访问到的所有变量)共同组成的一个抽象的概念。 来看下面的例子:

function getMyName() {
  var name = 'lily';
  function getName() {
    console.log(name)
  }
  return getName;
}
var getNameFunc = getMyName();
getNameFunc();
=> lily

函数 getMyName 在执行时,会创建执行上下文,压入执行栈,接着 getMyName 的活动对象被创建,执行函数,闭包函数 getName 被解析,设置 getName 作用域链到内置属性[[scope]],根据词法作用域,此时函数 getName 的作用域链上有两个对象,getMyName 的活动对象与全局变量对象,这在函数定义时就已经确定。

  getName.[[scope]] = [
    AO(getMyName),
    VO(global)
  ];
闭包持有作用域

通常来讲 getMyName 执行完毕返回后,其内部作用域将消失,因为都执行完了,引擎通过垃圾回收机制将其回收看起来是合理的。但是因为闭包的存在,使其作用域并未消失。变量 getNameFunc 引用着函数 getName,而 getName 又引用着它声明时的作用域 getName.[[scope]],因此 getMyName 的活动对象不能被回收。

getNameFunc 执行会创建 getName 的活动对象,并将其推入 getName 作用域链的前端,getNameFunc 函数在查找变量 name 时会先在自己的作用域下寻找,如果没有则沿着作用域链一级一级往上寻找,直到全局变量对象,因此 getNameFunc 可以访问到 getMyName 的作用域,也就能够访问到 name 变量。根据前述闭包的概念,getName 函数(不论它是否返回)就是一个闭包,闭包了 getMyName 的作用域。

  scopeChain = [
    AO(getName),
    AO(getMyName),
    VO(global)
  ];

闭包的应用

  • 模块

    function PersonModule() {
      var food = "meat";
      var work = "coder";
    
      function eat() {
        console.log(food);
      }
    
      function profession() {
        console.log(work);
      }
    
      return {
        eat,
        profession
      }
    }
    
    var p = PersonModule();
    p.eat(); // meat
    p.profession(); // coder
    

    可以看到通过 p 可以访问到 PersonModule 内部的函数,这种模式就是一种模块。对于这个模块,我们考虑如下事情: PersonModule 本身只是一个函数,只有被执行时才能创建模块的实例,没有外部函数的执行,内部作用域的创建或闭包(闭包 PersonModule 的作用域)都不会发生。此外 p 只引用了 PersonModule 内部的函数,并没有引用内部变量,因此它的内部的变量是私有的,对外无感知的(通过闭包创建私有变量也是闭包的一大特性)。模块的返回的对象实际上是模块的共有 API。可以看到要实现模块模式需要满足两个条件

    • 模块函数(PersonModule)需要至少执行一遍,以生成模块实例
    • 需要返回模块函数内部的函数,以此来通过闭包持有模块内部的作用域

    以上的 PersonModule 可以调用多次,而生成多个实例,有时我们希望生成单例。如下:

    通过将模块包入一个立即执行函数,从而创建了一个单例。

    var p = (function PersonModule() {
      var food = "meat";
      var work = "coder";
    
      function eat() {
        console.log(food);
      }
    
      function profession() {
        console.log(work);
      }
    
      return {
        eat,
        profession
      }
    })();
    
    p.eat(); // meat
    p.profession(); // coder
    

闭包使用的注意事项

  • 性能考量

    由于闭包会引用上层环境的作用域,因此会比其他函数占用更多内存,过度使用闭包会导致内存消耗过多。

  • 循环中创建闭包

    下列中,匿名函数虽然分五次分离的定义,但是由于作用域的工作方式使得它们都闭包在同一个共享的全局作用域上,因此 i 是循环共享的,循环五次后 i 已经变成 5 了,所以会输出 5 个 5。

      for(var i=0;i<5;i++){
        setTimeout(function(){
            console.log(i); // 5个5
        },1000)
      }
      等同于
      var i = 0;
      for(;i<5;i++){
        setTimeout(function(){
            console.log(i); // 5个5
        },1000)
      }
    

    避免方法

    • 用 let 声明变量

      es6 中的 let 使得声明的变量具有块级作用域,而且在 for 循环中的块定义了一种特殊行为,它不只为循环声明一次变量,而是每次循环都会声明一个变量,因此循环后可以得到正确的值。

        for(let i=0;i<5;i++){
          setTimeout(function(){
              console.log(i); // 0 1 2 3 4
          },1000)
        }
      

      等同于

        for(var i=0;i<5;i++){
          {
            let j = i;
            setTimeout(function(){
                console.log(j); // 0 1 2 3 4
            },1000)
          }
        }
      
    • 添加闭包

      通过创建匿名闭包将事件回调同循环时的 i 值关联起来。

        for(var i=0;i<5;i++){
          ((j) => {
            setTimeout(function(){
              console.log(j); // 0 1 2 3 4
          },1000)})(i)
        }
      

      等同于

        var log = function (num) {
          setTimeout(function() {
            console.log(num); // 0 1 2 3 4
          },1000)
        }
        for(var i=0;i<5;i++){
          log(i)
        }
      

      可以看到通过调用 log 函数创建了闭包,这个闭包引用了创建时的作用域,闭包了对不同 i 的引用。