【转】学习JavaScript闭包

------------------------------------------------------------------------------------------------------------
闭包是JavaScript中一个基础概念,这是每一个严格意思上的程序员都应该十分熟悉的。
 
网络上有不少文章介绍闭包是什么,可是不多有文章深刻讲解为何是这样的。
 
我发觉从根本上去理解一种技术,可以使开发人员去熟练地使用他们所掌握的工具,因此这篇文章致力于从细节上去讲解闭包内部原理是怎么样的,以及为何是这样的。
 
但愿在你之后的平常工做中,可以更好的运用闭包的优点。那咱们开始吧!
 

什么是闭包?

闭包是JavaScript(和大多数编程语言)中一个强大的特性。MDN对闭包的定义是:
闭包是指向独立(自由)变量的函数,换句话说,定义在闭包里的函数“记住”了它建立时的环境。
 
注:自由变量是指那些既不是局部变量,也不是做为参数传递的变量。
 
咱们看几个例子:
 
例1:
复制代码
 1 function numberGenerator() {
 2   // 闭包里的局部“自由”变量
 3   var num = 1;
 4   function checkNumber() { 
 5     console.log(num);
 6   }
 7   num++;
 8   return checkNumber;
 9 }
10 
11 var number = numberGenerator();
12 number(); // 2
复制代码

 


在上面的例子中, numberGenerator函数建立了一个局部的“自由”变量 num (数字变量)和 checkNumber (把 num打印在命令窗口的函数)。 checkNumber 函数中没有定义局部变量——然而,它能够访问父函数( numberGenerator)里定义的变量,这就是由于闭包。所以,它可使用定义在 numberGenerator函数里的 num变量,并成功地把它输出在命令窗口,即使是在 numberGenerator函数返回以后依然如此。 
 
例2:
在这个例子中,咱们将演示一个闭包里包含了全部定义在其父函数内部定义的局部变量。
复制代码
1 function sayHello() {
2   var say = function() { console.log(hello); }
3   // Local variable that ends up within the closure 
4   var hello = 'Hello, world!';
5   return say;
6 }
7 var sayHelloClosure = sayHello(); 
8 sayHelloClosure(); // ‘Hello, world!’
复制代码

 


注意变量 hello是如何在匿名函数后定义的,但这个匿名函数依然能够访问 hello变量,这是由于 hello变量被建立时已经定义在函数“做用域”里了,这使得当匿名函数最终执行时, hello变量依然可用。(别急,我将随后在这篇文章中解释什么是“做用域”,如今,就让咱们来看看) 
 

高层次的理解

 
这两个例子从一个高层次来阐述了“什么”闭包。通常的主题是这样的——咱们能够访问定义在封闭函数里的变量,即便这些封闭函数定义在变量返回以后。显然,在这背后确定作了一些其它的事情,这些事使得这些变量即便在其后的封闭函数返回以后还能够访问。
 
为了理解这是怎么实现的,咱们须要去接触一些相关的概念——咱们将从更高的层次一步步走向闭包。让咱们从一个函数运行的全局上下文开始,即所谓的“执行上下文”。
 

执行上下文

 
执行上下文是ECMAScript规范使用的抽象概念,用于跟踪代码的运行时评估。这能够是你代码首先执行时的全局上下文,或者是当执行到一个函数体时的上下文。
 
 
在任意一个时间点,都只能运行一个执行上下文,这就是为何JavaScript是“单线程”的,这就意味着每次只能有一条命令被处理。一般,浏览器使用“栈”来维护这个执行上下文,栈是后进先出的数据结构,这意味着最后压进栈的是最早被弹出来的(这是由于咱们只能在栈顶插入或删除元素)。当前或“正在运行的”执行上下文老是在栈顶的,当运行执行上下文的代码被彻底执行后,它就从栈顶弹出,这就容许下一个顶部项接管运行执行上下文。
 
并且,仅仅由于执行上下文正在运行,并不意味着它必须在不一样的执行上下文运行以前完成运行;有时,运行执行上下文被挂起,不一样的执行上下文成为运行的执行上下文,被挂起的执行上下文可能会在之后的某个点上从新回到它被挂起的位置,在任什么时候刻,一个执行上下文就这样被其它执行上下文替代,一个新的执行上下文被建立,并压到栈里,成为当前执行上下文。
 
 
在浏览器里用实际的例子来讲明这个概念,请看下面这个例子:
 
按 Ctrl+C 复制代码
<gr_block p="0,502">var x = 10;
function foo(a) {
  var b = 20;

  function bar(c) {
  var d = 30;
  return boop(x + a + b + c + d);
  }

  function boop(e) {
  return e * -1;
  }

  return bar;
}

