相信很多初学者在学习 JavaScript 的时候,一直对闭包(closure) 有所疑惑。因为从字面上来看,完全看不出它所代表的东西。那么今天,我想通过这篇文章,尽量用简单易懂的话来与各位介绍「闭包」到底是什么。

在具体介绍闭包之前,为了更好的理解本文要介绍的内容,建议先去阅读前面的文章《JavaScript 系列之变量对象》《JavaScript 系列之作用域和作用域链》,因为它们相互之间都是有关联的。

闭包是什么?

首先,先来看看 MDN 对闭包的定义:

闭包是指那些能够访问自由变量的函数。

那什么是自由变量呢?

自由变量是一个既不是函数的形参,也不是函数的局部变量的变量。

由此,我们可以看出闭包共有两部分组成:

闭包 = 函数 + 函数能够访问的自由变量

好,如果上面三行就看得懂的话那么就不用再往下看了,Congratulations!

…… 不过如果你是初学者的话,我想应该不会,如果仅用三言两语就把闭包讲通,那还能称为 Javascript 语言的一个难点吗?

先来举个例子:

var n = 1;

function f1() {
  console.log(n); // 1
}

f1();

f1 函数可以访问变量 n,但是 n 既不是 f1 函数的形参,也不是 f1 函数的局部变量,所以这种情况下的 n 就是自由变量。其实上面代码中就存在闭包了,即函数 f1 + f1 函数访问的自由变量 n 就构成了一个闭包

上面代码中,函数 f1 可以读取全局自由变量 n。但是,函数外部无法读取函数内部声明的变量:

function f1() {
  var n = 1;
}

console.log(n); // Uncaught ReferenceError: n is not defined

如果有时需要得到函数内的局部变量。正常情况下,这是办不到的,只有通过改变形式才能实现。那就是在函数的内部,再定义一个函数。

function f1() {
  var n = 1;
  function f2() {
    console.log(n); // 1
  }
  return f2;
}

var a = f1();
a();

上面代码中,函数f2就在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。既然f2可以读取f1的局部变量,那么只要把f2作为返回值,我们就可以在f1外部读取它的内部变量了。

所以闭包是一个可以从另一个函数的作用域访问变量的函数。这是通过在函数内创建函数来实现的。当然,外部函数无法访问内部范围

在我们深入研究闭包之前,有必要先从不使用闭包的情况切入,了解为什么要用闭包。

不使用闭包的情况

在 JavaScript 中,全局变量的错用可能会使得我们的代码出现不可预期的错误。

假设我们现在要做一个计数的函数,一开始我们想要先写一个给狗的计数函数:

// 狗的计数函数
var count = 0;

function countDogs() {
  count += 1;
  console.log(count + " dog(s)");
}

countDogs(); // 1 dog(s)
countDogs(); // 2 dog(s)
countDogs(); // 3 dog(s)

接着继续写代码的其他部分,当写到后面时,我发现我也需要写猫的计数函数,于是我又开始写了猫的计数函数:

// 狗的计数函数
var count = 0;

function countDogs() {
  count += 1;
  console.log(count + " dog(s)");
}

// 中间的其它代码...

// 猫的计数函数
var count = 0;

function countCats() {
  count += 1;
  console.log(count + " cat(s)");
}

countCats(); // 1 cat(s)
countCats(); // 2 cat(s)
countCats(); // 3 cat(s)

乍看之下好像没啥问题,当我执行countDogs()countCats(),都会让count增加,然而问题在于当我在不注意的情况下把count这个变量建立在了全局作用域底下时,不论是执行countDogs()或是countCats()时,都是用到了全局的count变量,这使得当我执行下面的代码时,它没有办法分辨现在到底是在对狗计数还是对猫计数,进而导致把猫的数量和狗的数量交错计算的错误情况:

countCats(); // 1 cat(s)
countCats(); // 2 cat(s)
countCats(); // 3 cat(s)

countDogs(); // 4 dog(s),我希望是 1 dog(s)
countDogs(); // 5 dog(s),我希望是 2 dog(s)

countCats(); // 6 cat(s),我希望是 4 cat(s)

闭包让函数有私有变量

从上面的例子我们知道,如果错误的使用全局变量,很容易会出现一些莫名其妙的 bug ,这时候我们就可以利用闭包(closure)的写法,让函数有自己私有变量,简单来说就是countDogs里面能有一个计算dogscount变数;而countCats里面也能有一个计算catscount变量,两者是不会互相干扰的。

为了达到这样的效果,我们就要利用闭包,让变量保留在该函数中而不会被外在环境干扰。

改成闭包的写法会像这样:

function dogHouse() {
  var count = 0;
  function countDogs() {
    count += 1;
    console.log(count + " dogs");
  }
  return countDogs;
}

const countDogs = dogHouse();
countDogs(); // "1 dogs"
countDogs(); // "2 dogs"
countDogs(); // "3 dogs"

这样我们就将专门计算狗的变量count闭包在dogHouse这个函数中,在dogHouse这个函数中里面的countDogs()才是我们真正执行计数的函数,而在dogHouse这个函数中存在count这个变量,由于 JavaScript 变量会被缩限在函数的执行上下文中,因此这个count的值只有在dogHouse里面才能被取用,在dogHouse函数外是取用不到这个值的。

接着因为我们要能够执行在dogHouse中真正核心countDogs()这个函数,因此我们会在最后把这个函数给 return 出来,好让我们可以在外面去调用到dogHouse里面的这个countDogs()函数。

最后当我们在使用闭包时,我们先把存在dogHouse里面的countDogs拿出来用,并一样命名为countDogs(这里变量名称可以自己取),因此当我执行全局中的countDogs时,实际上会执行的是dogHouse里面的countDogs函数。

