你不知道的javascript系列-深入理解闭包

1、背景

对于那些有一点javascript使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作对javascript编程艺术的升华,掌握它将会揭开javascript神秘的一面,帮助你加深理解javascript。网上有很多的文章也介绍过,但看了之后,很快就忘了,始终不得其奥义。归根到底,闭包似乎有一种只可意会不可言传的味道,直到有一天我接触到了作用域的概念,我才深深地理解了它的奥妙,也就是我要传给你的秘诀:函数嵌套和垮作用域引用

2、闭包的奥义

其实,闭包无处不在。不用怀疑,其实你的代码中也存在,只是它认识你,你不认识它罢了。

下面就让我们来揭开它的神秘面纱吧。

根据《你不知道的JavaScript》一书对闭包的定义:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

好了,先让我们告别天书,用人类的语言进行交流吧,先看下面一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
// 例子A
function par() {
var a = 1;

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

chi();
}

par();

上述例子是一个非常普通的代码例子,也许你的代码中类似的例子遍地都是,那么它是闭包吗?

确切地说,它只是准闭包,并不是真正的闭包。

什么是准闭包?若将闭包比作是一个人的修炼境界,那么准闭包就是人从一个境界快要突破到另一个境界的过渡期,此刻他已经初窥另一个境界的门道但尚未真正突破。

例子中,par()函数嵌套了另一个函数chi(),chi()函数调用par()函数词法作用域内的变量a。这些规则都是闭包规则的一部分,而且是非常重要的一部分。

也就是刚开始我们介绍到的闭包两个重要组成部分:函数嵌套和垮作用域引用。但上述变量a实现了垮作用域,但a所在的作用域依旧没有实现“跨作用域引用”,因此它只能是准闭包,并不是真正的闭包。

接下来我们看另外一个例子,清晰地展示闭包到底长什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 例子B
function par() {
var a = 1;

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

return chi;
}

var out = par();

out(); // 1

上述例子就是一个经典的闭包教科书例子。

函数chi()的此法作用域能够访问par()的内部作用域,然后我们将chi()函数本身当作一个值类型进行传递给到外面。在par()函数执行后,其返回值(也就是它的内部chi()函数)赋值给out并调用out(),实际上只是通过不同的标识符引用调用了内部的函数bar().

对比上述两个例子,它们不同之处在于:第一个例子chi()在自己定义的作用域内执行,而第二个例子chi()则将它传递到外面,在它定义作用域外执行。

我们都知道,通常情况下,一个函数执行完后(后面不再被利用),js引擎为节省内存会利用垃圾回收机制,将函数内部的作用域销毁,回收函数内部的内存。

然而,闭包的神奇之处,就是阻止这件的事情发生。事实上,闭包内部作用域依旧存在,不会被销毁。

例如上述例子A,par()函数执行完后,其内部作用域将被销毁,变量a被回收;而例子B,par()函数执行完后(倒数第二行),chi函数被赋予变量out,而由于chi()函数引用了par()内部的a变量,因此其内部作用域得以存活并不会被销毁。

最终,chi()依然持有对par作用域的引用,而这个引用就叫做“闭包”。

也就是域外引用a所在的作用域,“跨作用域引用”的真正含义。

注意:这里指的是对作用域的引用,并不仅仅指的是对作用域中变量a的引用,网上有很多文章认为对某一个变量的域外引用就是闭包,这种观点不敢苟同,对作用域中变量a的跨域引用只是是闭包的一个表现而已,并不是闭包的本质和含义。闭包在“域”而不在“量”。

当然,无论使用何种形式,实现上述所谓的跨作用域引用另外一个作用域,它就是闭包。

这种形式有很多,通常都是通过嵌套函数方式,但其他的方式尚未想到,这里姑且将“嵌套函数”作为闭包先决条件之一,更深层的含义则是“垮作用域引用”

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 例子C
function par() {// par作用域
var a = 1;

function chi() {// chi作用域
console.log(a);
}

other(chi);
}

function other(tt) {// other作用域
tt(); // 1 闭包
}

上述other()函数中执行tt()函数,而tt()函数是par()函数传递它的内部函数chi()域外,而chi()持有对原始定义par作用域的引用,因此它是闭包。

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

3、闭包的应用

前面的例子只是为了向大家展示闭包的真正含义,为了解释闭包进行了人为的修饰。日常业务代码中还有很多闭包的例子,比如:

1
2
3
4
5
6
function sayHelloAfterTime(var name, var sec=1) {
setTimeout(function () {
console.log("hello, I'm " + name)
}, sec*1000)
}
sayHelloAfterTime("Jack"); // hello, I'm Jack

分析:由于setTineout将内部匿名函数传递到sayHelloAfterTime外部执行,而匿名函数引用sayHelloAfterTime作用域,因此属于闭包

1
2
3
4
var targ = document.getElementById('test');
targ.addEventListener('click', function () {
console.log('hello');
}, false);

分析:由于addEventListener将内部匿名函数传递到点击事件click的外部执行,而匿名回调函数引用click事件作用域,因此属于闭包

注意:由于闭包是作用域的引用,而this也是作用域的引用,需要注意调用闭包后,此作用域非彼作用域,需要注意匿名函数中this的调用,比如:

1
2
3
4
5
6
7
8
9
10
function sayHelloAfterTime(var name, var sec=1) {
function aa() {
console.log("aa")
}
setTimeout(function () {
console.log("hello, I'm " + name)
this.aa() // 会提示aa为undefined,此this非彼this
}, sec*1000)
}
sayHelloAfterTime("Jack"); // hello, I'm Jack

上面的类似例子我们会经常用到吧,如果你熟悉Jquery一定会知道下面这个例子:

1
2
3
(function($){
// ……
})(jQuery)

这也属于闭包。

闭包的影子其实无处不在,本质上无论何时何地,如果将函数当作第一级的值类型并向外传递(垮作用域引用产生了),你就会发现闭包的影子。

在定时器,事件监听器,Ajax请求,垮窗口通信,Web Worker或者任何异步/同步任务中,只要使用了回调函数,实际上就是在使用闭包。

下面让我们看看前端精英们使用闭包而创造了不朽的作品:

1)JQuery的应用:前端模块设计的代表之作,利用了闭包的编程方式和原理,因此减少了命名冲突,避免污染全局环境;

2)sessionStorage的应用:前端缓存设计的代表之作,javascript没有私有成员的概念,利用了闭包的编程方式和原理,既实现了缓存的效果,节省访问时间,同时将key封装成私有变量,只能通过set和get方法访问。

向伟大的工程师们致敬!

4、总结

闭包的影子其实在我们身边无处不在,你不必为了刻意引用闭包而创建闭包代码,它只是javascript编程中自然而然出现的产物,然而掌握它将对我们理解javascript编程有重要意义,就像欣赏艺术一样编写javascript程序,javascript编程因它而变得更加神奇而又玄妙。倘若将普通的代码块理解为普通人类的话,那么闭包就是人类中的艺术家。

闭包有两个重要的组成部分:嵌套函数和跨作用域引用,理解这两个含义即可窥探它的奥义。

闭包当然也有其自身的缺陷(内存没有及时释放,this的引用等),如果没有真正理解其含义而滥用闭包将造成不可想象的后果,我们不需要刻意地去引用闭包,而需要时刻保持清醒的头脑,合理地科学地使用闭包。

5、参考

1.《你不知道的JavaScript》p43-57