var moar = foo(5); // Closure
/*
  The function below executes the function bar which was returned
  when we executed the function foo in the line above. The function bar
  invokes boop, at which point bar gets suspended and boop gets push
  onto the top of the call stack (see the screenshot below)
*/
moar(15);
 
按 Ctrl+C 复制代码

 


 
 
 
当boop 返回时,它会弹出栈顶,而bar 复原:
 
 
当咱们有一串执行上下文一个接一个运行时——一般一个执行上下文在中间被暂停,而后又会被恢复——咱们须要一种方式来跟进这个状态的变化,因此咱们能够管理这些执行这些上下文的顺序,实际上就是这样的。根据ECMAScript规范,每一个执行上下文有各类状态组件,用于记录每一个上下文中的代码的进展状况。这包括:
  • 代码评估状态:执行、暂停和恢复与此执行上下文相关的代码的任何状态。
  • 函数:该执行上下文正在评估的函数对象。(若是被评估的上下文是脚本或模块,则为null)
  • 领域:一组内部对象,ECMAScript全局环境,在该全局环境范围内加载的全部ECMAScript代码,以及其余关联的状态和资源。
  • 词法环境: 用来解析该执行上下文中的代码所做的标识符引用。
  • 变量环境:词法环境,环境记录保存由该执行上下文中的变量状态建立的绑定。
若是这听起来让你很迷惑,不要担忧,全部这些变量,词法环境变量对咱们来讲是最有意思的变量,由于它显示声明,它解析该执行上下文中的代码所做的“标识符引用”。你能够认为“标识符”就是变量。由于咱们最初的目的是弄清楚,它是怎么去访问那些即便函数(或“上下文”)已经返回的变量,词法环境看起来就是咱们应该去深究的东西。
 
注意:从技术上说,经过使用变量环境和词法环境一块儿来实现闭包,可是为了简单起见,咱们将统一用“环境”来表示,对于词法环境和变量环境间的不一样处的细节解释,能够查看 Alex Rauschmayer’s博士的 article
 

词法环境

 
定义:词法环境是一种规范类型,用于根据ECMAScript代码的词汇嵌套结构定义标识符与特定变量和函数的关联。词汇环境由一个环境记录和一个指向外部词汇环境的可能为空的引用组成。一般,词汇环境与ECMAScript代码的某些特定的语法结构相关联,好比函数声明、块语句或异常捕获语句,以及每次执行这些代码时,都会建立一个新的词法环境。
 
让咱们来分开解释下:
  • “用于定义标识符的关联”:词法环境的目的是用来管理代码里的数据(如标识符),换句话说,它使得标识符有意义。例如,若是咱们有一行代码“console.log(x / 10)”,若是变量(或“标识符”)x没有任何含义,那么这行代码就没有任何意义了。词法环境就是经过它的环境记录来提供意义(或“关联”)。
  • “词法环境由环境记录组成”:环境记录是用一种奇特的方式来描述它是保存了全部标识符和它们在词法环境里的绑定的记录。每一个词法环境都有各自的环境记录。
  • “词法嵌套结构”:这是最有意思的部分,这个基本上说是它的内部环境引用它的外部环境,而它的外部环境也同样能够有它的外部环境,因此,一个环境能够是多个内部环境的外部环境。全局环境是惟一一个没有外部环境的词法环境,这就是JS的棘手之处,咱们能够用洋葱的皮层来表示词法环境:全局环境就是洋葱最外层的皮层,每个子层都嵌套在它里面。
 
 