上面这是闭包的基本写法:一个函数里面包了另一个函数,同时会 return 里面的函数让我们可以在外面使用到它

我们可以把我们最一开始的代码都改成使用闭包的写法:

function dogHouse() {
  var count = 0;
  function countDogs() {
    count += 1;
    console.log(count + " dogs");
  }
  return countDogs;
}

function catHouse() {
  var count = 0;
  function countCats() {
    count += 1;
    console.log(count + " cats");
  }
  return countCats;
}

const countDogs = dogHouse();
const countCats = catHouse();

countDogs(); // "1 dogs"
countDogs(); // "2 dogs"
countDogs(); // "3 dogs"

countCats(); // "1 cats"
countCats(); // "2 cats"

countDogs(); // "4 dogs"

当我们正确地使用闭包时,虽然一样都是使用count来计数,但是是在不同执行环境内的count因此也不会相互干扰。

进一步了解和使用闭包

另外,甚至在运用的是同一个dogHouse 时,变量间也都是独立的执行环境不会干扰,比如:

function dogHouse() {
  var count = 0;
  function countDogs() {
    count += 1;
    console.log(count + " dogs");
  }
  return countDogs;
}

// 虽然都是使用 dogHouse ,但是各是不同的执行环境
// 因此彼此的变量不会互相干扰

var countGolden = dogHouse();
var countPug = dogHouse();
var countPuppy = dogHouse();

countGolden(); // 1 dogs
countGolden(); // 2 dogs

countPug(); // 1 dogs
countPuppy(); // 1 dogs

countGolden(); // 3 dogs
countPug(); // 2 dogs

将参数代入闭包中

但是这么做的话你可能觉得还不够清楚,因为都是叫做dogs,这时候我们一样可以把外面的变量通过函数的参数代入闭包中,像是下面这样,返回的结果就清楚多了:

// 通过函数的参数将值代入闭包中
function dogHouse(name) {
  var count = 0;
  function countDogs() {
    count += 1;
    console.log(count + " " + name);
  }
  return countDogs;
}

// 同样是使用 dogHouse 但是使用不同的参数
var countGolden = dogHouse("Golden");
var countPug = dogHouse("Pug");
var countPuppy = dogHouse("Puppy");

// 结果看起来更清楚了
countGolden(); // 1 Golden
countGolden(); // 2 Golden

countPug(); // 1 Pug
countPuppy(); // 1 Puppy

countGolden(); // 3 Golden
countPug(); // 2 Pug

为了进一步简化代码,我们可以在闭包中直接 return 一个函数出来,我们就可以不必为里面的函数命名了,而是用匿名函数的方式直接把它返回出来。

因此写法可以简化成这样:

function dogHouse() {
  var count = 0;
  // 把原本 countDogs 函数改成匿名函数直接放进来
  return function () {
    count += 1;
    console.log(count + " dogs");
  };
}

function catHouse() {
  var count = 0;
  // 把原本 countCats 函数改成匿名函数直接放进来
  return function () {
    count += 1;
    console.log(count + " cats");
  };
}

然后我们刚刚有提到,可以透过函数参数的方式把值代入闭包当中,因此实际上我们只需要一个 counter ,在不同的时间点给它参数区分就好。这样子不管你是要记录哪一种动物都很方便了,而且代码也相当简洁:

function createCounter(name) {
  var count = 0;
  return function () {
    count++;
    console.log(count + " " + name);
  };
}

const dogCounter = createCounter("dogs");
const catCounter = createCounter("cats");
const pigCounter = createCounter("pigs");

dogCounter(); // 1 dogs
dogCounter(); // 2 dogs
catCounter(); // 1 cats
catCounter(); // 2 cats
pigCounter(); // 1 pigs
dogCounter(); // 3 dogs
catCounter(); // 3 cats

闭包的实际应用

我们要实现这样的一个需求:点击某个按钮,提示点击的是”第 n 个”按钮,此处我们先不用事件代理:

.....
<button>测试1</button>
<button>测试2</button>
<button>测试3</button>
<script type="text/javascript">
   var buttons = document.getElementsByTagName('button')
    for (var i = 0; i < buttons.length; i++) {
      buttons[i].onclick = function () {
        console.log('第' + (i + 1) + '个')
      }
    }
</script>

这时候可能会预期点选不同的按钮时,会根据每个 button 点击顺序的不同而得到不同的结果。但是实际执行后,你会发现返回的结果都是“第四个”。这是因为i是全局变量,执行到点击事件时,此时i的值为 3。

如果要强制返回预期的结果,那该如何修改呢?最简单的是用let声明i

for (let i = 0; i < buttons.length; i++) {
  buttons[i].onclick = function () {
    console.log("第" + (i + 1) + "个");
  };
}

简单来说,通过let可以帮我们把所定义的变量缩限在块级作用域中,也就是变量的作用域只有在{ }内,来避免 i 这个变量跑到全局变量被重复覆盖。

另外我们可以通过闭包的方式来修改:

for (var i = 0; i < buttons.length; i++) {
  (function (j) {
    buttons[j].onclick = function () {
      console.log("第" + (j + 1) + "个");
    };
  })(i);
}

这其实也是「立即执行函数表达式 (Immediately Invoked Function Expression, IIFE)」的概念,后续会有专门的文章进行介绍。

希望看完这篇文章后,你能对于闭包有更清楚的认识。

如果觉得文章对你有些许帮助,欢迎在我的 GitHub 博客点赞和关注,感激不尽!


JavaScript      JavaScript 闭包

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!