什么是JavaScript闭包?

原文出处 What’s a JavaScript closure? In plain English, please.

JavaScript闭包就如同汽车的功能——不同的位置都有对应那辆车的不同组件。

JavaScript中的每一个函数都构成一个闭包,这也是JavaScript最酷的特点之一。因为没有闭包的话,实现像回调函数或者事件句柄这样的公共结构就会很困难。

不管你什么时候定义了一个函数,你都创建了一个闭包。然后当你执行这些函数时,他们的闭包能够让他们访问他们作用域内的数据。

这有点像生产一辆带有一些像start, accelerate, decelerate之类功能的汽车。司机每次操纵他们的车时执行这些功能。定义这些函数的闭包就像汽车一样,并且‘闭合’了需要操作的变量。

让我们拿accelerate函数做一个简单的类比,当汽车被制造的时候,函数也就被定义了:

function accelerate(force) {
  // Is the car started?
  // Do we have fuel?
  // Are we in traction control mode?
  // Many other checks...
  // If all good, burn more fuel depending on 
  // the force variable (how hard we’re pressing the gas pedal)
}

每次司机踩下油门,这个方法就被执行。注意这个函数需要访问很多变量才能执行,包括它自己的force变量。但是更重要的是,它需要自己作用域外被其它汽车功能控制的变量。这就是accelerate函数的闭包(我们从汽车本身获得到的)的用处。

以下是accelerate函数的闭包对加速函数所作出的承诺:

好的accelerate,当你执行时,你可以访问你的_force_变量,你可以访问_isCarStarted_变量,也可以访问_fuelLevel_变量和_isTractionControlOn_变量。 你也可以控制我们发送给引擎的_currentFuelSupply_变量。

请注意,闭包不会为这些变量赋予acceleration函数确切的值,而是允许在accelerate函数执行时访问这些值。

闭包与函数作用域密切相关,因此理解这些作用域如何工作将有助于理解闭包。 简而言之,了解作用域最重要的就是了解当你执行一个函数时,一个私有函数作用域被创建并用于执行该函数的过程。

然后当你内部函数开始执行函数时,这些函数作用域就会形成嵌套。

当你定义一个函数时就创建了一个闭包,而不是当你执行它的时候。然后,每当你执行这个函数,其已经定义的闭包使它可以访问所有对它可用的函数作用域。

在某种程度上,你可以认为作用域是临时的(全局作用域除外),而把闭包是永久的。

一个chrome调试工具展示的闭包。

想要真正了解闭包好它在JavaScript里扮演的角色,你首先需要明白几个简单的JavaScript函数和作用域的概念。

在我们开始之前,注意我已经创建了一个交互实验,你可以在这里查看。

1 — 按引用分配函数

当你把一个函数赋值给一个变量,就像这样:

function sayHello() {
  console.log("hello");
};

var func = sayHello;

你正在给变量func赋予一个sayHello的引用,而不是复制。这使得func仅仅是sayHello的一个别名,你在这个别名上做的任何事,其实都是在原来的函数上操作的。比如:

func.answer = 42;

console.log(sayHello.answer); // prints 42

属性的answer是直接在func上设置的,然后使用sayHello进行读取,这依然是有效的。

你还可以通过执行func别名来执行sayHello:

func() // prints "hello"

2 — 作用域有生命周期

当你调用一个函数时,在执行该函数期间创建一个作用域,函数执行完毕,作用域消失。

当你第二次调用该函数时,在第二个执行期间创建一个新的不同的作用域,当函数执行完毕,第二个作用域也随之消失。

function printA() {
  console.log(answer);
  var answer = 1;
};
printA(); // this creates a scope which gets discarded right after
printA(); // this creates a new different scope which also gets discarded right after;

在上面的示例中创建的这两个作用域是不同的。这里的变量answer在它们两个之间完全是不共享的。

每个函数作用域都有一个生命周期。它们会被创建出来,然后又立刻被丢弃。惟一的例外是全局作用域,只要应用程序在运行,它就不会消失。

3 — 闭包跨越多个作用域

当你定义一个函数,也就创建了一个闭包

和作用域不同,闭包是当你定义一个函数时创建的,而不是你执行函数的时候。闭包在你执行完函数后也不会消失。

在定义了一个函数很久以后,你依然可以访问闭包里的数据,即使它执行了也是一样。

一个闭包包含所有定义好的函数可以访问的书。这意味着定义函数的作用域,全局作用域和定义函数作用域之间嵌套的作用域,以及全局作用域本身。

var G = 'G';

// Define a function and create a closure
function functionA() {
  var A = 'A'

  // Define a function and create a closure
  function functionB() {
    var B = 'B'
    console.log(A, B, G);
  }

  functionB();  // prints A, B, G

  // functionB closure does not get discarded
  A = 42;
  functionB();  // prints 42, B, G
}

