前言
本文专门介绍闭包,但事实上,闭包的难点并不在概念,而是在词法环境的嵌套上。只要将词法环境的嵌套关系整理清楚,闭包就瞬间被克服了。(或是去看看Python……)
总之,先不废话了,正文开始。
闭包
如果一个函数在定义的词法环境外运行并记住了定义时的词法环境,这样的现象就可以称作函数闭包(Function Closure) 举个简单例子:
function f(x=0){
var count=x;
function getCount(){
return count++;
}
return getCount
}
var func = f(10);
console.log(func()); // 10
console.log(func()); // 11
console.log(func()); // 12
console.log(func()); // 13
console.log(func()); // 14
首先要明白上面代码究竟发生了什么,梳理一下过程:
函数f
返回了函数getCount
的引用, 并将局部变量count设为了10
。- 外部词法环境中,
func
指向了getCount
然后在执行func
时就很神奇的进行了叠加。
为了直观表示,我就是用图示法表示环境绑定了(也可以用执行上下文伪代码):

注意: func
和getCount
没有在同一个词法环境。
那么执行过程就是:
func
引用自getCount
所指向的函数。- 调用
func
会进入getCount
的词法环境 - 解析标识符
count
,在当前词法环境中未找到,进入外部词法环境; - 在外部词法环境找到,返回值后叠加。 --> 保存词法环境状态
- 再次调用
func
时,会再次访问外部词法环境,访问count
,此时count
为11,然后返回。 - ……
可以注意到:形成函数的闭包的关键在于:它会保存外部词法环境的状态。 但是为什么会这样?
闭包实现 I: 执行上下文也会创建外部环境
- 函数调用时,会为该函数创建一个执行上下文。
- 执行上下文中会创建当前词法环境的环境记录,记作
CurrentEnvRec
- 除了会创建
CurrentEnvRec
,还会创建外部词法环境的环境记录, 记作OuterEnvRec
- 如果
OuterEnvRec
还有外部词法环境,那么继续创建OuterEnvRec的外部词法环境的环境记录 …… - 上面的过程一直延伸到全局词法环境截止,上面的词法环境形成了一个链表,就是众所周知的作用域链(Scope Chain)了。
在环境记录中提到过,环境记录用于记录当前词法环境标识符状态的对象;只要环境记录中的变量存在,那么就可以访问。
这个作用域链会保存在函数的[[Scope]]
内部属性中,
可以在调试中可以看到:

注意:
Chrome
中,Google浏览器专门对console.log
做了优化,因此可以通过func.prototype
看到这个属性值。
闭包实现 II: [[Scope]]不会被删除。
Javascript另外一个特别的地方是:一切皆对象。在Javascript中采用的是标记清除算法释放内存的;简单来说,如果对象可以被访问到,那么就会一直带有一个标记,当对象再也无法被访问到时,那就去除标记,在下一个遍历周期中被释放。
- 因为函数也是一个对象,并且保存了
[[Scope]]
属性的值。 - 因为
[[Scope]]
一直在引用环境记录。 - 所以
[[Scope]]
中的环境记录会一直被保存。 - 所以函数总能够访问到外部词法环境的值并且能够一直更新。
- 所以即便是执行上下文被销毁,函数的词法环境也没有消失。再次创建该函数的执行上下文时,也只是重新指向函数的词法环境而已。
闭包陷阱
一般情况下,不会用上面那种麻烦的形式,而是直接会返回一个匿名函数:
function f(x=0){
var count=x;
return function(){
return count++;
}
}
或是更加精简:
let f= (x=0)=>{
var count=x;
return ()=>count++
}
偶尔,会遇到需要多个函数闭包的情形,即:
let f = ()=>{
let func = []
for(var i = 0; i < 3; i++){
func.push(()=>{
return i*i;
})
}
return func
}
这样,f1应该输出0
, f2输出1
,f3输出4
……
本应该这样才对。
但是结果却是:
let [f1,f2,f3]=f();
console.log(f1()); // 9
console.log(f2()); // 9
console.log(f3()); // 9
这TMD是又为何?
道理很简单,因为使用了var声明的变量。var声明的变量是没有块级作用域的,上面的代码在逻辑上,等价于:
let f = ()=>{
let func = []
var i
for(i = 0; i < 3; i++){
func.push(()=>{
return i*i;
})
}
return func /// (*)
}
所以整理一下就是:
- 因为
func[0] / func[1] / func[2]
三个闭包函数的外部词法环境都是循环体的词法环境和函数f的词法环境: - 在查询标识符i的过程中, 在循环体的词法环境中未找到标识符i,所以将使用函数f的词法环境中的标识符i。
- 调用
f1 / f2 / f3
时,循环体已经结束, 所以标识符 i
最终值为3
。所以最终皆返回9
我们知道,导致最终结果都相同的原因是f1/f2/f3
在解析标识符i的过程中使用的都是函数词法环境的标识符i。
一图胜千言:

所以: 可以在函数外部创建一个立即执行函数表达式,我们可以直接使用这个函数作用域中的标识符x。
let f = ()=>{
let func = []
var i
for(i = 0; i < 3; i++){
func.push((function(){
var x=i;
return ()=>x*x;
}()))
}
return func
}
词法环境嵌套图:

或是直接用循环体的作用域:
let f = ()=>{
let func = []
var i
for(i = 0; i < 3; i++){
let x = i;
func.push(()=>{
return x*x;
})
}
return func
}
词法环境嵌套图…… (自己尝试下)
这样每次都会声明一个x并保留标识符i的值,这时正确输出:
let [f1,f2,f3]=f();
console.log(f1()); // 0
console.log(f2()); // 1
console.log(f3()); // 4
知道了前因后果,可以再对上面的代码加以简化。
let
声明的计数变量每次都会重新声明,并以上一次循环结束后的值作为初始化计数变量。(详见规范CreatePerIterationEnvironment条目)即:
let f = ()=>{
let func = []
for(let i = 0; i < 3; i++){
func.push(()=>{
return i*i;
})
}
return func
}
okay.
这时词法环境嵌套图是这样子的:

补充: setTimeout
我记得有个经典面试题,是这样的:
for(var i =0;i<6;i++)
setTimeout(()=>{console.log(i)})
// 输出 666666
我觉得这个,理解了上面的示例,这个应该不成问题; 但是要注意,里面有个坑存在,那就是这样魔改一下:
//
for(var i =0;i<6;i++)
setTimeout(console.log, 0, i)
// 0-5
它是正常输出的, 因为setTimeout
接收了当前i的值
作为参数后,会在函数内部将i
参数传递给console.log
。:
即:
setTimeout(F, 0, x)
// 在其setTimeout内部实现上,会有:
function setTimeout(F, delay, ...x){
// ....其他代码
F.apply(thisObj, x)
// ...
}
window.setTimeout=setTimeout;
备注:
setTimeout
的delay
,其实是最小延迟时间, 但是HTML标准规定,但凡是小于4ms皆以4ms计算。
最后
下一篇,开始异步。 要牢记一句话: 进阶要有深度,学习不要总被套路