JavaScript闭包由浅入深

691 阅读5分钟

JavaScript中的闭包并非单一的概念,它涉及到作用域、作用域链、执行上下文、内存管理等多种知识。

如果在一个函数中我们返回了另一个函数,且这个返回的内层函数使用了外层函数的变量,那么外界便能够通过这个返回的函数获取原函数内部的变量值,则我们将返回的函数称为原函数的一个闭包。

这概念看起来是不是有点绕呢🤔?​那么我们来看一下下面这个例子:

function outFun(){
    let num = 1;
    num++;
    const innerFun = ()=>{}
    return ()=>{
        console.log(num);
    } 
}

const innerFun = outFun();
innerFun();	// 控制台输出2

上面是一个简单的闭包示例,在外部函数outFun内返回了箭头函数,我们将其赋值给innerFun,调用innerFun我们可以访问到outFun函数内的变量numinnerFun就是outFun的一个闭包。

看了上面的例子想必你对于闭包已经有了基本的了解😋

那么就有小伙伴要问了:为什么可以在函数外部访问到outFun中的局部变量呢?

一般来讲,在一个函数执行完毕后,会从函数执行栈中出栈,函数内的局部变量在下一个垃圾回收(GC)节点会被回收,同时该函数对应的执行上下文会被销毁,所以我们无法在外界访问函数内部定义的变量,也就形成了所谓的函数作用域。

但是我们根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量。通过innerFun访问outFun中的变量,引用的变量仍然保存在内存中,垃圾回收机制无法将其回收,形成了闭包。

接下来我们通过一道经典面试题来深入了解下闭包:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}

console.log(new Date, i);

上面这道题输出了什么?

答案
	Wed Dec 23 2020 22:42:17 GMT+0800 (中国标准时间) 5
	Wed Dec 23 2020 22:42:18 GMT+0800 (中国标准时间) 5
	Wed Dec 23 2020 22:42:18 GMT+0800 (中国标准时间) 5
	Wed Dec 23 2020 22:42:18 GMT+0800 (中国标准时间) 5
	Wed Dec 23 2020 22:42:18 GMT+0800 (中国标准时间) 5
	Wed Dec 23 2020 22:42:18 GMT+0800 (中国标准时间) 5
    因为 var 污染了全局,了解 Event Loop 的同学应该知道在执行上面这段代码时,所有的setTimeout定时任务在for循环执行结束后才会执行,那时候的 i 已经变成了 5 ,而最先输出的是下面的 console.log ,一秒后执行定时任务完成剩余输出。

上面的问题你答对了吗?那么我们进入下一步😎

如果我们想要输出5 0 1 2 3 4,该如何对上面的代码进行改造呢?

熟悉setTimeoutAPI的小伙伴会给出以下方案:

for (var i = 0; i < 5; i++) {
    setTimeout(function(j) {
        console.log(new Date, j);
    }, 1000, i);
}

console.log(new Date, i);

setTimeout的第三个参数为回调函数的传入参数。

熟悉ES6的小伙伴应该会使用let来实现这个需求吧?

for(let i = 0;i < 5; i++){
    setTimeout(function() {
        console.log(new Date(), i);
    }, 1000)
}

console.log(new Date(), i);

使用let替代var,在每一次循环内let都会形成一个块级作用域,进行重新赋值,但这种解决方案会存在一个问题,因为 i 只会存在于循环内部,所以在外部的 console.log 并无法访问到内部的 i ,这并不算是一个完美的解决方案。

要让外部访问到函数内部的变量,你是不是想到什么了呢?​没错​!​就是​我们​的​主角​闭包 😜

for (var i = 0; i < 5; i++) {
    (function(j) { 
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000);
    })(i);
}

console.log(new Date, i);

利用IIFE (Immediately Invoked Function Expression:声明即执行的函数表达式)来执行将每一次循环的变量传入定时任务中可以达到我们想要的效果。

如果更进一步,我们不仅想要输出5 0 1 2 3 4,同时希望它们的输出间隔都为1秒呢?

我们可以修改定时器的时间来实现该需求:

for (var i = 0; i < 5; i++) {
    (function(j) { 
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000 * j);
    })(i);
}

setTimeout(function(){
    console.log(new Date, i);
}, 1000 * i);

既然我们每一个循环都有一个异步操作,那么我们能不能使用Promise来实现这个需求呢?

