深入理解JavaScript闭包【译】

在《高级程序设计》中,对于闭包一直没有很好的解释,在stackoverflow上翻出了一篇很老的《JavaScript closure for dummies》(2016)~

出处:http://stackoverflow.com/questions/111102/how-do-javascript-closures-work


闭包不是魔法

本文旨在用JavaScript代码让程序员理解闭包,函数式编程的程序员或者导师请绕行。

只要理解了闭包的核心理念,闭包并不难学。但是通过学习一些相关的学术论文或者学术方面的信息很难理解闭包。

本文是写给有主流语言编程经验的程序员的,至少应该能够看懂如下JavaScript函数:

1 function sayHello(name) {
2     var text = 'Hello ' + name;
3     var say = function() { console.log(text); }
4     say();
5 }

闭包举例

两个一句话总结:

  • 闭包是函数的局部变量 —— 但是当函数return后,它仍旧有效
  • 闭包是一个函数return后仍不会释放的的堆栈结构(就像分配了一个堆栈结构而不是在一个栈上)

以下代码给函数返回了一个引用:

1 function sayHello2(name) {
2     var text = 'Hello ' + name; // Local variable
3     var say = function() { console.log(text); }
4     return say;
5 }
6 var say2 = sayHello2('Bob');
7 say2(); // logs "Hello Bob"

大多数的JavaScript程序员能够理解,上述代码是如何返回给变量(say2)一个函数的引用的,如果你不理解的话,请在学习闭包前先弄懂它。C语言程序员可能认为这个函数是返回了一个函数的指针,并认为变量say和say2是两个指向了函数的指针。

C的函数指针和JavaScript的函数引用之间有关键性的区别:在JavaScript中你可以认为,一个函数引用变量既是一个指向了函数的指针,也是一个指向了闭包的隐性指针。

因为匿名函数 function() { console.log(text); } 是在另外一个函数( sayHello2() )里面声明的,所以以上代码包含一个闭包。在JavaScript中,如果你在另外一个函数里面使用 function 关键字,那么你就创造了一个闭包。

在C和大多数其他常见语言中,当一个函数return后,因为堆栈已经被清理了,所以所有的局部变量都无法再被访问。

在JavaScript中,如果你在另外一个函数中声明一个函数,那么当你调用一个函数并return后,这个局部变量仍旧是可访问的,上述代码已经证实过这一点:我们在sayHello2()函数return后调用了函数say()。

function() { console.log(text); } // Output of say2.toString();
通过say2.toString()的输出结果可以看到,代码指向了变量text。因为sayHello()的局部变量text被保存在一个闭包里,所以匿名函数可以引用值为'Hello Bob'的text。

魔法在于,在JavaScript中,一个函数引用会有一个隐式的、指向该函数被创建时所在的闭包的引用,就像是方法指针加上对一个对象的隐式引用一样。

更多举例

出于某些原因闭包有时看起来的确难以理解,但是当你学习一些例子的时候你就能够明白他们是怎样工作起来的了。我建议认真逐一的研究以下举例,直到你理解了他们是如何运作的。如果你在不完全理解闭包是怎样运作之前就使用他们,很快你就会创造一些非常诡异的bug~

例3

这个举例展示了:局部变量并没有被赋值,他们是通过引用被保持的。这有点像当外部函数存在时,保持了一个堆栈结构在内存里。

function say667() {
    // Local variable that ends up within closure
    var num = 666;
    var say = function() { console.log(num); }
    num++;
    return say;
}
var sayNumber = say667();
sayNumber(); // alerts 667

例4

因为三个全局函数在同一个调用setupSomeGlobals()的闭包中,所以他们有指向相同闭包的同一个引用。

var gLogNumber, gIncreaseNumber, gSetNumber;
function setupSomeGlobals() {
    // Local variable that ends up within closure
    var num = 666;
    // Store some references to functions as global variables
    gLogNumber = function() { console.log(num); }
    gIncreaseNumber = function() { num++; }
    gSetNumber = function(x) { num = x; }
}

setupSomeGlobals();
gIncreaseNumber();
gLogNumber(); // 667
gSetNumber(5);
gLogNumber(); // 5

var oldLog = gLogNumber;

setupSomeGlobals();
gLogNumber(); // 666

oldLog() // 5

当三个函数被定义的时候,三个函数共享了对于相同闭包的访问,这个闭包是setupSomGlobals()的局部变量。

注意在以上距离中,如果你再次调用setupSomeGlobals() ,会创建一个新的闭包(堆栈空间)。旧的变量gAlertNumber, gIncreaseNumber, gSetNumber值会被有着新闭包的新函数覆盖。(在JavaScript中,无论什么时候在其他函数中声明了一个新函数,当外部函数每次被调用时,内部函数都会被创新创建)。

例5

对于很多人来说在本例都会犯错,所以你一定要理解闭包才行。当你在循环里面定义函数的时候一定要非常小心,因为闭包里的局部变量的表现通常不像你想的那样。

function buildList(list) {
    var result = [];
    for (var i = 0; i < list.length; i++) {
        var item = 'item' + i;
        result.push( function() {console.log(item + ' ' + list[i])} );
    }
    return result;
}

