讲了好长时间的闭包前置知识,今天终于说到了闭包。正如前面文章中说到的,闭包涉及到的知识点很多,如果直接阅读本文,可能你会看不太懂,因此,为了更好地理解本文,建议你去看看前面几篇文章。


什么是闭包?

废话不多说,我们先来看看MDN对闭包的定义:

简单讲,闭包就是指有权访问另一个函数作用域中的变量的函数。
MDN 上面这么说:闭包是一种特殊的对象。它由两部分构成:函数,以及创建该函数的环境。环境由闭包创建时在作用域中的任何局部变量组成。

但是,在网上找了好多资料,也翻了好几本相关书籍,它们对闭包都有各种各样的定义。但是个人最认同的是《你不知道的JavaScript》中的描述:

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

虽然其它的说法都没有错,但闭包应该是一种现象,你不用刻意去创建,因为闭包在代码中随处可见,只是你还不知道当时你写的那一段代码其实就产生了闭包。

闭包分析

上面已经说到了,当函数可以记住并访问函数所在的词法作用域,就产生了闭包。
看一段代码

1
2
3
4
5
6
7
8
9
10
var scope = 'global scope'
function checkScope() {
var scope = 'local scope'
function foo () {
return scope
}
return foo;
}
var foo = checkScope();
foo()

这段代码是不是很熟悉?没错,这就是我们上一篇文章JavaScript闭包之执行上下文最后着重分析的那段代码。但是仔细看看,这两段代码好像又有些不同,下面我们简要的分析下这段代码的执行过程。

  1. 执行全局代码,创建全局执行上下文。
1
2
3
4
5
6
7
8
9
10
11
globalContext = {
VO: {
arguments: {
length: 0
},
scope: undefined,
foo: undefined
},
Scope: globalContext.VO,
this: undefined
}
  1. 全局上下文压入执行上下文栈
1
2
3
ECStack = [
globalContext
]
  1. 全局执行上下文初始化
1
2
3
4
5
6
7
8
9
10
11
globalContext = {
VO: {
arguments: {
length: 0
},
scope: 'global scope',
checkScope: reference to function checkScope(){...}
},
Scope: globalContext.VO,
this: window
}
  1. 初始化的同时,checkScope函数被创建
1
2
3
4
5
6
7
8
9
10
11
checkScopeContext = {
AO: {
arguments:{
length: 0
},
scope: undefined,
foo: reference to function foo() {...}
},
Scope: [AO, globalContext.VO],
this: undefined
}
  1. checkScope函数上下文被压入执行上下文栈
1
2
3
4
ECStack = [
checkScopeContext,
globalContext
]
  1. checkScope执行上下文初始化
1
2
3
4
5
6
7
8
9
10
11
checkScopeContext = {
AO: {
arguments:{
length: 0
},
scope: 'local scope,
foo: reference to function foo() {...}
},
Scope: [AO, globalContext.VO],
this: window
}
  1. checkScope函数执行,返回foo函数的引用,checkScope从执行上下文栈中弹出
1
2
3
ECStack = [
globalContext
]
  1. 执行foo函数,创建foo函数执行上下文
1
2
3
4
5
6
7
8
9
fooContext = {
AO: {
arguments: {
length: 0
}
},
Scope: [AO, checkScopeContext.AO, globalContext.VO],
this: undefined
}
  1. foo函数执行上下文被压入执行栈
1
2
3
4
ECStack = [
fooContext,
globalContext
]
  1. foo函数执行上下文初始化
1
2
3
4
5
6
7
8
9
fooContext = {
AO: {
arguments: {
length: 0
}
},
Scope: [AO, checkScopeContext.AO, globalContext.VO],
this: window
}
  1. 执行foo函数,查找变量scope
1
2
3
-'scope'
--- fooContext.AO // not found
--- checkScope.AO // found -> 'local scope'
  1. foo函数执行完毕,foo函数执行上下文从执行栈中弹出
1
2
3
ECStack = [
globalContext
]

看到这儿,小伙伴们应该思考一个问题:
当 foo函数执行的时候,checkscope函数上下文已经从执行上下文栈中被弹出了,怎么还会读取到checkscope作用域下的 scope值呢?
其实原因很简单,foo函数执行上下文维护了一个作用域链

1
fooContext.Scope = [AO, checkScopeContext.AO, globalContext.VO]

所以即使checkScope函数执行完毕,但是JavaScript依然会让checkScope.AO存活在内存中,foo函数依旧可以通过作用域链找到它。
还记得我们上面对闭包的定义吗?

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

我们再来看看foo函数,它是不是就记住了checkScope的作用域,因此上面的foo函数就是一个闭包。
闭包在计算机科学中也只是一个普通的概念,大家不要去想得太复杂。

闭包经典例子

在闭包中,有一个很经典的例子:

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

在这段代码中,我们对其预期的输出是0 ~ 9,但真正的输出结果却是10次10,这是因为当setTimeout中的匿名函数执行时,循环已经结束,此时全局上下文的VO:

1
2
3
4
5
6
globalContext.VO = {
arguments: {
length: 0
},
i: 10
}

究其原因:i是声明在全局作用域中的,定时器中的匿名函数也是执行在全局作用域中,那当然是每次都输出11了。
知道了原因,解决起来就简单多了,我们可以让i在每次循环的过程中都产生一个私有作用域

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

这样当setTimeout中的匿名函数执行时,全局上下文的VO:

1
2
3
4
5
6
globalContext.VO = {
arguments: {
length: 0
},
i: 10
}

和修改之前一样,完全没变化,但是此时setTimeout中的匿名函数的作用域链:

1
Scope = [AO, 匿名函数Context.AO, globalContext.VO]

而匿名函数的AO:

1
2
3
4
5
6
7
匿名函数Context.AO = {
arguments: {
0: 0,
length: 1
},
i: 0 //每次作为参数传来的值,这里以第一次为例。
}

所以查找i的过程:

1
2
3
-'i'
--- 匿名函数Context(setTimeout内部) // not found
--- 匿名函数Context // found -> 0

闭包的应用

关于闭包的应用,小伙伴可以去看看这篇文章为了前端的深度-闭包概念与应用

闭包的缺陷

  • 闭包的缺点就是常驻内存会增大内存使用量,并且使用不当很容易造成内存泄露。
  • 如果不是因为某些特殊任务而需要闭包,在没有必要的情况下,在其它函数中创建函数是不明智的,因为闭包对脚本性能具有负面影响,包括处理速度和内存消耗。

说到了这儿,闭包基本上就全部过了一遍,在这个过程中,我翻了无数的书和博客,收获很大。这是我第一次写系列文章,感觉写的不怎么好,但这也算是一次经验吧!相信以后一定能写出好的文章。

本文完

参考文章:
闭包详解一
深入理解JavaScript系列(16):闭包(Closures)
为了前端的深度-闭包概念与应用