持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第29天,点击查看活动详情
为什么需要闭包
首先我们来看一下为什么需要闭包。先看下嘛的例子:
function eat(){
var food = "鸡翅";
console.log(food);
}
eat(); // 鸡翅
console.log(food); // 报错
在上面的例子中,我们声明了一个名为 eat 的函数,并对它进行调用。
JavaScript 引擎会创建一个 eat 函数的执行上下文,其中声明 food 变量并赋值。
当该方法执行完后,上下文被销毁,food 变量也会跟着消失。这是因为 food 变量属于 eat 函数的局部变量,它作用于 eat 函数中,会随着 eat 的执行上下文创建而创建,销毁而销毁。所以当我们再次打印 food 变量时,就会报错,告诉我们该变量不存在。
那么,如何在函数销毁后也能继续使用变量 food 呢?
这就涉及到了要使用闭包。
什么是闭包
要解释闭包,可以从广义和狭义上去理解。
-
广义上的闭包:所有的函数就是闭包。
-
狭义上的闭包:需要满足两个条件。
- 一个函数中要嵌套一个内部函数,并且内部函数要访问外部函数的变量
- 内部函数要被外部引用
关于广义上闭包的含义,估计很多人很难理解,我就正常写个函数,怎么这玩意儿就变成闭包了?
关于这一点,我们稍后再来解释。
我们先来看一下狭义上的闭包。
function eat(){
var food = '鸡翅';
return function(){
console.log(food);
}
}
var look = eat();
look(); // 鸡翅
look(); // 鸡翅
在这个例子中,eat 函数返回一个函数,并在这个内部函数中访问 food 这个局部变量。调用 eat 函数并将结果赋给 look 变量,这个 look 指向了 eat 函数中的内部函数,然后调用它,最终输出 food 的值。
按照之前的说法,这个 food 变量应该当 eat 函数调用完后就销毁,后续为什么还能通过调用 look 方法访问到这个变量呢?
这就是因为闭包起了作用。返回的内部函数和它外部的变量 food 实际上就是一个闭包。
闭包的实质,就是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使离开了创造它的环境也不例外。
这里提到了自由变量,它又是什么呢?
自由变量可以理解成跨作用域的变量,比如子作用域访问父作用域的变量。
如下代码中,console.log(a) 要得到 a 变量,但是在当前的作用域中没有定义 a(可对比一下 b)。当前作用域没有定义的变量,这成为自由变量 。
var a = 100
function fn() {
var b = 200
console.log(a) // 这里的 a 就是一个自由变量,需要顺着作用域链来查找 a 变量的值
console.log(b)
}
fn()
闭包的原理
接下来,我们来看一下闭包的原理。
要解释闭包的原理,这里需要回答 2 个问题。
(1)为什么函数内部可以访问外部函数的变量?
原因很简单,当一个函数上下文产生的时候,会确定 3 个东西:变量对象、作用域链条以及 this 指向。
正因为有作用域链的存在,所以能够通过作用域链来访问到外部函数的变量。
(2)为什么当外部函数的上下文执行完以后,其中的局部变量还是能通过闭包访问到呢?
其实用上一个问题的答案再延伸一下,这个问题的答案就出来了。
在介绍作用域的时候,我们有介绍过作用域是在函数创建的时候就确定下来了(参阅《作用域》章节)。
所以即使外部函数的上下文结束了,但内部的函数只要不销毁(被外部引用了,就不会销毁),就会一直引用着刚才上下文的作用域链对象,那么包含在作用域链中的变量也就可以一直被访问到。
综上所述,闭包其实就是利用到了作用域链的知识。
把这个理解了,闭包的原理也就明白了。
那么为什么说每一个函数都是一个闭包呢?
因为每一个函数都能通过作用域链访问到全局上下文中的变量,例如:
var stuName = "zhangsan";
function test(){
console.log(stuName);
}
test();
在上面的代码中,我们在 test 函数中访问了自由变量 stuName,这个被引用的自由变量将和这个函数一同存在。
闭包的优缺点
闭包的优点
先来看看闭包的优点,主要有以下 2 点:
- 通过闭包可以让外部环境访问到函数内部的局部变量。
- 通过闭包可以让局部变量持续保存下来,不随着它的上下文环境一起销毁。
看下面这个例子:
var count = 0; // 全局变量
function compute() { // 将计数器加 1
count++;
console.log(count);
}
for (var i = 0; i < 100; i++) {
compute(); // 循环 100 次
}
这个例子是对一个全局变量进行加 1 的操作,一共加 100 次,得到值为 100 的结果。
但是因为使用了全局变量,所以存在全局变量污染的问题。
下面用闭包的方式重构它:
function compute() {
var count = 0; // 局部变量
return function () {
count++; // 内部函数访问外部变量
console.log(count);
}
}
var func = compute(); // 引用了内部函数,形成闭包
for (var i = 0; i < 100; i++) {
func();
}
// 在外面新创建一个 count 的变量,完全不冲突
var count = "Hello";
console.log(count);
for (var i = 0; i < 100; i++) {
func();
}
这个例子就不再使用全局变量,其中 count 这个局部变量依然可以被保存下来。我们甚至可以在外面新创建一个 count 变量,完全不会和内部的 count 变量产生冲突。
闭包的缺点
说完闭包的优点,接下来来看一下闭包的缺点。
局部变量本来应该在函数退出时被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。也就是说,闭包会将局部变量保存下来。如果大量使用闭包,而其中的变量又未得到清理,闭包的确会使一些数据无法被及时销毁,从而造成内存泄漏。
但是使用闭包的一部分原因,是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量。
把这些变量放在闭包中和放在全局作用域中,对内存方面的影响是一样的,所以这里并不能说成是内存泄漏。如果在将来需要回收这些变量,我们可以手动把这些变量设置为 null。
如果要说闭包和内存泄漏有关系的地方,那就是使用闭包的同时比较容易形成循环引用,如果闭包的作用域中保存着一些 DOM 节点,这个时候就有可能造成内存泄漏。
但这本身并非闭包的问题,也并非 JavaScript 的问题。在 IE 浏览器中,由于 BOM 和 DOM 中的对象是使用 C++ 以 COM 对象的方式实现的,而 COM 对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄漏在本质上也不是闭包造成的。
同样,如果要解决循环引用带来的内存泄漏问题,我们只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。
闭包的应用场景
最后,我们来看一下闭包的一些实际的应用场景。
var a = 100;
setTimeout(function () {
console.log(++a);
}, 1000);
上面是一段很简单的代码,但是这实际上就在你毫无察觉的情况下使用用到了闭包。
在这个例子中,用到了时间函数 setTimeout,并在等待 1 秒钟后对变量 a 进行加 1 的操作。
之所以说这是闭包,是因为 setTimeout 中的匿名函数对外部变量(自由变量)进行访问,然后该函数又被 setTimeout 方法引用。满足了形成闭包的两个条件。所以你看,即使外部上下文结束了,1 秒后仍然能对变量 a 进行加 1 操作。
在 DOM 的事件操作中,也经常用到闭包,比如下面这个例子:
<input id="count" type="button" value="计数">
(function(){
var cnt = 0; // 计数器
var count = document.getElementById("count");
count.onclick = function(){
console.log(++cnt);
}
})()
onclick 指向的函数中访问了外部变量 cnt,同时该函数又被 onclick 事件引用了,满足 2 个条件,是闭包。
所以当外部上下文结束后,你继续点击按钮,在触发的事件处理方法中仍然能访问到变量 cnt。
再比如,img 对象经常用于进行数据上报,如下所示:
const report = function (src) {
var img = new Image();
img.src = src;
}
report('http://xxx.com/getUserInfo');
但是通过查询后台的记录我们得知,因为一些低版本的浏览器的实现存在 bug,在这些浏览器下使用 report 函数进行数据上报时会丢失 30% 左右的数据,也就是说,report 函数并不是每一次都成功发起了 HTTP 请求。
丢失数据的原因是 img 是 report 函数中的局部变量,当 report 函数在调用结束后,img 局部变量随即被销毁,而此时或许还没来得及发出 HTTP 请求,所以此次请求就会丢失掉。
现在我们把 img 变量用闭包封闭起来,便能解决请求丢失的问题,如下:
const report = (function () {
var imgs = [];
return function (src) {
var img = new Image();
imgs.push(img);
img.src = src;
}
})();
在有些时候,闭包还会引起一些奇怪的问题,比如下面这个例子:
for (var i = 1; i <= 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
我们预期的结果是过 1 秒后分别输出 i 变量的值为 1,2,3。但是,执行的结果是:4,4,4。
实际上,问题就出在闭包身上。你看,循环中的 setTimeout 访问了它的外部变量 i,形成闭包。
而 i 变量只有 1 个,所以循环 3 次的 setTimeout 中都访问的是同一个变量。循环到第 4 次,i 变量增加到 4,不满足循环条件,循环结束,代码执行完后上下文结束。但是,那 3 个 setTimeout 等 1 秒钟后才执行,由于闭包的原因,所以它们仍然能访问到变量 i,不过此时 i 变量值已经是 4 了。
既然是闭包引起的问题,那么解决的方法就是去掉闭包。
我们知道形成闭包有两个条件,只要不满足其一,那就不再是闭包。
条件之一,内部函数被外部引用,这个我们没办法去掉。条件二,内部函数访问外部变量。这个条件我们有办法去掉,比如:
for (var i = 1; i <= 3; i++) {
(function (index) {
setTimeout(function () {
console.log(index);
}, 1000);
})(i)
}
这样 setTimeout 中就可以不用访问 for 循环声明的变量 i 了。而是采用调用函数传参的方式把变量 i 的值传给了 setTimeout,这样它们就不再形成闭包。也就是说 setTimeout 中访问的已经不是外部的变量 i,所以即使 i 的值增长到 4,跟它内部也没关系,最后达到了我们想要的效果。
当然,解决这个问题还有个更简单的方法,就是使用 ES6 中的 let 关键字。
它声明的变量有块作用域,如果将它放在循环中,那么每次循环都会有一个新的变量 i,这样即使有闭包也没问题,因为每个闭包保存的都是不同的 i 变量,那么刚才的问题也就迎刃而解。
for (let i = 1; i <= 3; i++) {
setTimeout(function () {
console.log(i);
}, 1000);
}
另外,使用闭包还可以模拟出面向对象中的私有方法。
过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。
对象以属性的形式包含了数据,以方法的形式包含了过程。
而闭包则是在过程中以环境的形式包含了数据。通常用面向对象思想能实现的功能,用闭包也能够实现,反之亦然。
在 JavaScript 语言的祖先 Scheme 语言中,甚至都没有提供面向对象的原生设计,但却可以使用闭包来实现一个完整的面向对象的系统。
下面我们来看看这段跟闭包相关的代码:
function Test(){
var value = 0; // 相当于是对象的属性
return {
call : function(){
value++;
console.log(value);
}
}
}
const test = new Test();
test.call(); // 1
test.call(); // 2
test.call(); // 3
如果换成面向对象的写法,那就是如下:
const test = {
value: 0,
call: function () {
this.value++;
console.log(this.value);
}
}
test.call(); // 1
test.call(); // 2
test.call(); // 3
或者
function Test() {
this.value = 0;
}
Test.prototype.call = function () {
this.value++;
console.log(this.value);
}
const test = new Test();
test.call(); // 1
test.call(); // 2
test.call(); // 3
灵魂拷问
- 闭包是什么?闭包的应用场景有哪些?怎么销毁闭包?
闭包是指有权访问另外一个函数作用域中的变量的函数。
因为闭包引用着另一个函数的变量,导致另一个函数已经不使用了也无法销毁,所以闭包使用过多,会占用较多的内存,这也是一个副作用,内存泄漏。
如果要销毁一个闭包,可以 把被引用的变量设置为 null,即手动清除变量,这样下次 JS 垃圾回收机制回收时,就会把设为 null 的量给回收了。
闭包的应用场景:
- 匿名自执行函数
- 结果缓存
- 封装
- 实现类和继承
- 解决全局污染