闭包到底是什么?

这篇文章主要讲讲什么是闭包。

写在前面的碎碎念

笔者最近在阅读书籍《你不知道的JavaScript(上)》,该书的第一部分即是作用域与闭包,而正是这一章节,让我坐下来好好重新了解一下“闭包”这个词语的含义。

有幸的是,笔者在经过计算机组成、体系结构和操作系统等底层原理课程的学习之后,对于作用域等概念有了比较深刻的理解。得益于这些基础知识,让我对于曾经比较畏惧和排斥的概念重新拾起了兴趣。

所以学校教的东西虽然看上去没有立竿见影的效果,不像前端开发那样,今天学习了React,明天就可以搭建一个网站。但是其实它们对于我们有着潜移默化的影响,这个影响很深远。

1、什么是闭包

闭包它其实并没有特别神秘,相反,它时常出现在我们写下的代码之中。只要我们对于作用域的理解足够深刻,那么闭包的概念就显得十分自然。事实上闭包是函数作用域下的必然产物。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

这句话也可以这样理解,函数与其所在的词法作用域共同构成了闭包。

“记住”这个词的含义需要仔细揣摩。所谓“记住”就是:某个函数的作用域其外层是不能访问的,但是通过闭包,我们可以在其外部对其进行访问,尽管看上去这个函数的空间已经被销毁了。

并且闭包的使用场景就是去“记住”某个作用域。

2、从函数作用域谈起

借用《你不知道的JavaScript》作者举的例子

1
2
3
4
5
6
7
8
function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();

我们关注一下bar()这个函数,根据函数作用域的规则,函数bar()定义于函数foo()之中,那么其内对于变量 a 的引用(这里是RHS引用查询),就会自然而然地去访问其外层作用域即foo()函数的作用域。因为函数 bar() 中没有变量a,而函数作用域是允许嵌套的,并且在进行RHS引用访问时,会进行递归查找。

上面的描述是自然而然的,任何一个了解JavaScript语法的人都能够理解,并且认为我在说废话。事实上也确实是这样。

那么这个是闭包吗?

或许是,但不完全是。

上面的这个东西虽然不完全是闭包, 但是它是闭包规则的一部分,也是最重要的一部分。希望读者能够注意这句话,并且再去想一下上面那段看上去是废话的解释。

3、一个完整的真正的闭包

再此借用作者的例子,当然这个例子也是几乎所有认识闭包的程序员见过的第一个闭包的例子。事实上,它只是对上面的代码进行了一个很简单的改动。

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2

我们来解释一下这段代码。函数bar() 的词法作用域能够访问foo() 的内部作用域。然后我们将bar() 函数本身当作一个值类型当作返回值进行传递。在foo()执行之后,其内部的函数bar()赋值给了baz。那么bar()这个函数(现在这个函数被baz引用)很明显能够正常执行,并且输出了a的值2。函数bar()在自己定义的作用域(函数foo())外部被调用并且执行,同时它还能访问a这个本不能在全局作用域被访问到的变量

foo() 执行后,我们通常会期待foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

但是这件事情被bar()给阻止了,因为它拥有了涵盖foo()内部作用域的闭包,使得该作用域仍然存在,以供bar在之后的任何时间进行引用。

bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

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

到这里,闭包的概念已经很明了了。正如文章开头所说的,闭包没有那么神奇,它就是一件很自然的事情。

4、闭包的作用

我们学会一项技术就要学会怎么去使用它。

4.1 回调函数与IIFE(立即执行函数表达式)

1
2
3
4
5
6
7
8
9

function setupBot(name, selector) {
$( selector ).click( function activator() {
console.log( "Activating: " + name );
} );
}
setupBot( "Closure Bot 1", "#bot_1" );
setupBot( "Closure Bot 2", "#bot_2" );

上面这个jQuery框架的例子中,我们为click事件绑定了一个回调函数。了解过一些事件循环概念的人应该会知道这个回调函数的执行是由系统调用的。那么上面代码中,这个回调函数在未来执行的时候仍然能够使用name这个变量的值,这就是闭包。因为它“记住”了其所在的作用域,而name作为函数参数也包含在函数作用域之中。

再来一个例子

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

这是个老生常谈的例子,相信每一个学习JavaScript的人都在很多博客、很多场景中看到过这个例子。如果你还是天真的认为它的输入是 1-5,那么我建议你重新去学习一下作用域等等很多的基础知识。

没错,这段代码的输出结果是5个6,具体原因我就不解释了,我们来看看我们如何通过闭包来将其更改成我们想要的结果。

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

通过这种方式,我们在匿名函数中新建了一个作用域,在这个作用域中用j来保存了i这个变量的值。这个时候函数timer就“记住”了j的值,这同样是闭包。

这也揭示了IIFE的作用: 创建一个作用域来保存我们想要的值.

es6以后,我们有了块作用域,上面的问题可以很容易的得到解决了。

4.2 模块化

学习过C++或者Java的同学一定知道类可以有私有成员。而私有成员的主要作用就是防止外部代码不小心对其进行更改。举一个C++的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person(){
private:
string name = "Tom";
public:
void setName(string name){
this.name = name;
}
string getName(){
return this.name;
}
}
Person person = new Person();
person.setName("Ydream");
std::cout<<person.getName();

这个简单的C++代码就是面向对象的“封装”理念的体现。

有了闭包,我们JavaScript也可以进行封装,我们用js写一下上面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person() {
var name = 'Tom';
function setName(str) {
name = str;
}
function getName() {
return name;
}
return {setName,getName}
}
var person = Person();
person.setName('Ydream');
console.log(person.getName());//Ydream

5、总结

一个函数和其对于声明它的作用域的引用就是闭包。

闭包产生的原因就是函数作用域的规则,根据函数作用域的规则,闭包的产生显得自然而然。

闭包无处不在,我们平时也时常在使用闭包。