抽象地说,用伪代码来描述环境它看起来就是这样的:
复制代码
1 LexicalEnvironment = {
2   EnvironmentRecord: {
3   // Identifier bindings go here
4   },
5   
6   // Reference to the outer environment
7   outer: < >
8 };
复制代码

 

 
  •  “每次执行这样的代码就会建立一个新的词法环境”:每次一个封闭的外部函数被调用时,就会建立一个新的词法环境,这一点很重要——咱们在文章最后将会再说到这点。(边注:函数不是惟一能够建立词法环境的方式,块语句和catch子句也能够建立词法环境,为了简单起见,在这篇文章中咱们将只说函数建立的环境。
总之,每个执行上下文都有一个词法环境,这个词法环境包含了变量和其相关的值,以及对它外部环境的引用。词法环境能够是全局环境、模块环境(它包含对模块顶层声明的绑定),或者函数环境(因为调用函数建立的环境)
 
 

做用域链

 
基于上面的定义,咱们知道一个环境能够访问它的父环境,它的父环境也能够访问它的父环境,依次类推。每一个环境均可以访问的这个标识符集称为“做用域”。咱们能够嵌套做用域到一个层次环境链里,这就是咱们所知道的“做用域链”。
 
咱们来看一个嵌套结构的例子:
复制代码
 1 var x = 10;
 2 
 3 function foo() {
 4   var y = 20; // free variable
 5   function bar() {
 6     var z = 15; // free variable
 7     return x + y + z;
 8   }
 9   return bar;
10 }
复制代码

 


就像你所看到的, bar就是嵌套在 foo里,为你帮你视觉化嵌套,请看下图: 
 
 
咱们在文章后面再回顾一下这个例子。
 
做用域链或者一个函数相关的环境链,是在建立时保存在这个函数对象。它是由源代码中的位置静态定义的。(这就是咱们熟知的“词法做用域”)
 
让咱们快速地了解一下“动态做用域”和“静态做用域”的不一样之处,这将帮助咱们理解为了实现闭包, 为何静态做用域(或者词法做用域)是必须存在的。
 

动态做用域 VS 静态做用域

 
动态做用域语言具备“基于栈的实现”,这意味着局部变量和函数参数被存放在堆栈里,所以,程序堆栈的运行时状态决定了你所引用的变量。
 
另外一方面,静态范围是根据建立的时间来记录在上下文中,换句话说,程序源代码的结构决定了你所引用的变量。
 
到此,你可能会想动态做用域和静态做用域是如何不一样的。下面有两个例子来帮你阐述这一点:
 
例1:
 
复制代码
 1 var x = 10;
 2 
 3 function foo() {
 4   var y = x + 5;
 5   return y;
 6 }
 7  
 8 function bar() {
 9   var x = 2;
10   return foo();
11 }
12  
13 function main() {
14   foo(); // Static scope: 15; Dynamic scope: 15
15   bar(); // Static scope: 15; Dynamic scope: 7
16   return 0;
17 }
复制代码

 


bar函数被调用时,咱们能够看到上面的动态做用域和静态做用域返回了不一样的值。
 
在静态做用域里, bar返回的值是基于 foo函数建立时返回的 x的值,这是由于源代码的静态和词法结构,结果就是 x的值是10,最后返回的结果就是15.
 
另外一方面,动态做用域在运行时为咱们提供了一组变量定义——这样咱们具体使用的是哪一个 x就取决于哪一个 x在做用域里,以及在运行时哪一个 x被动态定义了。运行 bar函数把x=2压到栈顶,这样就使得 foo返回7了。
 
例2:
 
复制代码
var myVar = 100;
 
function foo() {
  console.log(myVar);
}
 
foo(); // Static scope: 100; Dynamic scope: 100
 
(function () {
  var myVar = 50;
  foo(); // Static scope: 100; Dynamic scope: 50
})();

// Higher-order function
(function (arg) {
  var myVar = 1500;
  arg();  // Static scope: 100; Dynamic scope: 1500
})(foo);
复制代码

 


一样,在动态做用域的例子,上面的 myVar变量在使用了 myVar变量的函数被调用的地方解析。另外一方面,在静态做用域里,将 myVar解析为在建立两个IIFE函数的范围内保存的变量 。
 
就像你所看到的,动态做用域经常致使一些歧义,这不能明确知道自由变量将解析自哪一个做用域。
 

闭包

 
有些可能让你以为离题了,可是事实上,咱们已经涵盖了咱们所须要了解闭包的全部东西了:
 
每一个函数都有一个执行上下文,它包含给定函数里的变量意义的环境,和指向它父环境里的引用。指向父环境里的引用使得父做用域里的全部变量对于其全部内部函数都是可用的,无论内部函数是否在它们建立时的做用域内或外被调用。
 
因此,这就像函数“记住”它的环境(或者做用域),由于函数实际上有一个指向这个环境的引用(以及定义在那个环境里的变量)
 
回到嵌套结构的例子:
 
复制代码
var x = 10;

function foo() {
  var y = 20; // free variable
  function bar() {
    var z = 15; // free variable
    return x + y + z;
  }
  return bar;
}

var test = foo();

test(); // 45
复制代码

 


基于咱们对环境是如何工做的认识,咱们能够说,上面例子中定义的环境看起来是这样的(注意,这个彻底是伪代码): 
 
复制代码
GlobalEnvironment = {
  EnvironmentRecord: { 
    // built-in identifiers
    Array: '<func>',
    Object: '<func>',
    // etc..
    
    // custom identifiers
    x: 10
  },
  outer: null
};
 
fooEnvironment = {
  EnvironmentRecord: {
    y: 20,
    bar: '<func>'
  }
  outer: GlobalEnvironment
};

barEnvironment = {
  EnvironmentRecord: {
    z: 15
  }
  outer: fooEnvironment
};
复制代码

 

 
当咱们调用 test函数时,咱们获得的结果是45,这是从 bar函数被调用时返回的值(由于 foo函数返回 bar函数),即便 foo函数返回后, bar仍是能够访问变量 y,由于 bar经过它的外部环境引用 y,它的外部环境就是 foo的环境, bar也能够访问全局变量 x,由于 foo的环境能够访问全局环境。这称之为“沿着做用域链查找” 
 
返回咱们讨论的动态做用域和静态做用域:要实现闭包,咱们不能使用动态做用域来存储咱们的变量。这是由于,这样作的话,当函数返回时,变量将会从栈里弹出,并将再也不有效——这就和咱们对闭包最初的定义正好相反。取而代之是闭包中父级上下文中的数据被保存在称之为“堆”的东西里,它容许函数调用返回后,它的数据还保存在堆里(好比 即便执行上下文被弹出执行调用栈)。
 
听起来颇有道理?很好,咱们如今在抽象层面理解了闭包的内部实现,让咱们来多看几个例子:
 
例1:
一个典型的例子/错误是当有一个for循环,并且咱们尝试把for循环中的计数变量与for循环中的一些函数相关联:
 
复制代码
var result = [];
 
for (var i = 0; i < 5; i++) {
  result[i] = function () {
    console.log(i);
  };
}

result[0](); // 5, expected 0
result[1](); // 5, expected 1
result[2](); // 5, expected 2
result[3](); // 5, expected 3
result[4](); // 5, expected 4
复制代码

 


回到咱们刚才所学的,咱们就能够垂手可得就发现其中的错误所在!绝对,当for循环结束后,它这里的环境就像下面的同样: 
 
复制代码
environment: {
  EnvironmentRecord: {
    result: [...],
    i: 5
  },
  outer: null,
}
复制代码

 


这里错误的假想在做用域,觉得结果数组中五个函数的做用域是不同的,然而,事实上结果数组中五个函数的环境(或者/上下文/做用域)是同样的,所以,变量 i每增长一次,它就更新了做用域里的值——这个做用域里的值是被全部函数共享的。这就是为何五个函数中的任意一个去访问 i时都返回5的缘由(当for循环结束时, i等于5)。 
 
解决这个问题的一种方式,是为每个函数建立一个附加的封闭上下文,这样每一个函数都能取得它们本身拥有的执行上下文/做用域:
 
复制代码
var result = [];
 
for (var i = 0; i < 5; i++) {
  result[i] = (function inner(x) {
    // additional enclosing context
    return function() {
      console.log(x);
    }
  })(i);
}

result[0](); // 0, expected 0
result[1](); // 1, expected 1
result[2](); // 2, expected 2
result[3](); // 3, expected 3
result[4](); // 4, expected 4
复制代码

 


对!这样就能够了:) 
 
