JavaScript学习笔记(4) 闭包详解(Closure Are Not Magic)

写在开头

从我学习Javascript的第一天开始,就听说理解闭包是一件极其重要的事。看了JS高级程序设计以后,大概了解了一些,但当朋友问我“你知道什么是闭包吗”,我还是一头雾水。所以今天就想结合例子来讲讲:到底如何理解闭包?
本文是基于stackoverflow上的一篇高票答案所写的,在部分翻译的基础上加上了自己的理解,并且写的尽量基础,希望能让大家对闭包有更好的认识。

概念

官方定义
  • 闭包是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。 --维基百科
  • 闭包是指那些能够访问自由变量的函数(变量在本地使用,但定义在一个封闭的作用域中)。 --MDN
理解性概念
  • 当在一个函数内定义另外一个函数就会产生闭包,但当你使用构造函数来创建函数时,不会产生闭包。
  • 闭包可以理解为函数的局部变量集合,只是这些局部变量在函数返回后会继续存在。
  • 闭包是一种特殊的对象,它由两部分构成:1.函数 2.创建该函数时的环境(由闭包创建时在作用域中的任何局部变量组成)

预备知识

词法作用域

在Javascript中,变量的作用域是由它在源代码中所处位置决定的,并且嵌套的函数可以访问到其外层作用域中声明的变量。

举个简单的例子:

function init(){
 var name="mario";  //name 是一个局部变量
 function displayName(){     //这是一个内部函数,仅可以在init()之内使用
  alert(name);    //这里使用了在父函数(init)中声明的变量
 }
 displayName();
}

init();//输出 mario
头等函数 (first-class function)

头等函数是指在程序设计语言中,函数被当做头等公民。这意味着函数可以作为别的函数的参数、函数的返回值,赋值给变量或者存储在数据结构中。Javascript支持头等函数。

用例子理解闭包

例1:最典型的闭包
 function sayHello(sentence){
  var text=sentence;
  var say=function(){
   alert(text);
  }
  return say;
 }
 var output=sayHello("Hello world");
 output(); //输出“Hello World”

上述代码中,我们将"Hello world"当做参数传到函数sayHello中,局部变量text会被赋值为"Hello world",根据以往的习惯我们会认为这个text的值是无法被函数外部获取到的,因为在一些编程语言如c语言中,函数中的局部变量仅在函数的执行期间可用。然而,结果显示在函数结束后我们仍能输出"Hello world",从这里我们可以看出:我们能从sayHello()函数外部获取到局部变量text的值,它没有随着sayHello()的结束而被销毁。形成这个结果的原因是output变成了一个闭包,它由say函数和闭包创建时所存在的text变量所组成。

例2:闭包中的局部变量是引用而非拷贝
 function sayHello(sentence){
  var text=sentence;
  var textCopy=text;
  var say=function(){
   alert(text);
   alert(textCopy);
  }
  text="I changed!";
  return say;
 }
 var output=sayHello("Hello world");
 output(); //输出 “I changed”,"Hello world"

这里textCopy是text的一个拷贝,如果闭包中的局部变量也是拷贝的话,那么output()输出时应该会连续输出两条"Hello world"。但由于局部变量实际上是引用,所以text改变了,输出值也会改变,但textCopy因为是一个拷贝,它的值是不会受text的影响的。通俗的说,闭包中的text就是sayHello()中的text。

例3:不同函数绑定同一个闭包
 var outputNum,addNum,setNum;
 function originNum(){
  var num=0;
  
  outputNum=function(){
   alert(num);
  }
  
  addNum=function(){
   num++;
  }
  
  setNum=function(x){
   num=x;
  }
 }
 
 originNum();
 addNum();
 outputNum();//输出1
 setNum(5);
 outputNum();//输出5
 var oldNum=outputNum(); //储存结果
 
 originNum(); //第二次调用主函数originNum()
 outputNum();//输出0
 oldnum();//输出5

这里将三个匿名函数传递给了outputNum,addNum,setNum这三个全局变量,这三个函数共享了同一个闭包(包含同一个局部变量num),所以任何对num的操作都在三个函数中的反映是一致的。
值得注意的是,当第二次调用originNum()时,生成了一个新的闭包,因为outputNum,addNum和setNum都是全局变量,所以当新生成的函数传递给他们后,原来的函数都被取代了,它们在开始共享新的闭包,所以num的值重新变为了初始值0。如果想要储存之前的值,必须用别的变量来储存。(JS中,当你在一个函数内部定义另一个函数,每次主函数被调用时,内部函数都会被重新生成。) 这一点会在例6中更深入讨论。

例4:循环中的闭包
function buildList(list) {
    var result = [];
    for (var i = 0; i < list.length; i++) {
        var item = 'item' + i;
        console.log(item + ' ' + list[i]);//输出 item0 a; item1 b; item2 c;
        result.push( function() {console.log(item + ' ' + list[i])} );
    }
    console.log("i= "+i); //输出3
    return result;
}

function testList() {
    var fnlist = buildList([" a"," b"," c"]);
    for (var j = 0; j < fnlist.length; j++) {
        fnlist[j]();
    }
}

 testList()//输出三个“item2 undefined”