functionA();

当我们定义一个functionB所创建的闭包,允许我们访问functionB的作用域,functionA的作用域以及全局作用域。

每次我们执行functionB,我们都可以通过先前创建好的闭包访问变量B, A, 和 G。然而,闭包并不是复制了这些变量,而是引用它们。

例如,functionB的闭包被创建之后,变量A的值会在某些时候发生变化,当我们执行functionB之后,我们会看到新的值,而不是旧的值。functionB的第二个调用打印42、B、G,因为变量A的值被更改为42,闭包给我们提供了一个引用,而不是一个副本。

不要将闭包和作用域混淆

把闭包与作用域混淆是很常见的,所以让我们确保不要这样做。

// scope: global
var a = 1;
void function one() {
  // scope: one
  // closure: [one, global]
  var b = 2;

  void function two() {
    // scope: two
    // closure: [two, one, global]
    var c = 3;

    void function three() {
      // scope: three
      // closure: [three, two, one, global]
      var d = 4;
      console.log(a + b + c + d); // prints 10
    }();
  }();
}();

在上面的简单例子中,我们定义并立即调用了三个函数,所以他们都创建了作用域和闭包。

函数one()的作用域就是它自己,它的闭包让我们有访问它和全局作用域的权利。

函数two()的作用域就是它自己,它的闭包让我们有访问它和函数one(),还有全局作用域的权利。

同样,函数three()的闭包给我们访问所有作用域的权力。这就是为什么我们可以在函数three()中访问所有变量的原因。

但是作用域和闭包的关系不总是如此。在不同作用域里定义和调用函数时,情况又会变得不一样。让我通过一个例子来解释:

var v = 1;

var f1 = function () {
  console.log(v);
}

var f2 = function() {
  var v = 2;
  f1(); // Will this print 1 or 2?
};

f2();

你认为上面的例子中会打印1还是2?代码很简单,函数f1()打印v的值,是全局作用域的1。但是我们在有不同的值等于2的v的函数f2()里执行f1(),然后再执行f2()

这段代码将会打印1还是2?

如果你想说2,那么你将会感到惊讶,这段代码实际上会打印1。原因是作用域和闭包并不相同。console.log方法会使用当我们定义f1()时所创建的f1()闭包,这意味着f1()的闭包值允许我们访问f1()和全局的作用域。

我们执行f1()的地方的作用域并不会影响闭包。实际上,f1()的闭包并不会给我们访问函数f2()作用域的权力。如果你删除全局变量v,然后执行这段代码,你将会得到错误消息:

var f1 = function () {
  console.log(v);
}

var f2 = function() {
  var v = 2;
  f1(); // ReferenceError: v is not defined
};

f2();

这对理解和记忆非常重要。

4 — 闭包有读和写的权限

由于闭包给我们提供了在作用域中的变量的引用,所以意味着它们给我们的权限包括读和写,而且不仅仅是读。

看看这个例子:

function outer() {
  let a = 42;

  function inner() {
    a = 43;
  }

  inner();
  console.log(a);
}

outer();

我们定义了一个inner()函数,创建了一个可以让我们访问变量a的闭包。我们可以读写这个变量,并且如我们我们真的改变了它的值,我们会改变outer()作用域里变量a的值。

这段代码会打印43,因为我们用inner()函数的闭包改变了outer()函数的变量

这就是为什么我们可以在任何地方改变全局变量。所有闭包都给我们提供了对所有全局变量的读写权限。

5 — 闭包可以分享作用域

因为在定义函数时,闭包就给我们访问嵌套作用域的权力,所以当我们在同一个作用域中定义多个函数时,这个作用域就被其中的闭包共享。由于这个原因,全局作用域总是被所有闭包共享。

function parent() {
  let a = 10;

  function double() {
    a = a+a;
   console.log(a);
  };

  function square() {
    a = a*a;
   console.log(a);
  }

  return { double, square }
}

let { double, square } = parent();

double(); // prints 20
square(); // prints 400
double(); // prints 800

在上面的例子中,我们有一个设置变量a的值为10的函数parent(),我们在函数parent()的作用域里定义了两个函数,double()square()。定义函数double()square()时所创建的闭包共享函数double() 的作用域。

因为double()square()都会改变变量a,当我们执行最后3行代码时,我们先把a相加(让a = 20),然后把相加后的值相乘(让a = 400),然后把相乘后的值相加(让a = 800)。

最后一个测试

让我们来测试到目前为止你对闭包的理解。在你执行下面的代码之前,先猜猜它会打印什么:

let a = 1;

const function1 = function() {
  console.log(a);
  a = 2
}

a = 3;

const function2 = function() {
  console.log(a);
}

function1();
function2();

我希望得到正确答案并且希望这个简单的概念能帮你正真理解函数闭包在JavaScript里扮演的重要角色。