我们可以使用一个数组存放Promise对象,使用Promise.allAPI来实现这个需求:

const arr = [];
for(var i = 0;i < 5;i++){
    const getPromise = (i)=>{
        return new Promise((resolve)=>{
            setTimeout(()=>{
                console.log(new Date, i);
                resolve();
            }, 1000 * i);
        })
    }
    arr.push(getPromise(i));
}

Promise.all(arr)
    .then(()=>{
    setTimeout(()=>{
        console.log(new Date, i);
    }, 1000);
})

在 for 循环中使用了var进行声明而不使用let进行声明,是因为在执行Promise.all后之后的回调还需要执行一次定时任务,若使用let则无法拿到函数作用域内的i变量,若使用let最后结果将输入 0 而不是 5 。

既然我们使用了Promise,那么不妨将其优化一下,不使用Promise.all而使用async/await来实现:

const getPromise = (i)=>{
    return new Promise((resolve)=>{
        setTimeout(()=>{
            console.log(new Date, i)
            resolve()
        }, 1000)
    })
}

(async ()=>{
    for(var i=0;i<5;i++){
        await getPromise(i);
    }
    await getPromise(i);
})()

这种写法相对来说可读性也有所提高,同时节省了数组所需的内存。


接下来我们回归原点,来看一道与闭包相关的手写代码题:函数柯里化

所谓的柯里化(curry)就是将接受多个参数的函数通过闭包转化为接收更少参数的函数,该函数返回一个接收剩余参数的函数。柯里化函数能够实现参数的复用。

我们来看一下具体的示例:

function getSum(a, b, c){
    return a + b + c;
}

// 柯里化函数
function curryGetSum(a){
    return function(b, c){
        return a + b + c; 
    }
}

// 这样也是柯里化函数
function curryGetSum2(a){
    return function(b){
        return function(C){
            return a + b + c;
        }
    }
}

看到这里你应该能明白什么是柯里化函数了吧?那么接下来让我们一起来尝试实现一个柯里化工具函数吧😆

柯里化实现思路:

  1. 调用时返回一个柯里化封装后的函数carried
  2. 当传入的args长度与原始函数所定义的func.length相同或更长,那么直接将参数传递给它即可
  3. 否则返回一个封装后的偏函数,它将重新应用carried,将之前传入的参数与新的参数一起传入,直到传入的参数总长度大于或等于func.length时才会获取最终结果。
function curry(func){
    return function curried(...args){
        if(args.length >= func.length){
        	return func.apply(this,...args);
    	}else{
            return function(...args2){
                return curried.apply(this,args.concat(args2))
            }
   	 	}
    }
}

上面的代码结构稍微复杂点,可以代入上面的例子以及实现思路反复咀嚼,能够从根本上理解其实现思路你就赢了🤗


当然,凡事有好有坏,闭包也不能免俗。闭包在使用的同时存在内存泄漏的风险。

内存泄漏:指内存空间明明已经不再被使用,但由于某种原因并没有被释放的现象。

我们来看一下具体的例子:

function first(){
    let value = 0;
    setInterval(function(){
        console.log(value++);
    }, 1000)
})

first();

first = null;

使用setIntervalfirst内的value被引用,即使设置了first = null内存空间仍然无法释放,每隔一秒仍然会在控制台输出value值。这种情况需要使用clearInterval对其清除,占用的内存才能被释放。

const element = document.getElementById('element');
element.innerHTML = '<button id="button">click</button>';
const button = document.getElementById('button');
button.addEventListener('click', function(){
    // ... 
})
element.innerHTML = '';

使用element.innerHTML = '',成功将buttondom中移除,但是事件处理函数仍在监听它,element节点无法回收,内存被占用。这种情况需要使用removeEventListener函数去除事件监听,防止内存泄漏。

const element = document.getElementById('element');
element.third = "third";
element.parentNode.removeChild(element);

使用element.parentNode.removeChild(element)element从文档流中去除,但是element仍然存在,该节点所占用的内存无法释放。这种情况需要使用element = null来释放内存。

到此为此我想你对于闭包造成内存泄漏的情况已经有了基本的认知,闭包虽好,使用的时候也要小心内存泄漏哦😉


感谢你花时间读到这里。如果这一篇文章你觉得不错或是对你有所帮助的话,请给笔者一个赞:+1:,如果对文中内容有任何疑问,欢迎评论区留言评论👻