一、引子

我们先来看一个例子:

1
2
3
4
5
var n = 999;
function f1() {
console.log(n);
}
f1() // 999

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

1
2
3
4
5
function f1() {
var n = 999;
}
console.log(n)
// Uncaught ReferenceError: n is not defined

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

1
2
3
4
5
6
function f1() {
var n = 999;
function f2() {
console.log(n); // 999
}
}

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

二、闭包是什么?

1. 理解闭包

我们对上面代码进行修改:

1
2
3
4
5
6
7
8
9
10
11
function f1() {
var a = 999;
function f2() {
console.log(a);
}
return f2; // f1返回了f2的引用
}

var result = f1(); // result就是f2函数了
result();
// 执行result,全局作用域下没有a的定义,但是函数闭包,能够把定义函数的时候的作用域一起记住,输出999

上面代码中,函数f1的返回值就是函数f2,由于f2可以读取f1的内部变量,所以就可以在外部获得f1的内部变量了。

闭包就是函数f2,即能够读取其他函数内部变量的函数。由于在JavaScript语言中,只有函数内部的子函数才能读取内部变量,因此可以把闭包简单理解成“定义在一个函数内部的函数”。闭包最大的特点,就是它可以“记住”诞生的环境,比如f2记住了它诞生的环境f1,所以从f2可以得到f1的内部变量。在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

那到底什么是闭包呢?

当函数可以记住并访问所在的词法作用域(通过let const with() try-catch创建的变量会存在词法环境中),即使函数是在当前词法作用域之外执行,这就产生了闭包。 —-《你不知道的Javascript上卷》

2. 闭包的概念

(1)MDN对闭包的定义是:闭包是指那些能够访问自由变量的函数。

那什么是自由变量呢?自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

由此,我们可以看出闭包共有两部分组成:函数 + 函数能够访问的自由变量。

举个例子:

1
2
3
4
5
6
7
var a = 1;

function foo() {
console.log(a);
}

foo();

foo函数可以访问变量a,但a既不是foo函数的局部变量,也不是foo函数的参数,所以a就是自由变量。

那么,函数foo+foo函数访问的自由变量a不就是构成了一个闭包嘛……

还真是这样的!

(2)所以在《JavaScript权威指南》中讲到:从技术的角度来讲,所有的JavaScript函数都是闭包。

哎,这怎么跟我们平时看到的讲到的闭包不一样呢?

别着急,这只是理论上的闭包,其实还有一个实践角度上的闭包:

ECMAScript中,闭包指的是:

从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。

从实践角度:以下函数才算是闭包:

(1)即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)

(2)在代码中引用了自由变量

平时主要研究的是实践上的闭包。

三、闭包的特性

1. 每个函数都是闭包,每个函数天生都能够记忆自己定义时所处的作用域环境。

1
2
3
4
5
6
7
8
9
10
11
12
// 例题1
var inner;
function outer(){
var a = 250;
inner = function(){
alert(a); // 这个函数虽然在外面执行,但能够记忆住定义时的那个作用域,a是250
}
}

outer();
var a = 300;
inner(); // 一个函数在执行的时候,找闭包里面的变量,不会理会当前作用域。
1
2
3
4
5
6
7
8
9
10
// 例题2
function outer(x){
function inner(y){
console.log(x+y);
}
return inner;
}

var inn = outer(3); // 数字3传入outer函数后,inner函数中x便会记住这个值
inn(5); // 当inner函数再传入5的时候,只会对y赋值,所以最后弹出8

2. 闭包的内存泄露

栈内存提供一个执行环境,即作用域,包括全局作用域和私有作用域,那它们什么时候释放内存呢?

全局作用域:只有当页面关闭的时候全局作用域才会销毁。

私有作用域:只有函数执行才会产生。

一般情况下,函数执行会形成一个新的私有的作用域,当私有作用域中的代码执行完成后,我们当前作用域都会主动的进行释放和销毁。但当遇到函数执行返回了一个引用数据类型的值,并且在函数的外面被一个其他的东西给接收了,这种情况下一般形成的私有作用域都不会销毁。

如下面这种情况:

1
2
3
4
5
6
function fn(){
var num=100;
return function(){}
}

var f=fn(); // fn执行形成的这个私有的作用域就不能再销毁了

也就是像上面这段代码,fn函数内部的私有作用域会被一直占用的,发生了内存泄漏。所谓内存泄漏指任何对象在您不再拥有或需要它之后仍然存在。闭包不能滥用,否则会导致内存泄露,影响网页的性能。闭包使用完了后,要立即释放资源,将引用变量指向null。

四、闭包的作用

1. 可以读取函数内部的变量。

2. 可以使变量的值长期保存在内存中,生命周期比较长。因此不能滥用闭包,否则会造成网页的性能问题

