codepsi

JavaScript闭包初探

codepsi · 2017-01-17翻译 · 771阅读 原文链接

闭包是JavaScript中的一个基本的概念,每一个真正的程序员都应该了解它的原理。 互联网上充斥着对于“闭包”是什么的解释,但很少深入到事物的“为什么”的一面。 我发现理解内部原理最终可以让开发者对他们的工具有更强的把握,所以本文将重点阐述“闭包”为什么以及怎样做的具体细节。 希望您阅读完本文之后,可以在您的日常工作中更好的利用“闭包”。现在我们就开始吧!

什么是闭包?

“闭包”是JavaScript(和大多数开发语言)的一项非常重要的特性。MDN是这样定义闭包的:

闭包是指向独立(自由)变量的函数。换句话说,在闭包中定义的函数能够记住创建它的环境。

注:自由变量指的是那些既不是局部声明也不是作为参数传递进来的变量。 让我们看几个例子:

Example 1:

function numberGenerator(){
// Local “free” variable that ends up within the closure
    var num=1;
    function checkNumber(){
        console.log(num);
    }
    num++;
    return checkNumber;
}

var number=numberGenerator();
number();// 2

在上面的例子中,函数numberGenerator创建了一个局部“自由”变量num和函数checkNumber。函数checkNumber没有属于自己的局部变量-但是它却有权限访问外部函数numberGenerator的变量。这就是因为闭包。所以它可以使用在numberGenerator中定义的变量num而且成功地将其输出到控制台,即使在numberGenerator被返回之后。

Example 2:

在本例中,我们将演示闭包包含在外部函数中声明的所有局部变量。

function sayHello(){
    var say=function(){console.log(hello);}
    var hello='Hello,world!';
    return say;
}
var sayHelloClosure=sayHello();
sayHelloClosure();// 'Hello,world!'

我们注意到变量hello虽然定义在匿名函数之后,但是仍然可以被访问到。这是因为变量hello在创建的时候已经在函数scope中定义了,使得当匿名函数最终被执行时能够获取到该变量(别着急,我一会儿会解释scope是什么,现在大家可以先忽略它)。

更高层次的理解

下面这些例子将在更高的层次上解释闭包是什么。总的来说,我们有权访问到定义在包围函数中的变量,即使定义这些变量的函数已经被返回。显然,为了能够实现这种效果,在后台一定有某些事情发生。

为了理解这如何变为可能,我们需要了解一些相关的概念-从3000英尺高的地方开始慢慢降落到闭包这片土地上。我们先从“执行上下文”的概念开始入手吧。

Execution Context

执行上下文

执行上下文是ECMAScript标准定义的一个抽象的概念,用来追踪代码的运行时赋值。它既可以是代码初次运行时的全局上下文也可以是代码执行到一个函数体时的执行上下文。

执行上下文

在任何时间点,只能有一个执行上下文运行。这就是为什么JavaScript被称为“单线程”语言,在同一时间,只能处理一个请求。通常,浏览器使用“栈”来存放执行上下文。“栈”是一个后进先出(FIFO)的数据结构,意思是最后被压入栈的数据,会被最先弹出(这是因为我们只能在“栈”的顶端添加或者删除元素)。当前的或者说“正在运行的”执行上下文总是在“栈”的顶端。当运行的执行上下文中的代码已经被完全解析时,它被弹出顶部,允许下一个顶部项目作为运行的执行上下文接管。

此外,仅仅因为执行上下文正在运行并不意味着它必须在其他执行上下文可以运行之前完成运行。有时,运行的执行上下文被挂起,并且不同的执行上下文变为运行的执行上下文。然后,挂起的执行上下文可以稍后在其停止的地方拾取。每当一个执行上下文被另一个替换时,一个新的执行上下文被创建并推送到堆栈上,成为当前的执行上下文。

有关在浏览器中操作的此概念的实际示例,请参阅下面的示例:

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);// 闭包

moar(15);

然后当boop返回时,它会弹出堆栈,并恢复bar:

当我们有一堆执行上下文一个接一个执行的时候,会经常在中间暂停,然后恢复,我们需要一些方法来跟踪状态,以便我们可以管理这些上下文的顺序和执行。事实是,根据ECMAScript规范,每个执行上下文具有各种状态组件,用于跟踪每个上下文中的代码执行的进度。

  • 代码解析状态 :执行,挂起和恢复与此执行上下文相关联的代码的解析所需的任何状态
  • 函数 :执行上下文正在解析的函数对象(如果正在解析的上下文是脚本或模块,则为null)
  • 领域 :一组内部对象,ECMAScript全局环境,在该全局环境范围内加载的所有ECMAScript代码,以及其他关联的状态和资源
  • 词汇环境:用于解析此执行上下文中的代码所作的标识符引用
  • 变量环境:词汇环境,其环境记录保存由此执行上下文中的变量状态创建的绑定

如果这听起来太混乱,不要担心。 在所有这些变量中,词汇环境变量是我们最感兴趣的变量,因为它明确声明它解析了执行上下文中的代码所作的“标识符引用”。 你可以把“标识符”当成变量。 因为我们的最初目标是弄清楚在一个函数(或“上下文”)返回之后,我们是否可以神奇地访问变量,词汇环境看起来像我们应该挖掘的东西!

注意:技术上,可变环境和词汇环境都用于实现闭包,但为了简单起见,我们将其概括为“环境”。有关词汇和可变环境之间的区别的详细解释,请参阅Alex Rauschmayer博士的优秀文章

词汇环境

