23

什么是闭包?

闭包的概念
《JavaScript权威指南》:函数对象可以通过作用域链相互关联起来,函数体内部的变量可以保存在函数作用域内,这种特性称为“闭包”。

不好理解?那就通俗点讲:所谓闭包,就是一个函数,这个函数能够访问其他函数的作用域中的变量。

理解闭包

理解闭包首先要了解嵌套函数的词法作用域规则,先来看一下这段代码

var scope = 'global scope';        // 全局变量
var checkScope = function () {
    var scope = 'local scope';    // 局部变量
    function f() {
        return scope;
    }
    return f();                    // => local scope
};
checkScope();

checkScope()函数声明了一个局部变量,并定义了一个函数f(),函数f()反回了这个变量的值,最后将函数f()的执行结果返回。你应当非常清楚为什么调用checkscope()函数会返回“local scope”。

这个词法作用域的例子介绍了引擎是如何解析函数嵌套中的变量的。词法作用域中使用的域,是变量在代码中声明的位置所决定的。嵌套的函数可以访问在其外部声明的变量。

现在来考虑以下例子 :

var scope = 'global scope';        // 全局变量
var checkScope = function () {
    var scope = 'local scope';    // 局部变量
    function f() {
        return scope;
    }
    return f;    
};
checkScope()();                    // 返回值是什么?

这段代码中,我们将函数内的一对圆括号移动到了checkscope()之后。checkscope()现在仅仅返回函数内嵌套的一个函数对象,而不是直接返回结果。在函数作用域外面,调用这个嵌套的函数会发生什么呢?

这个谜题的答案是,JavaScript中的函数会形成闭包,闭包是由函数以及创建该函数的词法环境组合而成。也就是说,这个环境包含了这个闭包创建时所能访问的所有局部变量。

在这个例子中,嵌套的函数f()定义在这个作用域链里,其中的变量scope一定是局部变量,不管何时何地执行f(),这种绑定在执行f()时依然有效。因此最后一行代码返回“local scope”,而不是“global scope“。

如果你理解了词法作用域的规则,你就能很容易地理解闭包:函数定义时的作用域链到函数执行时依然有效。

然而很多同学觉得闭包非常难理解,因为他们在深入学习闭包的实现细节时将自已搞得晕头转向。他们觉得在外部函数中定义的局部变量在函数返回后就不存在了,那么嵌套的函数如何能调用不存在的作用域链呢?如果你想搞清楚这个问题,你需要更深入地了解类似C语言这种更底层的编程语言,并了解基于栈的CPU架构:如果一个函数的局部变量定义在CPU的栈中,那么当函数返回时它们的确就不存在了。

但回想一下我们是如何定义作用域链的。我们将作用域链描述为一个对象列表,不是绑定的栈。每次调用JavaScript函数的时候,都会为之创建一个新的对象用来保存局部变量,把这个对象添加至作用域链中。当函数返回的时候,就从作用域链中将这个绑定变量的对象删除。如果不存在嵌套的函数,也没有其他引用指向这个绑定对象,它就会被当做垃圾回收掉。如果定义了嵌套的函数,每个嵌套的函数都各自对应一个作用域链,并且这个作用域链指向一个变量绑定对象。但如果这些嵌套的函数对象在外部函数中保存下来,那么它们也会和所指向的变量绑定对象一样当做垃圾回收。但是如果这个函数定义了嵌套的函数,并将它作为返回值返回或者存储在某处的属性里,这时就会有一个外部引用指向这个嵌套的函数。它就不会被当做垃圾回收,并且它所指向的变量绑定对象也不会被当做垃圾回收。

下面再来看一个更有意思的示例:— makeAdder函数:

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2));  // 7
console.log(add10(2)); // 12

在这个示例中,我们定义了makeAdder(x)函数,它接受一个参数x,并返回一个新的函数。返回的函数接受一个参数y,并返回x+y的值。

从本质上讲,makeAdder是一个函数工厂 — 他创建了将指定的值和它的参数相加求和的函数。在上面的示例中,我们使用函数工厂创建了两个新函数 — 一个将其参数和 5 求和,另一个和 10 求和。

Add5和add10都是闭包。它们共享相同的函数定义,但是保存了不同的词法环境。在add5的环境中,x为 5。而在add10中,x则为 10。

实用的闭包

闭包很有用,因为它允许将函数与其所操作的某些数据(环境)关联起来。这显然类似于面向对象编程。在面向对象编程中,对象允许我们将某些数据(对象的属性)与一个或者多个方法相关联。

因此,通常你使用只有一个方法的对象的地方,都可以使用闭包。

接着来看一个uniqueInteger()函数,这个函数使用自身的一个属性来保存每次返回的值,以便每次都能跟踪上次返回的值。

var uniqueInteger = (function() {
    var counter = 0;
    return function() {
        return counter++;
    }
})();

你需要仔细阅读这段代码才能理解其含义。粗略来看,第一行代码看起来像将函数赋值给一个变量 uniqueInteger,实际上,这段代码定义了一个立即调用的函数,因此是这个函数的返回值赋值给变量uniqueInteger。现在,我们来看函数体,这个函数返回另外一个函数,这是一个嵌套的函数,我们将它赋值给变量uniqueInteger,嵌套的函数是可以访问作用域内的变量的,而且可以访问外部函数中定义的 counter变量。当外部函数返回之后,其他任何代码都无法访问 counter变量,只有内部的函数才能访问到它。

像 counter一样的私有变量不是只能用在一个单独的闭包内,在同一个外部函数内定义的多个嵌套函数也可以访问它,这多个嵌套函数都共享一个作用域链,看一下这段代码:

function counter() {
    var n = 0;
    return {
        count: function() { return n++; }
        reset: function() { n = 0; }
    };
}
var c = counter(), d = counter();        // 创建两个计数器
c.count();                            // =>0
d.count();                            // =>0: 它们互不干扰
c.reset();                            // reset()和 count()方法共享状态
c.count();                            // =>0: 因为我们重置了c
d.count();                            // =>1: 而没有重置d

counter()函数返回了一个“计数器”对象,这个对象包含两个方法:count()返回下一个整数,reset()将计数器重置为内部状态。首先要理解,这两个方法都可以访问私有变量n。再者,每次调用counter()都会创建一个新的作用域链和一个新的私有变量。因此,如果调用counter()两次,则会得到两个计数器对象,而且彼此包含不同的私有变量,调用其中一个计数器对象的count()或reset()不会影响到另外一个对象。

从技术角度看,其实可以将这个闭包合并为属性存取器方法getter和setter。下面这段代码的私有状态的实现是利用了闭包,而不是利用普通的对象属性来实现:

function counter(n) { //函数参数n是一个私有变量
    return {
        //属性getter方法返回并给私有计数器var递增1
        get count() { return n++; },
        //属性setter不允许n递减
        set count(m) {
        if (m >= n) n = m;
        else throw Error("count can only be set to a larger value");
        }
    };
}
var c = counter(1000); 
c.count;                    // => 1000
c.count;                    // => 1001
c.count = 2000;
c.count;                    // => 2000
c.count = 2000;            // => Error!

需要注意的是,这个版本的counter()函数并未声明局部变量,而只是使用参数n来保存私有状态,属性存取器方法可以访问n。这样的话,调用counter()的 函数就可以指定私有变量的初始值了。

再来一个例子,用闭包模拟私有方法:
编程语言中,比如 Java,是支持将方法声明为私有的,即它们只能被同一个类中的其它方法所调用。

而 JavaScript 没有这种原生支持,但我们可以使用闭包来模拟私有方法。私有方法不仅仅有利于限制对代码的访问:还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。

下面的示例展现了如何使用闭包来定义公共函数,并令其可以访问私有函数和变量。这个方式也称为模块模式(module pattern)

var Counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }   
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

在之前的示例中,每个闭包都有它自己的词法环境;而这次我们只创建了一个词法环境,为三个函数所共享:Counter.increment,Counter.decrement和Counter.value。

该共享环境创建于一个立即执行的匿名函数体内。这个环境中包含两个私有项:名为privateCounter的变量和名为changeBy的函数。这两项都无法在这个匿名函数外部直接访问。必须通过匿名函数返回的三个公共函数访问。

这三个公共函数是共享同一个环境的闭包。多亏 JavaScript 的词法作用域,它们都可以访问privateCounter变量和changeBy函数。

闭包常见错误

我们已经给出了很多例子,在同一个作用域链中定义两个闭包,这两个闭包共享同样的私有变量或变量。这是一种非常重要的技术,但还是要特别小心那些不希望共享的变量往往不经意间共享给了其他的闭包,了解这一- 点也很重要。看一下下面这段代码:

//这个函数返回一个总是返回v的函数
function constfunc(v) { return function() { return v; }; }

//创建一个数组用来存储常数函数
var funcs = [];
for(var i = 0; i < 10; i++) funcs[i] = constfunc(i);

//在第5个位置的元素所表示的函数返回值为5
funcs[5]() //=> 5

这段代码利用循环创建了很多个闭包,当写类似这种代码的时候往往会犯-一个错误:那就是试图将循环代码移入定义这个闭包的函数之内,看一下这段代码:

//返回一个函数组成的数组,它们的返回值是0~9
function constfuncs() {
    var funcs=[];
    for(var i = 0; i < 10; i++)
        funcs[i] = function() { return i; };
    return funcs;
}

var funcs = constfuncs();
funcs[5]()     //返回值是什么?

上面这段代码创建了10个闭包,并将它们存储到一个数组中。这些闭包都是在同一个函数调用中定义的,因此它们可以共享变量i。当constfuncs()返回时,变量i的值是10,所有的闭包都共享这一个值,因此,数组中的函数的返回值都是同一个值,这不是我们想要的结果。

关联到闭包的作用域链都是“活动的”,记住这一点非常重要。嵌套的函数不会将作用域内的私有成员复制一份,也不会对所绑定的变量生成静态快照(staticsnapshot)。

书写闭包的时候还需注意一件事情, this是JavaScript的关键字, 而不是变量。正如之前讨论的,每个函数调用都包含一个thi s值,如果闭包在外部函数里是无法访问this的,除非外部函数将this转存为一个变量:

var self = this;

绑定argument的问题与之类似。arguments并不是一个关键字,但在调用每个函数时都会自动声明它,由于闭包具有自己的绑定的arguments,因此闭包内无法直接访问外部函数的参数数组,除非外部函数将参数保存到另外一个变量中:

var outerArguments = arguments;    // 保存起来以便嵌套的函数能使用它

参考:

* 《JavaScript权威指南》第六版
* [MDN Web 文档](https://developer.mozilla.org/zh-CN/)

推荐阅读:
【专题:JavaScript进阶之路】
JavaScript之“use strict”
JavaScript之new运算符
JavaScript之call()理解
JavaScript之对象属性


我是Cloudy,年轻的前端攻城狮一枚,爱专研,爱技术,爱分享。
个人笔记,整理不易,感谢阅读、点赞和收藏。
文章有任何问题欢迎大家指出,也欢迎大家一起交流前端各种问题!

云鱼
3.2k 声望530 粉丝

感谢阅读、浏览和关注!