闭包该如何理解?

前言

说到闭包,实在是居家旅行破境渡劫摄魄迷魂必备良药!不吃不知道,一吃哇哇叫,下面我们也去搞两盒试试。

一、闭包是什么

闭包,一个近乎神话的概念,从字面上理解感觉就像是一个比较封闭的东西,百度百科上的定义是:闭包就是能够读取其他函数内部变量的函数。

而我个人比较倾向于这么理解:闭包就是一个封闭包裹了它所能使用的作用域的函数。

这样看起来好像有点那个意思了,通俗的说就是:函数这个袋子把一些作用域装起来了,哪些作用域呢?这个函数作用域链上的作用域。

光说不写假帅气,下面来些例子瞧瞧:

1.1 函数传递

1
2
3
4
5
6
7
8
9
10
11
// 1.函数作为返回值
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}

var f = foo();
f(); // 2 这就是闭包的效果,或者说f即bar函数就是一个闭包,它把a所在的作用域包了起来,以便自己随时使用

上面的例子是将函数作为值返回,下面我们换个方式试试(其实无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 2.函数作为参数传递
function foo() {
var a = 2;
function bar() {
console.log( a );
}
f(bar);
}

function f(fn) {
fn(); // 函数作为参数传递,也包裹了a的作用域,这也是闭包
}

foo(); // 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 3.间接传递函数
var fn;
function foo() {
var a = 2;
function bar() {
console.log( a );
}
fn = bar; // 将bar分配给全局变量fn
}

function f() {
fn(); // fn指向bar,bar包裹着a的作用域,这也是闭包
}

foo();
f(); // 2
1
2
3
4
5
6
7
8
9
// 4.回调函数,传递给JS引擎调用
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}

wait( "Hello" ); // 'Hello'
// 将一个内部函数timer传递给setTimeout,timer具有涵盖wait作用域的闭包,因此还有对变量message的引用

其实,在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。

所以无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用(包裹),无论在何处执行这个函数都会使用闭包。

tip: 词法作用域指由书写代码时变量所在的位置所决定的作用域。

1.2 IIFE

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

以上这个立即执行函数是闭包吗?嗯,看起来应该是。

但严格来讲它并不是闭包。为什么?因为上面的函数并不是在它本身的词法作用域以外执行的,它在定义时所在的作用域中执行,a是通过普通的词法作用域查找而非闭包被发现的。

尽管IIFE本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被封闭起来的闭包的工具,后面我们会讲到。

1.3 循环与闭包

说到这个循环闭包的例子,可谓是如影随形,惺惺相惜,让猿欲罢不能。

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

这个想必大家伙就算没吃过也见过这个猪是怎么跑的:以每秒一次的频率输出五次6,而不是每秒一次一个的分别输出1~5。

首先解释6是从哪里来的:这个循环的终止条件是i不再<=5,条件首次成立时i的值是6。因此,输出显示的是循环结束时i的最终值。

仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。但事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个6出来。

究竟是什么原因导致这结果和我们预想的不一样呢?

原因是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i,所以都是在共享同一个i。

如何解决这个问题?

我们设想一下如果每次循环函数都能将属于自己的i包裹起来,然后保存下来,那就需要闭包作用域,下面我们试试:

1
2
3
4
5
6
7
for (var i=1; i<=5; i++) { 
(function() {
setTimeout(function timer() {
console.log( i );
}, i*1000);
})();
}

这样行吗?答案是不行。为什么?上面的确创建了五个封闭的作用域,但大家有没有注意到,但这个作用域是空的,它们并没有将i包裹并存储起来,我们依旧是引用外部的同一个全局i,所以这个封闭的作用域需要有自己的变量,用来在每个迭代中储存i的值:

1
2
3
4
5
6
7
8
for (var i=1; i<=5; i++) { 
(function() {
var j = i; // 将i的值存储在闭包内
setTimeout(function timer() {
console.log(j);
}, j*1000);
})();
}

搞定!将timer传递给setTimeout,时间到后,JS引擎会调用timer函数,然后找到对应包裹起来的i,我们还可以再改进一下:

1
2
3
4
5
6
7
for (var i=1; i<=5; i++) { 
(function(j) { // j参数也是属于函数隐式声明的变量
setTimeout(function timer() {
console.log(j);
}, j*1000);
})( i );
}