按照定义:词汇环境是一种规范类型,用于定义标识符与特定变量和函数的关联,基于ECMAScript代码的词汇嵌套结构。词汇环境包括环境记录和对外部词汇环境的可能空引用。通常,词汇环境与ECMAScript代码的一些特定句法结构相关联,例如TryStatement的函数声明,BlockStatement或Catch子句,并且每次解析这样的代码时创建新的词汇环境。— _ECMAScript-262/6.0

让我们来分解它。

  • 用于定义标识符的关联:词汇环境的目的是管理代码中的数据(即标识符)。换句话说,它使标识符有意义。例如,如果我们有一行代码console.log(x / 10),变量(或“标识符”)x没有任何东西为其提供意义,那么这件事情是没有意义的。词汇环境通过其环境记录(见下文)提供了这个意义(或“关联”)。、
  • 词汇环境由一个环境记录组成:环境记录是一种奇特的方式,它保留所有标识符及其在词汇环境中存在的绑定的记录。每个词汇环境都有自己的环境记录。
  • 词汇嵌套结构:这是个有趣的部分,基本上说,内部环境引用围绕它的外部环境,并且该外部环境也可以具有它自己的外部环境。因此,环境可以用作多于一个内部环境的外部环境。全局环境是没有外部环境的唯一词汇环境。这里的语言很棘手,所以让我们使用一个比喻,并认为词汇环境如洋葱层:全局环境是洋葱的最外层;下面的每个后续层都嵌套在其中。

抽象地,环境在伪代码中看起来像这样:

LexicalEnvironment = {
 EnvironmentRecord: {
 // Identifier bindings go here |
 },
 // Reference to the outer environment
 outer: < >
 };
  • 每次解析此类代码时都会创建一个新的词汇环境:每次调用包围外部函数时,都会创建一个新的词汇环境。这很重要 - 我们将在最后再回到这一点。(附注:函数不是创建词汇环境的唯一方法。其他包括块语句或catch子句。为简单起见,我将重点介绍由这篇文章中的函数创建的环境)

简而言之,每个执行上下文都有一个词汇环境。这个词汇环境保存着变量及其相关的值,并且还引用了它的外部环境。词汇环境可以是全局环境,模块环境(其包含用于模块的顶层声明的绑定)或函数环境(由于调用函数而创建的环境)。

Scope Chain

范围链

基于上述定义,我们知道环境可以访问其父环境,并且其父环境可以访问其父环境,等等。每个环境都可以访问的这组标识符称为“范围”。我们可以将范围嵌套到称为“范围链”的分层环境链中。

让我们看看这个嵌套结构的一个例子:

var x=10;
function foo(){
    var y=20;
    function bar(){
        var z=15;
        return x+y-z;
    }
    return bar;
}

如你所见,bar嵌套在foo中。为了帮助您可视化嵌套,请参见下图:

我们将在后面的帖子中再次讨论这个例子。 与函数关联的此范围链或环境链在创建时保存到函数对象,换句话说,它是由源代码中的位置静态定义的。 (这也称为“词汇范围”)。

让我们快速绕行来理解“动态范围”和“静态范围”之间的区别,这将有助于澄清为了具有闭包,为什么静态范围(或词汇范围)是必要的。

改道: 动态范围 vs. 静态范围

动态作用域语言具有“基于栈的实现”,意味着局部变量和函数的参数存储在堆栈中。因此,程序堆栈的运行时状态决定了要引用的变量。 另一方面,静态范围是根据创建的时间来记录在上下文中。换句话说,程序源代码的结构决定了你所引用的变量。 此时,您可能想知道动态作用域和静态作用域是如何不同的。这里有两个例子来帮助说明:

Example 1:

var x=10;
function foo(){
    var y=x+5;
    return y;
}
function bar(){
    var x=2;
    return foo();
}
function main(){
    foo();// Static scope: 15; Dynamic scope: 15
    bar();// Static scope: 15; Dynamic scope: 7
    return 0;
}

我们在上面看到,当调用函数栏时,静态作用域和动态作用域返回不同的值。 对于静态作用域,bar的返回值基于foo创建时的x值。这是因为源代码的静态和语法结构,这导致x为10,结果为15。 另一方面,动态范围为我们提供了一个在运行时跟踪的变量定义的堆栈 - 这样我们使用哪个x取决于在运行时是否动态定义范围。运行函数栏将x = 2推到堆栈顶部,使foo返回7。

Example 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
})();

(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)。bar可以访问自由变量y,即使在函数foo返回后,因为bar通过其外部环境引用了y,这是foo的环境!bar也可以访问全局变量x,因为foo的环境可以访问全局环境。这被称为“范围链查找”。 回到我们对动态范围与静态范围的讨论:对于要实现的闭包,我们不能使用动态栈来存储我们的变量.原因是因为这意味着当一个函数返回时,这些变量会从堆栈中弹出,不再可用 - 这与我们最初的闭包定义相矛盾。而是,父级上下文的闭包数据保存在所谓的“堆”中,这允许数据在使它们返回的函数调用之后保留(即使在执行上下文从执行调用堆栈弹出之后). 合理?好!现在我们在抽象层面上理解了内部实现,让我们再来看几个例子:

Example 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递增时,它都会更新范围 - 这是所有函数共享的。这就是为什么任何5个函数尝试访问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

Example 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都会创建一个新的闭包,即foo和bar。随后调用任一闭包函数将更新闭包本身内的闭包变量,表明在iCantThinkOfAName返回后,每个闭包中的变量可以通过iCantThinkOfAName的doSomething函数继续使用。

Example 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,
};

因为我们的加法和减法函数引用了mysteriousCalculator函数环境,所以他们能够利用该环境中的变量来计算结果。

Example 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独占访问密码变量,同时使得不可能从外部访问密码。

相关文章