一、引子
我们先来看一个例子:
1 | var n = 999; |
上面代码中,函数f1可以读取全局变量n。但是函数外部无法读取函数内部声明的变量。
1 | function f1() { |
如果有时需要得到函数内的局部变量,正常情况下,这是办不到的。只有通过变通的方法才能实现,那就是在函数内部再定义一个函数。
1 | function f1() { |
上面代码中,函数f2就在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。既然f2可以读取到f1的局部变量,那么只要把f2作为f1的返回值,我们就可以在f1外部读取它的内部变量了。
二、闭包是什么?
1. 理解闭包
我们对上面代码进行修改:
1 | function f1() { |
上面代码中,函数f1的返回值就是函数f2,由于f2可以读取f1的内部变量,所以就可以在外部获得f1的内部变量了。
闭包就是函数f2,即能够读取其他函数内部变量的函数。由于在JavaScript语言中,只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成“定义在一个函数内部的函数”。闭包最大的特点,就是它可以“记住”诞生的环境,比如f2记住了它诞生的环境f1,所以从f2可以得到f1的内部变量。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。
那到底什么是闭包呢?
当函数可以记住并访问所在的词法作用域(通过let const with() try-catch创建的变量会存在词法环境中),即使函数是在当前词法作用域之外执行,这就产生了闭包。 —-《你不知道的Javascript上卷》
2. 闭包的概念
(1)MDN对闭包的定义是:闭包是指那些能够访问自由变量的函数。
那什么是自由变量呢?自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。
由此,我们可以看出闭包共有两部分组成:函数 + 函数能够访问的自由变量。
举个例子:
1 | var a = 1; |
foo函数可以访问变量a,但a既不是foo函数的局部变量,也不是foo函数的参数,所以a就是自由变量。
那么,函数foo+foo函数访问的自由变量a不就是构成了一个闭包嘛……
还真是这样的!
(2)所以在《JavaScript权威指南》中讲到:从技术的角度来讲,所有的JavaScript函数都是闭包。
哎,这怎么跟我们平时看到的讲到的闭包不一样呢?
别着急,这只是理论上的闭包,其实还有一个实践角度上的闭包:
ECMAScript中,闭包指的是:
从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
从实践角度:以下函数才算是闭包:
(1)即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
(2)在代码中引用了自由变量
平时主要研究的是实践上的闭包。
三、闭包的特性
1. 每个函数都是闭包,每个函数天生都能够记忆自己定义时所处的作用域环境。
1 | // 例题1 |
1 | // 例题2 |
2. 闭包的内存泄露
栈内存提供一个执行环境,即作用域,包括全局作用域和私有作用域,那它们什么时候释放内存呢?
全局作用域:只有当页面关闭的时候全局作用域才会销毁。
私有作用域:只有函数执行才会产生。
一般情况下,函数执行会形成一个新的私有的作用域,当私有作用域中的代码执行完成后,我们当前作用域都会主动的进行释放和销毁。但当遇到函数执行返回了一个引用数据类型的值,并且在函数的外面被一个其他的东西给接收了,这种情况下一般形成的私有作用域都不会销毁。
如下面这种情况:
1 | function fn(){ |
也就是像上面这段代码,fn函数内部的私有作用域会被一直占用的,发生了内存泄漏。所谓内存泄漏指任何对象在您不再拥有或需要它之后仍然存在。闭包不能滥用,否则会导致内存泄露,影响网页的性能。闭包使用完了后,要立即释放资源,将引用变量指向null。
四、闭包的作用
1. 可以读取函数内部的变量。
2. 可以使变量的值长期保存在内存中,生命周期比较长。因此不能滥用闭包,否则会造成网页的性能问题
3. 可以用来实现JS模块。
JS模块: 具有特定功能的js文件,将所有的数据和功能都封装在一个函数内部(私有的),只向外暴露一个包含n个方法的对象或函数,模块的使用者,只需要通过模块暴露的对象调用方法来实现对应的功能。
1 | // index.html文件 |
五、闭包的实际应用
1. IIFE(立即执行函数)
IIFE 是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。
1 | var a = 2; |
2. 函数柯里化
1 | function outer(){ |
3. 循环输出问题
最后来看一个常见的和闭包相关的循环输出问题,代码如下:
1 | for(var i = 1; i <= 5; i ++){ |
这段代码输出的结果是 5 个 6,那为什么都是 6 呢?如何才能输出 1、2、3、4、5 呢?
可以结合以下两点来思考第一个问题:
- setTimeout 为宏任务,由于 JS 中单线程 eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行。
- 因为 setTimeout 函数也是一种闭包,往上找它的父级作用域链就是 window,变量 i 为 window 上的全局变量,开始执行 setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。
那如何按顺序依次输出 1、2、3、4、5 呢?
1)利用 IIFE可以利用 IIFE(立即执行函数),当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行,改造之后的代码如下。
1 | for(var i = 1;i <= 5;i++){ |
可以看到,通过这样改造使用 IIFE(立即执行函数),可以实现序号的依次输出。利用立即执行函数的入参来缓存每一个循环中的 i 值。
2)使用 ES6 中的 letES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行。
1 | for(let i = 1; i <= 5; i++){ |
可以看到,通过 let 定义变量的方式,重新定义 i 变量,则可以用最少的改动成本,解决该问题。
3)定时器第三个参数setTimeout 作为经常使用的定时器,它是存在第三个参数的。我们经常使用前两个,一个是回调函数,另外一个是定时时间,setTimeout 从第三个入参位置开始往后,是可以传入无数个参数的。这些参数会作为回调函数的附加参数存在。那么结合第三个参数,调整完之后的代码如下:
1 | for(var i=1;i<=5;i++){ |
可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现想要的结果,这也是一种解决循环输出问题的途径。
六、总结
1、闭包概念:
通俗来讲,闭包其实就是一个可以访问其他函数内部变量的函数(所以闭包概念是建立在作用域的基础上)。即一个定义在函数内部的函数,或者说闭包是个内嵌函数。
通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中。
MDN对闭包的定义是:闭包是指那些能够访问自由变量的函数(自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。)。
2、闭包产生的原因:
当访问一个变量时,代码解释器会首先在当前的作用域查找,如果没找到,就去父级作用域去查找,直到找到该变量或者不存在父级作用域中,这样的链路就是作用域链。 所以闭包产生的本质就是:当前环境中存在指向父级作用域的引用。
那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,只需要让父级作用域的引用存在即可。
1 | // 以下虽然返回了函数,但不是闭包。因为没有对父级作用域的引用。也就是fun2()没有访问自由变量 |
3、面试题:
(1)闭包的特性:
函数内再嵌套函数;内部函数可以引用外层的参数和变量;参数和变量不会被垃圾回收机制回收;
(2)对闭包的理解
使用闭包主要是为了设计私有的方法和变量。闭包的优点是可以避免全局变量的污染,缺点是闭包会常驻内存,会增大内存使用量,使用不当很容易造成内存泄露。在js中,函数即闭包,只有函数才会产生作用域的概念。
闭包 的最大用处有两个,一个是可以读取函数内部的变量,另一个就是让这些变量始终保持在内存中。
闭包的另一个用处,是封装对象的私有属性和私有方法。
好处:能够实现封装和缓存等;
坏处:就是消耗内存、不正当使用会造成内存溢出的问题;
(3)使用闭包的注意点
由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。
解决方法是,在退出函数之前,将不使用的局部变量全部删除。