3. 可以用来实现JS模块。

JS模块: 具有特定功能的js文件,将所有的数据和功能都封装在一个函数内部(私有的),只向外暴露一个包含n个方法的对象或函数,模块的使用者,只需要通过模块暴露的对象调用方法来实现对应的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// index.html文件
<script type="text/javascript" src="myModule.js"></script>
<script type="text/javascript">
myModule2.doSomething()
myModule2.doOtherthing()
</script>

// myModule.js文件
(function () {
var msg = 'Beijing'//私有数据
//操作数据的函数
function doSomething() {
console.log('doSomething() '+msg.toUpperCase())
}
function doOtherthing () {
console.log('doOtherthing() '+msg.toLowerCase())
}
//向外暴露对象(给外部使用的两个方法)
window.myModule2 = {
doSomething: doSomething,
doOtherthing: doOtherthing
}
})()

五、闭包的实际应用

1. IIFE(立即执行函数)

IIFE 是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。

1
2
3
4
var a = 2;
(function IIFE(){
console.log(a); // 输出2
})();

2. 函数柯里化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function outer(){
var num = 0;
return function add(){
num++;
console.log(num);
};
}

var func1=outer();
func1(); // 实际上是调用add函数, 输出1
func1(); // 输出2 因为outer函数内部的私有作用域会一直被占用

var func2 = outer();
func2(); // 输出1 每次重新引用函数的时候,闭包是全新的。
func2(); // 输出2

3. 循环输出问题

最后来看一个常见的和闭包相关的循环输出问题,代码如下:

1
2
3
4
5
for(var i = 1; i <= 5; i ++){
setTimeout(function() {
console.log(i)
}, 0)
}

这段代码输出的结果是 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
2
3
4
5
6
7
for(var i = 1;i <= 5;i++){
(function(j){
setTimeout(function timer(){
console.log(j)
}, 0)
})(i)
}

可以看到,通过这样改造使用 IIFE(立即执行函数),可以实现序号的依次输出。利用立即执行函数的入参来缓存每一个循环中的 i 值。

2)使用 ES6 中的 letES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行。

1
2
3
4
5
for(let i = 1; i <= 5; i++){
setTimeout(function() {
console.log(i);
},0)
}

可以看到,通过 let 定义变量的方式,重新定义 i 变量,则可以用最少的改动成本,解决该问题。

3)定时器第三个参数setTimeout 作为经常使用的定时器,它是存在第三个参数的。我们经常使用前两个,一个是回调函数,另外一个是定时时间,setTimeout 从第三个入参位置开始往后,是可以传入无数个参数的。这些参数会作为回调函数的附加参数存在。那么结合第三个参数,调整完之后的代码如下:

1
2
3
4
5
for(var i=1;i<=5;i++){
setTimeout(function(j) {
console.log(j)
}, 0, i)
}

可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现想要的结果,这也是一种解决循环输出问题的途径。

六、总结

1、闭包概念:

通俗来讲,闭包其实就是一个可以访问其他函数内部变量的函数(所以闭包概念是建立在作用域的基础上)。即一个定义在函数内部的函数,或者说闭包是个内嵌函数。

通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中。

MDN对闭包的定义是:闭包是指那些能够访问自由变量的函数(自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。)。

2、闭包产生的原因:

当访问一个变量时,代码解释器会首先在当前的作用域查找,如果没找到,就去父级作用域去查找,直到找到该变量或者不存在父级作用域中,这样的链路就是作用域链。 所以闭包产生的本质就是:当前环境中存在指向父级作用域的引用。

那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,只需要让父级作用域的引用存在即可。

1
2
3
4
5
6
7
8
// 以下虽然返回了函数,但不是闭包。因为没有对父级作用域的引用。也就是fun2()没有访问自由变量
function fun1() {
var a = 2
function fun2() {}
return fun2;
}
var result = fun1();
result();

3、面试题:

(1)闭包的特性:

函数内再嵌套函数;内部函数可以引用外层的参数和变量;参数和变量不会被垃圾回收机制回收;

(2)对闭包的理解

使用闭包主要是为了设计私有的方法和变量。闭包的优点是可以避免全局变量的污染,缺点是闭包会常驻内存,会增大内存使用量,使用不当很容易造成内存泄露。在js中,函数即闭包,只有函数才会产生作用域的概念。

闭包 的最大用处有两个,一个是可以读取函数内部的变量,另一个就是让这些变量始终保持在内存中。

闭包的另一个用处,是封装对象的私有属性和私有方法。

好处:能够实现封装和缓存等;

坏处:就是消耗内存、不正当使用会造成内存溢出的问题;

(3)使用闭包的注意点

由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。

解决方法是,在退出函数之前,将不使用的局部变量全部删除。