另外,更聪明的方法是用let代替var,由于let是块做用域,因此在for循环中一个新的标识符绑定是在每次迭代时被建立的:
 
复制代码
var result = [];
 
for (let i = 0; i < 5; i++) {
  result[i] = function () {
    console.log(i);
  };
}

result[0](); // 0, expected 0
result[1](); // 1, expected 1
result[2](); // 2, expected 2
result[3](); // 3, expected 3
result[4](); // 4, expected 4
复制代码

 


例2:  
在这个例子里,咱们将展现每次回调函数时是怎么建立一个新的、独立的闭包:
 
复制代码
function iCantThinkOfAName(num, obj) {
  // This array variable, along with the 2 parameters passed in, 
  // are 'captured' by the nested function 'doSomething'
  var array = [1, 2, 3];
  function doSomething(i) {
    num += i;
    array.push(num);
    console.log('num: ' + num);
    console.log('array: ' + array);
    console.log('obj.value: ' + obj.value);
  }
  
  return doSomething;
}

var referenceObject = { value: 10 };
var foo = iCantThinkOfAName(2, referenceObject); // closure #1
var bar = iCantThinkOfAName(6, referenceObject); // closure #2

foo(2); 
/*
  num: 4
  array: 1,2,3,4
  obj.value: 10
*/

bar(2); 
/*
  num: 8
  array: 1,2,3,8
  obj.value: 10
*/

referenceObject.value++;

