# 闭包 ## 经典真题 - 闭包是什么?闭包的应用场景有哪些?怎么销毁闭包? ## 为什么需要闭包 首先我们来看一下为什么需要闭包。先看下嘛的例子: ```js function eat(){ var food = "鸡翅"; console.log(food); } eat(); // 鸡翅 console.log(food); // 报错 ``` 在上面的例子中,我们声明了一个名为 *eat* 的函数,并对它进行调用。 *JavaScript* 引擎会创建一个 *eat* 函数的执行上下文,其中声明 *food* 变量并赋值。 当该方法执行完后,上下文被销毁,*food* 变量也会跟着消失。这是因为 *food* 变量属于 *eat* 函数的局部变量,它作用于 *eat* 函数中,会随着 *eat* 的执行上下文创建而创建,销毁而销毁。所以当我们再次打印 *food* 变量时,就会报错,告诉我们该变量不存在。 那么,如何在函数销毁后也能继续使用变量 *food* 呢? 这就涉及到了要使用闭包。 ## 什么是闭包 要解释闭包,可以从**广义**和**狭义**上去理解。 - 广义上的闭包:所有的函数就是闭包。 - 狭义上的闭包:需要满足两个条件。 - 一个函数中要嵌套一个内部函数,并且内部函数要访问外部函数的变量 - 内部函数要被外部引用 关于广义上闭包的含义,估计很多人很难理解,我就正常写个函数,怎么这玩意儿就变成闭包了? 关于这一点,我们稍后再来解释。 我们先来看一下狭义上的闭包。 ```js 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*)。当前作用域没有定义的变量,这成为自由变量 。 ```js var a = 100 function fn() { var b = 200 console.log(a) // 这里的 a 就是一个自由变量,需要顺着作用域链来查找 a 变量的值 console.log(b) } fn() ``` ## 闭包的原理 接下来,我们来看一下闭包的原理。 要解释闭包的原理,这里需要回答 *2* 个问题。 **(1)为什么函数内部可以访问外部函数的变量?** 原因很简单,当一个函数上下文产生的时候,会确定 *3* 个东西:变量对象、作用域链条以及 *this* 指向。 正因为有作用域链的存在,所以能够通过作用域链来访问到外部函数的变量。 **(2)为什么当外部函数的上下文执行完以后,其中的局部变量还是能通过闭包访问到呢?** 其实用上一个问题的答案再延伸一下,这个问题的答案就出来了。 在介绍作用域的时候,我们有介绍过作用域是在函数创建的时候就确定下来了(参阅《作用域》章节)。 所以即使外部函数的上下文结束了,但内部的函数只要不销毁(被外部引用了,就不会销毁),就会一直引用着刚才上下文的作用域链对象,那么包含在作用域链中的变量也就可以一直被访问到。 综上所述,闭包其实就是利用到了作用域链的知识。 把这个理解了,闭包的原理也就明白了。 那么为什么说每一个函数都是一个闭包呢? 因为每一个函数都能通过作用域链访问到全局上下文中的变量,例如: ```js var stuName = "zhangsan"; function test(){ console.log(stuName); } test(); ``` 在上面的代码中,我们在 *test* 函数中访问了自由变量 *stuName*,这个被引用的自由变量将和这个函数一同存在。 ## 闭包的优缺点 **闭包的优点** 先来看看闭包的优点,主要有以下 *2* 点: - 通过闭包可以让外部环境访问到函数内部的局部变量。 - 通过闭包可以让局部变量持续保存下来,不随着它的上下文环境一起销毁。 看下面这个例子: ```javascript var count = 0; // 全局变量 function compute() { // 将计数器加 1 count++; console.log(count); } for (var i = 0; i < 100; i++) { compute(); // 循环 100 次 } ``` 这个例子是对一个全局变量进行加 *1* 的操作,一共加 *100* 次,得到值为 *100* 的结果。 但是因为使用了全局变量,所以存在全局变量污染的问题。 下面用闭包的方式重构它: ```javascript 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* 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。 ## 闭包的应用场景 最后,我们来看一下闭包的一些实际的应用场景。 ```js var a = 100; setTimeout(function () { console.log(++a); }, 1000); ``` 上面是一段很简单的代码,但是这实际上就在你毫无察觉的情况下使用用到了闭包。 在这个例子中,用到了时间函数 *setTimeout*,并在等待 *1* 秒钟后对变量 *a* 进行加 *1* 的操作。 之所以说这是闭包,是因为 *setTimeout* 中的匿名函数对外部变量(自由变量)进行访问,然后该函数又被 *setTimeout* 方法引用。满足了形成闭包的两个条件。所以你看,即使外部上下文结束了,*1* 秒后仍然能对变量 *a* 进行加 *1* 操作。 在 *DOM* 的事件操作中,也经常用到闭包,比如下面这个例子: ```html ``` ```js (function(){ var cnt = 0; // 计数器 var count = document.getElementById("count"); count.onclick = function(){ console.log(++cnt); } })() ``` *onclick* 指向的函数中访问了外部变量 *cnt*,同时该函数又被 *onclick* 事件引用了,满足 *2* 个条件,是闭包。 所以当外部上下文结束后,你继续点击按钮,在触发的事件处理方法中仍然能访问到变量 cnt。 再比如,*img* 对象经常用于进行数据上报,如下所示: ```js 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* 变量用闭包封闭起来,便能解决请求丢失的问题,如下: ```js const report = (function () { var imgs = []; return function (src) { var img = new Image(); imgs.push(img); img.src = src; } })(); ``` 在有些时候,闭包还会引起一些奇怪的问题,比如下面这个例子: ```js 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* 了。 既然是闭包引起的问题,那么解决的方法就是去掉闭包。 我们知道形成闭包有两个条件,只要不满足其一,那就不再是闭包。 条件之一,内部函数被外部引用,这个我们没办法去掉。条件二,内部函数访问外部变量。这个条件我们有办法去掉,比如: ```js 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* 变量,那么刚才的问题也就迎刃而解。 ```js for (let i = 1; i <= 3; i++) { setTimeout(function () { console.log(i); }, 1000); } ``` 另外,使用闭包还可以模拟出面向对象中的私有方法。 过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。 对象以属性的形式包含了数据,以方法的形式包含了过程。 而闭包则是在过程中以环境的形式包含了数据。通常用面向对象思想能实现的功能,用闭包也能够实现,反之亦然。 在 *JavaScript* 语言的祖先 *Scheme* 语言中,甚至都没有提供面向对象的原生设计,但却可以使用闭包来实现一个完整的面向对象的系统。 下面我们来看看这段跟闭包相关的代码: ```js 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 ``` 如果换成面向对象的写法,那就是如下: ```js const test = { value: 0, call: function () { this.value++; console.log(this.value); } } test.call(); // 1 test.call(); // 2 test.call(); // 3 ``` 或者 ```js 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* 的量给回收了。 > > 闭包的应用场景: > > 1. 匿名自执行函数 > 2. 结果缓存 > 3. 封装 > 4. 实现类和继承 > 5. 解决全局污染 -*EOF*-