上面这段代码初看有点难以理解,让我们一步一步来解析。首先理解第一个函数buildList中的输出信息应该是比较容易的,它在testList中被调用时,list中传入三个字符串" a"," b"," c"(abc之前的空格只是为了输出美观),所以会依次输出item0 a; item1 b; item2 c;同时i输出3是因为在离开for循环之前的最后一步还是会执行依次i++,这些都和闭包没有关系,都是前提。真正涉及到闭包的代码是fnlist[j](),随着j的取值依次为0,1,2,会调用buildList中被push进result的函数function(){console.log("item"+i+list[i])},这三个函数分别是在i为0,1,2的时候被push进result里的,那么为什么当调用fnlist[0],fnlist[1]和fnlist[2]的时候输出结果都为“item2 undefined”呢?其实究其根本,这个例子还是和例2例3遵循着相同的规律,首先,这还是一个不同函数绑定同一个闭包的例子,只不过例3没有被放在一个for循环里,因为例3的每个函数做的事不同,而这里三个不同的匿名函数做了相同的事,同样的,他们也绑定了同一个闭包,那么与例3相同,他们共享着闭包中的局部变量i。其次,如例2中所提到的,闭包中的局部变量是引用而非拷贝。简单的说,无论之前i或者item等于多少,这里我们获取闭包中的局部变量时只能获取到它最后被赋予的值,因为之前的值都被覆盖了。所以当i在buildList中循环过后变成了3之后,fnList[j]调用三次函数时所获取到的i都是i=3,因为i在进行完buildList()函数之后停留在了3,而list[3]是没有被定义的,(我们只定义了list[0]=1;list[1]=2;list[2]=3)所以是undefined。同理,item在经过buildList()之后停留在了"item2",所以这就是为什么最后会输出3次"item2 undefined"。所以总结的话,例4还是运用了例2和例3的概念,但因为用了循环加上我们对循环中的匿名函数不是那么熟悉,所以会感觉有点复杂,这类题目也经常出现在前端的笔试题里,如果大家还是有不理解的地方希望能留言讨论。

例5:每一次函数调用都会创建新的闭包
var outputNum,addNum,setNum;
  function originNum(x){
   var num=10;

    addNum=function(){
   num++;
  }

   setNum=function(x){
   num=x;
  }

    return function(){
      console.log(num+x);
    }

 }

  fn1=originNum(3);
  fn2=originNum(5);
  fn1(); //输出13
  fn2(); //输出15
  
  addNum();
  fn1(); //输出13
  fn2(); //输出16

这个例子我在例3的基础上细微的变化,但这里重点想表达的是每一次函数调用都会创建新的闭包,上述fn1和fn2为两次调用,它们创建了2个闭包。所以fn1()和fn2()的输出结果是不同的,因为这两个function中的num是两个不同闭包中的局部变量。但为什么例3也是同样调用两次originNum,在第二次调用时之前的num就被看似“覆盖”了呢?实际上在第二次调用之后,第一次调用所创建的num依然还存在,只不过它“失宠”了,全局函数addNum中的num不再用它而是用新生成的num了,所以当我们在这里调用addNum()以后,fn1()中的num不再增加,但依然存在,仍旧可以输出13,fn2()中的num作为目前的“宠儿”,被加上了1。而之所以会有“失宠”这个概念,只是因为addNum作为全局变量,只能保存一个函数,把新的函数传递给它必定就意味着会取代旧的函数。例5和例3本质上是一样的,只是想表达的侧重点不同。

例6 (可以跳过)
function sayAlice() {
    var say = function() { console.log(alice); }
    var alice = 'Hello Alice';
    return say;
}
sayAlice()();

原文中举这个例子想表达的是闭包会包含所有在函数结束之前被声明的局部变量。这里alice即使被声明在匿名函数之后,仍然可以被获取到。但我认为形成这个的主要原因是JS存在变量提升的特性,所以这个例子和例子最典型的闭包例子其实一模一样。

总结

引用知乎“寸志”的回答,Javascript闭包的本质源于两点,词法作用域函数当做值传递。我们上述的每一个例子中,都可以看到函数被当做值返回或者传递,我们可以将这个返回的函数看作一个通道,这个通道因为可以访问这个函数词法作用域中的变量(即我们经常提到的局部变量),所以将函数所需要的数据结构保存了下来。(所以局部变量不会随着创建它的函数结束而被销毁)所以,闭包的形成很简单,在执行过程完毕后,返回函数或者将函数得以保留下来,即形成闭包。

个人感觉闭包真的是一个很抽象的概念,看一些官方的解释经常能看的一头雾水,所以个人认为借助例子来理解是一种很好的学习方法。上文中的解释有些并不严谨,但本意是希望能用一种比较容易让人理解的方式来传达闭包的特性。如果有我没讲明白或者有理解错误的地方,欢迎留言讨论。有英文阅读能力的同学,可以阅读一下stackoverflow的原文,虽然我觉得它的例子举的不算太好,但是说的很详细。知乎“寸志”的回答让我在写完这么长篇例子后脑子有点混乱的情况下一下子清晰了起来,它的回答可以算是对以上6个例子很好的总结,也很容易理解,推荐去原文看一下。

参考

  • stackoverflow--How do JavaScript closures work?
  • MDN
  • 什么是闭包?--知乎“寸志”的回答

你可能感兴趣的:(JavaScript学习笔记(4) 闭包详解(Closure Are Not Magic))