function testList() {
    var fnlist = buildList([1,2,3]);
    // Using j only to help prevent confusion -- could use i.
    for (var j = 0; j < fnlist.length; j++) {
        fnlist[j]();
    }
}

行 result.push( function() {console.log(item + ' ' + list[i])} 三次向结果数组添加了一个指向匿名函数的引用,如果你不是很熟悉匿名函数的话,你可以认为这里匿名函数的作用类似如下代码:

pointer = function() {console.log(item + ' ' + list[i])};
result.push(pointer);

注意当你执行这个例子的时候,"item2 undefined"会被alert三次!这是因为如前例所述,这三次都是指向buildList的本地变量的同一个闭包。当行 fnlist[j](); 被执行时调用了匿名函数,这些匿名函数都使用了一个相同的闭包,他们都使用这个闭包的当前值i和item(i的值是3是因为循环已经结束了,也因此item的值是item2)。注意因为索引是从0开始的,因此item的值是item2,而i++会使i的值加到3。

例6

本例说明了,闭包中会包含任何在闭包存在前的、该闭包的外部函数内声明的局部变量。注意变量alice实际上是在匿名函数之后声明的,即匿名函数先被声明了。而因为alice与闭包在同一个作用域(JavaScript做了变量提升),所以调用的函数可以存取到alice变量。同样的,sayAlice()()直接调用了sayAlice()返回的引用,这与之前的例子其实是一样的,只是没有临时变量而已。

function sayAlice() {
    var say = function() { console.log(alice); }
    // Local variable that ends up within closure
    var alice = 'Hello Alice';
    return say;
}
sayAlice()();

注意:变量say也在闭包之中,并且可以被任何在sayAlice()中声明的函数存取,也可以被函数内部的递归存取。

例7

最后的例子说明,每一次调用都会给局部变量创建一个隔离的闭包,每个函数声明都不是同一个闭包,每个函数的调用都有一个闭包。

function newClosure(someNum, someRef) {
    // Local variables that end up within closure
    var num = someNum;
    var anArray = [1,2,3];
    var ref = someRef;
    return function(x) {
        num += x;
        anArray.push(num);
        console.log('num: ' + num +
            '\nanArray ' + anArray.toString() +
            '\nref.someVar ' + ref.someVar);
      }
}
obj = {someVar: 4};
fn1 = newClosure(4, obj);
fn2 = newClosure(5, obj);
fn1(1); // num: 5; anArray: 1,2,3,5; ref.someVar: 4;
fn2(1); // num: 6; anArray: 1,2,3,6; ref.someVar: 4;
obj.someVar++;
fn1(2); // num: 7; anArray: 1,2,3,5,7; ref.someVar: 5;
fn2(2); // num: 8; anArray: 1,2,3,6,8; ref.someVar: 5;

Summary 小结

如果一切都似乎不是很清晰的话那么最好通过这些举例去详细理解他们。比起理解举例来说,读懂其中的说明更加困难。我对闭包和堆栈结构等的解释在技术上来说并不严格正确,但是他们能够更有效简单的帮助我们理解闭包。当完全掌握这些基础理念后再着眼于细节会更有帮助。

结论:

  • 任何时候在另外一个函数里面使用函数,都使用了一个闭包。
  • 任何时候在函数内使用eval(),都使用了一个闭包。eval的内容指向了当前函数的局部变量,同事,在闭包内甚至可以通过用eval('var foo = ...')创建一个新的局部变量。
  • 当在函数中使用使用 new Fucntion(...)(Function构造器,Function constructor)的时候,不会产生一个闭包。(新函数不能够引用外部函数的局部变量)。
  • JavaScript里的闭包就像保持了一份所有局部变量的拷贝,就跟函数之前存在的时候一样。
  • 最好认为闭包总是在进入函数的时候创建的,并且函数内所有的局部变量都会被添加到该闭包里面。
  • 一个带有闭包的函数每次被调用时都会保持一组新的局部变量。(鉴于函数包含了一个内部声明的函数,并且通过返回或者外部引用或者其他某种方式,包含并保持了一个对于内部函数的引用)。
  • 两个函数或许看起来有相同的源文本,但是因为他们的“隐形”闭包而可能有完全不同行为。我认为JavaScript代码事实上没有办法确认一个函数引用是否存在一个闭包。。
  • 如果你在尝试做一些动态的源代码修改(比如: myFunction = Function(myFunction.toString().replace(/Hello/,'Hola')); ),如果myFunction是一个闭包那么修改就可能不会生效(当然你不可能在运行时还会想着对源代码的字符串进行修改,但是总是有一些特殊的情况...)
  • 可以在函数里进行函数声明,并在函数声明里继续进行函数声明——这种方式你会得到超过一层的闭包。。。
  • 我认为通常来讲闭包是函数和捕获的变量的范围(term),但是请注意,我在本文中并没有使用这个定义!
  • 我推测JavaScript的闭包与通常来讲在函数式语言中的闭包并不一样。

相关链接