等等,解决这个问题的方法是每次迭代我们都需要一个块作用域,那么用let来生成块作用域不就搞定了吗?

1
2
3
4
5
for(let i=1; i<=5; i++) {  // 使用let声明i
setTimeout(function timer() {
console.log(i);
}, i*1000);
}

但let的作用不仅仅是生成块作用域,for循环头部的let声明还会有一个特殊的行为:变量i在循环过程中不止被声明一次,每次迭代都会声明,随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

这种每次迭代重新声明绑定的行为就类似这样:

1
2
3
4
5
6
for (var i=1; i<=5; i++) { 
let j = i; //每个迭代重新声明j并将i的值绑定在这个块作用域内
setTimeout( function timer() {
console.log(j);
}, j*1000);
}

这样一路看下来,感觉闭包好像也不是那么神秘嘛,我个人理解的话会把以上归纳为:只要发生了函数传递与调用,就会产生闭包。好了,了解了闭包是什么,那下面来看看它有什么用途。

二、闭包的应用

2.1 模块

闭包最大的作用莫过于创建模块了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function betterModule() {
var name = 'BetterMan';
var arr = [1, 2, 3];
function getName() {
console.log(name);
}
function joinArr() {
console.log(arr.join('-'));
}
return {
getName: getName,
joinArr: joinArr
}
}

var foo = betterModule();
foo.getName(); // 'BetterMan'
foo.joinArr(); // '1-2-3'

以上就是一个利用闭包来创建的模块,我们来理一理这段代码:

首先,betterModule()只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行外部函数,内部作用域和闭包都无法被创建。

其次,betterModule()返回一个用对象字面量语法{key: value, …}来表示的对象,这个返回的对象中含有对内部函数而不是内部数据变量的引用,保持了内部数据变量是隐藏且私有的状态,可以将这个对象类型的返回值看作本质上是模块的公共API。

这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API中的属性,如foo.joinArr()

tip: 从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。jQuery就是如此,jQuery$标识符就是jQuery模块的公共API,但它们本身都是函数(由于函数也是对象,它们本身也可以拥有属性)。

以上的betterModule函数可以被调用任意多次,每次调用都会创建一个新的模块实例;但如果我们只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var foo = (function betterModule() {
var name = 'BetterMan';
var arr = [1, 2, 3];
function getName() {
console.log(name);
}
function joinArr() {
console.log(arr.join('-'));
}
return {
getName: getName,
joinArr: joinArr
}
})();

我们将模块函数转换成了IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例foo。

2.2 柯里化

柯里化也用到了闭包,听起来有点高大上,那什么是柯里化呢?

柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术,看起来是不是有点绕,下面看看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function add(a, b, c) {
return a + b + c;
}
console.log(add(1,2,3)); // 6

function newAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
}
}
}
console.log(newAdd(1)(2)(3)); // 6

看着例子对照着定义,看起来描述得还是挺贴切的嘛,其实上面也是利用了闭包的功能绑定了参数的作用域,使得每次调用函数时可以访问上次所传入的参数。

三、闭包的注意事项

通常,函数的作用域及其所有变量都会在函数执行结束后被销毁。但是,在创建了一个闭包以后,这个函数的作用域就会一直保存到闭包不存在为止,因为闭包就是一个函数引用另外一个函数的变量,因为变量被引用着所以不会被回收。这是优点也是缺点,不必要的闭包只会徒增内存消耗,所以我们在使用的时候需要注意这方面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function add(x) {
return function(y) {
return x + y;
};
}

var add3 = add(3);
var add5 = add(5);

console.log(add3(2)); // 5
console.log(add5(5)); // 10

// 需要手动释放对闭包的引用
add3 = null;
add5 = null;

以上的add3add5都是闭包,它们共享相同的函数定义,但是保存了不同的环境。在add3的环境中,x为3。而在add5中,x则为5,最后我们通过null手动释放了add3add5对闭包的引用。

最后

如果到了这里你恍然大悟:原来在我的代码中已经到处都是闭包了,只是平时没注意到而已!那说明我这药方还是有点效果的,如果真的如此,那就来波点赞关注吧,因为你的支持就是我最大的动力!

GitHub传送门
博客园传送门