1

前言

在了解闭包之前,我们要清楚一点。我们了解闭包,不是为了去有意的创建闭包,实际上我们在写代码的过程中,就会无意的创建很多闭包,我们要做的只是了解和熟悉,在写代码的时候知道写出来的是闭包,然后在出现一些奇怪的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个闭包,关系如下图:

image.png

三个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++ 
// 异步函数执行

关系图:
image.png

块级作用域

下面的代码和前面用自调用函数拆分闭包的道理是一样的,区别只是把函数作用域变成了块级作用域。

⚠️:使用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++ 
// 异步函数执行

关系图:
image.png

柯里化

简单的聊一下函数柯里化,柯里化其实就是闭包的一种利用。
比如说我们要实现这样的一个效果:

实现一个函数,可以不停的往里传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.

柯里化其实就是利用闭包的原理,实现的一个类似于一个小仓库的效果。
我们用包子工厂举个栗子:
image.png
可以看到,实际就是利用了 fn1 活动对象不会被销毁的特点,把fn1当成了一个临时仓库,等所有包子原料sring加工完之后(偷懒了,我的贴的代码里没有加工的过程,但是道理是一样的),再统一输出。

扩展 - 词法作用域

定义:词法作用域就是定义在词法阶段的作用域。
解释:词法阶段也就是词法分析阶段(预编译阶段)。换句话说,词法作用域是在代码执行前就已经确定的作用域。

也就是说,词法作用域在代码执行前就被确定了,所以词法作用域是不会因为代码的执行而改变的。(其实还是有办法改变的,比如用eval搞一些奇怪的事情,但是这不在我们讨论范围内了,我们就当它不变的就好了)

image
上面的图中颜色深浅不一的三个地方就是三个词法作用域,它们是完全包含的关系,1⃣️ > 2⃣️ > 3⃣️

1⃣️ 包含foo函数所在的作用域,也就是全局作用域
2⃣️ 包含foo函数所创建的作用域,也就是bar函数所在的作用域
3⃣️ 包含bar函数创建的作用域


missing
47 声望5 粉丝