前言
在了解闭包之前,我们要清楚一点。我们了解闭包,不是为了去有意的创建闭包,实际上我们在写代码的过程中,就会无意的创建很多闭包,我们要做的只是了解和熟悉,在写代码的时候知道写出来的是闭包,然后在出现一些奇怪的bug的时候能正确找到它们。
举个栗子
在开始前,先看一个小栗子
问题:每个一秒分别打印 0、1、2
解法1:
(function foo(){
for (var i = 0; i< 3; i++) {
setTimeout(function fn1 (){
console.log(i)
}, 1000 * i);
}
})()
预期结果: 0、1、2
实际结果: 3、3、3
是不是很奇怪,前面这段代码看起来是没什么问题啊,输出结果怎么会不对?
这个疑问先放一放,我们先来看一下今天的主角,“闭包”同学
闭包
直接上代码:
function fn1() {
var a = 2;
function fn2() {
console.log(a);
}
return fn2;
}
var fn3 = fn1();
fn3();
上面这段代码中做了三件事情:
1⃣️ 函数 fn1 执行
2⃣️ fn1 的返回值(执行结果)被赋值给fn3
3⃣️ fn3(也就是fn2) 执行,打印变量 a
fn1执行前,引擎先为fn1创建了一个活动对象,然后塞进内存中。我们知道,js引擎有垃圾回收机制,会释放不再使用的内存空间,等fn1执行完之后,按理说前面创建的活动对象已经没用了,这个时候引擎会将该活动对象回收。
但是这里并不会,因为fn2内部引用的变量a是存活在fn1活动对象中的,也就是说fn2引用了fn1活动对象中的a,这也就使得fn1活动对象不会被销毁,仍然存活在内存中。( 可以理解为:引擎准备清理fn1活动对象的时候,发现还被别的对象引用着,说明它还有用,就放弃回收它了 )
因为fn1活动对象不会被销毁,等到fn3执行的时候,需要获取a的值并打印,就能正常获取和打印了。
总的来说就是,fn2持有了对fn1的引用,导致fn1执行完之后活动对象没有被销毁,这个现象就叫做闭包。
问题拆解
了解完闭包,现在我们来分析一下文章开头那段输出结果不对的代码:
// 原代码:
(function foo(){
for (var i = 0; i< 3; i++) {
setTimeout(function fn1 (){
console.log(i)
}, 1000 * i);
}
})()
对上面的代码做个拆解:
// 拆解后:
(function foo(){
var i
i = 0
// 第一次循环 此时 i === 0
if(i < 3){
// setTimeout 执行,定时器开启
// 但是由于fn1是异步代码,所以fn1会等到所有同步代码执行完成后再执行
setTimeout(function fn1 (){
console.log(i)
}, 0); // 1000 * 0
}
// i 自增
i++
// 第二次循环 此时 i === 1
if(i < 3){
setTimeout(function fn1 (){
console.log(i)
}, 1000); // 1000 * 1
}
i++
// 第三次循环 此时 i === 2
if(i < 3){
setTimeout(function fn1 (){
console.log(i)
}, 2000); // 1000 * 2
}
i++
// 这里 i === 3 ,不满足判断条件 i < 3 ,才会跳出循环
})()
现在我们再去看,for循环创建了三个定时器,每个定时器分别有一个回调函数fn1。
仔细看这里的每个fn1函数中输出的变量i都是引用的外层函数foo的,根据我们讨论闭包得出的结论,由于函数fn1引用了外层函数foo的变量i,所以fn1持有了对外层函数foo的引用,导致了foo函数的活动对象不会被销毁。
所以这段代码中会产生3个闭包,关系如下图:
三个fn1函数虽然分别产生了三个闭包,但是引用的是同一个外层函数foo的值,所以我们可以理解为三个闭包都是共享的。三个函数使用的是同一个父级作用域下的变量 i ,所以异步函数fn1 执行时,获取到的是同一个i值(i === 3)
这个栗子拆解是为了讲闭包,同时为了方便后面其他代码的讲解,所以用闭包的思路去分析。但是实际关键节点还是异步问题,小伙伴们不要钻牛角尖。
说到这里,眼尖的小伙伴可能已经发现了,那既然是取的时候已经是3了,那我每次进循环的时候,都把当前的i值存一下行不行?行啊,当然行,这就是我们接下来要说的。
闭包拆分
现在再回去看一下最开始的代码快,然后对代码做个改造。
for (var i = 0; i< 3; i++) {
setTimeout(function fn1 (){
console.log(i)
}, 1000 * i);
}
通过前面的讨论,我们可以确定,因为这里的fn1函数和相同的父级作用域形成了共享闭包。所以为了解决这个问题,我们可以给每个fn1函数外面再包一层作用域,拆分成三个独立小闭包。
改造:
for (var i = 0; i< 3; i++) {
(function foo (i) { // 这里加一层立即执行函数
setTimeout(function fn1 (){
console.log(i)
}, 1000 * i);
})(i) // 每次循环的时候,都给 foo 的 i 赋值
}
拆解:
var i
i = 0
if(i < 3){
(function foo (i) {
setTimeout(function fn1 (){
console.log(i)
}, 0); // 1000 * 0
})(i) // i === 0 (⚠️:立即执行函数,所以代码执行到这里的时候,就已经把foo内部的i给赋值成0了)
}
i++
if(i < 3){
(function foo (i) {
setTimeout(function fn1 (){
console.log(i)
}, 1000); // 1000 * 1
})(i) // i === 1
}
i++
if(i < 3){
(function foo (i) {
setTimeout(function fn1 (){
console.log(i)
}, 2000); // 1000 * 2
})(i) // i === 2
}
i++
// 异步函数执行
关系图:
块级作用域
下面的代码和前面用自调用函数拆分闭包的道理是一样的,区别只是把函数作用域变成了块级作用域。
⚠️:使用let关键字,会隐式的创建块级作用域
改造:
for (let i = 0; i< 3; i++) { // 这里的 var 改成 let
setTimeout(function fn2 (){
console.log(i)
}, 100);
}
拆解:
var i
i = 0
if(i < 3){
let j = i
setTimeout(function fn1 (){
console.log(j)
}, 0);
}
i++
if(i < 3){
let j = i
setTimeout(function fn1 (){
console.log(j)
}, 1000); // 1000 * 1
}
i++
if(i < 3){
let j = i~~~~
setTimeout(function fn1 (){
console.log(j)
}, 2000); // 1000 * 2
}
i++
// 异步函数执行
关系图:
柯里化
简单的聊一下函数柯里化,柯里化其实就是闭包的一种利用。
比如说我们要实现这样的一个效果:
实现一个函数,可以不停的往里传string,直到传入句号,结束并返回所有string拼接的结果。
例子:
strConcat('H')
strConcat('e', 'll')
strConcat('o', ' ', 'W')
strConcat('o')
strConcat('rl')
strConcat('d','.') // 输出 Hello Word.
实现:
function fn1() {
let arr = []
// ** 重要:返回函数 concat **
return function concat() {
// 拿到参数数组
const arg = Array.prototype.slice.call(arguments)
// 将参数存到外层作用域下的arr中
// 由于闭包的原因,每次concat执行时,arr都会保持上一次操作结果
arr = arr.concat(arg)
// 接到终止参数,则返回拼接字符串
if(~arg.indexOf('.')){
const result = arr.join('')
console.log(result)
return result
}
}
}
// ** 重要:这里 strConcat === concat **
const strConcat = fn1()
strConcat('H')
strConcat('e', 'll')
strConcat('o', ' ', 'W')
strConcat('o')
strConcat('rl')
strConcat('d','.') // 输出 Hello Word.
柯里化其实就是利用闭包的原理,实现的一个类似于一个小仓库的效果。
我们用包子工厂举个栗子:
可以看到,实际就是利用了 fn1 活动对象不会被销毁的特点,把fn1当成了一个临时仓库,等所有包子原料sring加工完之后(偷懒了,我的贴的代码里没有加工的过程,但是道理是一样的),再统一输出。
扩展 - 词法作用域
定义:词法作用域就是定义在词法阶段的作用域。
解释:词法阶段也就是词法分析阶段(预编译阶段)。换句话说,词法作用域是在代码执行前就已经确定的作用域。
也就是说,词法作用域在代码执行前就被确定了,所以词法作用域是不会因为代码的执行而改变的。(其实还是有办法改变的,比如用eval搞一些奇怪的事情,但是这不在我们讨论范围内了,我们就当它不变的就好了)
上面的图中颜色深浅不一的三个地方就是三个词法作用域,它们是完全包含的关系,1⃣️ > 2⃣️ > 3⃣️
1⃣️ 包含foo函数所在的作用域,也就是全局作用域
2⃣️ 包含foo函数所创建的作用域,也就是bar函数所在的作用域
3⃣️ 包含bar函数创建的作用域
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。