引言

这篇文章会以追根溯源的方式讲解释闭包的概念,弄清楚闭包因何而存在,在 JavaScript 中闭包又是如何实现的。

概念

Free variable(自由变量)

我们先来解释一下什么是 free variable:

Free variable: 一个变量,既不是函数参数,也不是函数的本地变量,则称为 free variable。

示例1:

let x = 10;
 
function foo() {
  console.log(x);
}

foo 函数来说,变量 x 就是一个 free variable。拥有了 free variable 的函数会面临一个问题——如何解析此类变量。

let x = 10;
 
function foo() {
  console.log(x);
}
 
function bar() {
  let x = 20;
  foo(); // 10, 而不是 20!
}

bar();

上述代码中,foo 函数中的 x 变量是解析到 10 还是 20?要回答这个问题,我们要弄清楚几个和函数相关的环境。

环境

一个函数的环境分为调用时环境创建时环境。调用时环境是动态的,随着程序的运行有着不确定性,创建时环境是静态的,我们可以通过观察代码就确定环境的范围。示例1中的环境可以用伪代码描述为:

globalEnvironment = {
  x: 10
};

fooCreateEnvironment = globalEnvironment;

barCreateEnvironment = globalEnvironment;

fooCallEnvironment = {
  x: 20
};

我们依赖函数的定义本身是无法确定 free variable 的值如何解析,要解决这个问题,我们可以将环境和函数关联起来,利用与函数相关联的环境中去查找解析变量。这样做会引出一个新的问题,是选择调用时环境还是创建时环境来关联函数呢?在 JavaScript 中是创建时环境,这主要是因为 JavaScript 采用了 static scope(静态范围)

Static scope: 如果可以仅凭查看源代码就可以决定解析一个变量所使用的环境,就说这个语言实现了静态范围。

Static scope 有时也被称为 lexical scope(词法范围)。

技术上来说,static scope 是通过捕捉函数创建时的环境来实现的。

闭包

当我们将函数和函数创建时的环境关联起来,就形成了 free variable 的一种解决方案。我们把此称为闭包:

函数 + 函数创建时的环境 = 闭包

通常人们对闭包的理解是不完全的,认为在 JavaScript 中只有嵌入的函数才是闭包。但其实任何拥有 free variable(自由变量)的函数都是以闭包的形式存在的。因为本质上,闭包是 free variable 问题的一种解决方案。

我们来看看 JavaScript 中典型的闭包,示例2:

function foo() {
  let x = 10;
   
  // 闭包,保存 foo 的环境
  function bar() {
    return x;
  }
 
  // 将 bar 作为返回值返回
  return bar;
}
 
let x = 20;
 
// 调用 `foo` 得到 `bar` 函数.
let bar = foo();
 
// 调用 bar 函数
bar(); // 10, not 20!

技术上来将,示例2中的 bar 函数和示例1的 foo 函数并没有区别,都是保存了函数创建时所处的环境。

值得一提的是,同一个环境可以被多个闭包共享。这使我们可以访问和修改共享范围内的数据,示例3:

function createCounter() {
  let count = 0;
 
  return {
    increment() { count++; return count; },
    decrement() { count--; return count; },
  };
}
 
let counter = createCounter();

console.log(
  counter.increment(), // 1
  counter.decrement(), // 0
  counter.increment(), // 1
);

示例3中的两个闭包(increment 和 decrement)都创建于一个包含 count 变量的代码块(范围)内,它们共享着父环境的引用。用伪代码描述为:

counterEnvironment = {
  count: 0
};

// 闭包
incrementClouse = {
  function: increment,  // 函数
  environment: counterEnvironment, // 环境
};

decrementClouse = {
  function: decrement,
  environment: counterEnvironment,
}

使用场景

闭包一个非常有用的特性就是它允许你将一些数据(环境)与一个可以操作这些数据的函数关联起来。这很容易和面向对象编程联系起来,在面向对象编程中,对象允许我们将数据(对象的属性)和方法(函数)关联起来。

函数工厂

汽车工厂生产的是汽车,函数工厂生产的是函数,在 JavaScript 中一个函数工厂就是创造函数的函数。

在一个项目中,我们经常需要使用各种函数来将一个参数和固定的数字相加,我们可以把每一个函数都声明出来:

function add3(value) {
  return 3 + value;
}

function add5(value) {
  return 3 + value;
}

function add7(value) {
  return 3 + value;
}

如果这样的函数有很多,将它们都一一定义出来是很不现实的,借助函数工厂,我们可以这样做:

function createAdder(augend) {
  return function adder(addend) {
    return augend + addend;
  };
}

const add3 = createAdder(3);
const add5 = createAdder(5);
const add7 = createAdder(7);

借助函数工厂,可以很大程度的精简我们的代码。

封装私有变量和方法

JavaScript 本身是没有原生的方式来定义私有变量和方法,但是我们可以通过闭包来模仿这种行为。私有变量和方法的好处不止在于可以限制可访问的代码,同时提供一种有效的方式来管理全局命名空间,防止非核心的变量和方法污染公共接口。

function makePeople() {
  let privateName;

  function setName(name) {
    privateName = name;
  }

  return {
    changeName: function (name) {
      setName(name);
    },
    greeting: function() {
      return 'Hi! I am ' + privateName + '.';
    },
  };   
}

const people = makePeople();

people.changeName('Tom');
console.log(people.greeting()); // Hi! I am Tom.

共享的环境被创建于 makePeople 函数体内,在此环境中我们声明了一个私有变量(privateName)和一个私有方法 (setName)。这些私有的成员无法在 makePeople 函数外被访问, 只能通过在 makePeople 函数内创建并返回的闭包来访问。

接着上面的例子:

const people1 = makePeople();
const people2 = makePeople();

people1.changeName('Tom');
people2.changeName('Bob');

console.log(people1.greeting()); // Hi! I am Tom.
console.log(people2.greeting()); // Hi! I am Bob.

这次我们通过同一个 makePeople 函数创建了两个 people, 但是 people1people2 都保存着各自独立的私有成员,互不干扰。这是因为每次 makePeople 执行时,都会创建一个新的环境,所以两次创建的 people 关联着不同的环境(people1people2 互相独立),但是同一个 people 内的闭包(changeNamegreeting)都共享同一个环境。

总结

这篇文章从一个问题出发,探索解决之道,结合 JavaScript 中的闭包实现,以理论的形式来阐述闭包的概念。希望可以帮助大家更容易的理解闭包。