foo(4);
/*
  num: 8
  array: 1,2,3,4,8
  obj.value: 11
*/

bar(4); 
/*
  num: 12
  array: 1,2,3,8,12
  obj.value: 11
*/
复制代码

 


在这个例子里,咱们能够看到每次调用 iCantThinkOfAName函数时都会建立一个新的闭包,也就是 foobar。后续调用每一个闭包函数都会更新闭包内的变量,这展现了 iCantThinkOfAName函数返回后,每一个闭包里的变量继续被 iCantThinkOfAName函数里的 doSomething函数所使用。
 
例3:
 
复制代码
function mysteriousCalculator(a, b) {
    var mysteriousVariable = 3;
    return {
        add: function() {
            var result = a + b + mysteriousVariable;
            return toFixedTwoPlaces(result);
        },
        
        subtract: function() {
            var result = a - b - mysteriousVariable;
            return toFixedTwoPlaces(result);
        }
    }
}

function toFixedTwoPlaces(value) {
    return value.toFixed(2);
}

var myCalculator = mysteriousCalculator(10.01, 2.01);
myCalculator.add() // 15.02
myCalculator.subtract() // 5.00
复制代码

 


咱们可以看到的是 mysteriousCalculator是在全局做用域里,并且它返回了两个函数。抽象来看,上面例子中的环境就像是这样的: 
 
复制代码
GlobalEnvironment = {
  EnvironmentRecord: { 
    // built-in identifiers
    Array: '<func>',
    Object: '<func>',
    // etc...

    // custom identifiers
    mysteriousCalculator: '<func>',
    toFixedTwoPlaces: '<func>',
  },
  outer: null,
};
 
mysteriousCalculatorEnvironment = {
  EnvironmentRecord: {
    a: 10.01,
    b: 2.01,  
    mysteriousVariable: 3,
  }
  outer: GlobalEnvironment,
};

addEnvironment = {
  EnvironmentRecord: {
    result: 15.02
  }
  outer: mysteriousCalculatorEnvironment,
};

subtractEnvironment = {
  EnvironmentRecord: {
    result: 5.00
  }
  outer: mysteriousCalculatorEnvironment,
};
复制代码

 


由于咱们的 addsubtract函数都有一个指向 mysteriousCalculator函数环境的引用,它们可使用那个环境里的变量来计算结果。 
 
例4:
最后这个例子演示了闭包最重要的一个功能:维护一个私有指向外部做用域变量的引用。
 
复制代码
function secretPassword() {
  var password = 'xh38sk';
  return {
    guessPassword: function(guess) {
      if (guess === password) {
        return true;
      } else {
        return false;
      }
    }
  }
}

var passwordGame = secretPassword();
passwordGame.guessPassword('heyisthisit?'); // false
passwordGame.guessPassword('xh38sk'); // true
复制代码

 


这是一个很强大的技巧——它使得闭包函数 guessPassword能够独占访问 password变量,同时让 password变量不能从外部访问。 
 

摘要

 
  • 执行上下文是ECMAScript规范用来根据运行时代码执行的一个抽象概念。在任什么时候候,在代码执行时都只有一个执行上下文。
  • 每一个执行上下文都有一个词法环境,这个词法环境保留着标识符绑定(如变量及其相关的值),同时还有一个指向它外部环境的引用。
  • 每一个环境均可以访问的标识符集称为“做用域”。咱们能够嵌套这些做用域到层次环境链中,这就是“做用域链”。
  • 每一个函数都有一个执行上下文,它由一个给予函数里的变量意义的词法环境,和指向父环境的引用组成,这看起来就像是函数“记住”这个环境(或者做用域),由于函数事实上有一个指向这个环境的引用,这就是闭包。
  • 每次一个封闭外部函数被调用时就会建立一个闭包,换句话说,内部函数不须要返回要建立的闭包。
  • JavaScript里的闭包做用域就是词法,这意味着它是在源代码里的位置静态定义的。
  • 闭包用许多实际的用处,最重要的一个用处是维护一个私有指向外部环境变量的引用。
 

结束语

 
我但愿这篇文章能对你有所帮助,但愿它能给你一种心智模式——在JavaScript里闭包是如何实现的。正如你所见,理解它们是如何工做的,可让你更好地掌握闭包——更不用说当你调试Bug时为你省下了不少麻烦。
 
PS:人有失足——若是你发现有任何问题,我但愿你能跟我说一声。
 

延伸阅读

 
为了简单起见,我避开了一些可能对有些读者感兴趣的主题,下面是一些我想分享给大家的连接:  
相关文章
相关标签/搜索