你以为什么是闭包(适用于学习积累和面试)

1,280 阅读13分钟

写在前面

初次接触到闭包这个概念的时候,我尝试在互联网上找到可靠的描述,搜罗到的答案可以说是五花八门,每一个帖子看完之后我都感觉仿佛彻底明了,但是自己张嘴却说不出个所以然,又一次重现了以下场景: 眼睛:已浏览。脑子:已过,稳了。嘴:啥?啥玩意儿? 无奈之下买了js原理的相关书籍,细细品味了一番,发现书里写的很多,也很详尽,但是概念终究太过抽象,有时候同一个概念,换一个表现形式,就容易失去联想。这也揭示一个猿们的通病,单纯看懂一个技术点并不难,难的是运用,以及同一个点的不同形式的变换。

别的观点

那么什么叫闭包?观点很多,出现频率最高的有以下两个观点:

  1. 函数套函数。
  2. 在函数外获取函数内变量的技术。

单纯的评价这两个观点,显然都不算错,因为闭包不管是从形式上还是表现上确实涵盖了以上特点,但这两个观点并不准确,属于闭包的必要不充分条件。我们来尝试推翻这两个观点。

首先说第二点,这个观点在没有先决条件下,可以说是相当的不严谨,一个简单的例子:

function fun() {
    var innerVal = '内部变量'return innerVal;
}
var getInnerVal = fun ();
console.log(getInnerVal);

根据以上例子,我们确实获得了函数fun的内部变量,但是这跟闭包有关系么?毫无关系。

从函数开始

下面我们来说上面的第一点,从这儿开始,我们正式认识下闭包。 闭包这项技术离不开函数,因为函数是闭包的基本组成部分,所以我们先谈谈函数,什么叫函数?我们都知道js的所有运用和变化,都是基于作用域规则产生的,这个规则就是内部作用域拥有其所在的外部作用域的访问权,这意味着内部作用域总是可以拿到外部作用域声明的变量,而外部作用域却没有内部作用域的直接访问权。而每一个函数被声明时,就相当于创造了自己的作用域,同时也遵循作用域的规则。

与此同时,函数本身也遵循软件开发的最小暴露原则,放在这里理解的话,就是说当一个作用域内声明了一个函数的时候,这个函数对于当前作用域来说,就是一个小黑屋,作用域并不知道里面有什么,只有当这个函数被执行的时候,函数内部的逻辑对于作用域来说才算是可见的。

以上指出的函数特点:

  1. 最小暴露原则。
  2. 创造作用域,并遵循作用域规则。

现在我们来看看所谓的‘函数套函数’这个观点:

//全局作用域下写了以下代码
function outFun (){
    var a = 0;
    function innerFun(b){
        a+=b;
        return a;
    }
}

根据该观点,我们现在就相当于产生了一个闭包,看起来好像是这样,但真的是么? 当全局作用域中的逻辑被执行的时候,我们遇到了一个函数的声明,声明了一个名叫outFun的函数,并产生了一个属于outFun的局部作用域,很显然,此时只是声明了函数,而我们没有做任何其他的事情,不要忘记刚说的最小暴露原则,那么现在对于全局作用域来说,outFun就是一个小黑屋,里面有什么并不知道,也就是说对于全局作用域来说,outFun就是个普通的函数,与其他的函数没什么差别,就更谈不上闭包了。所以‘函数套函数就是闭包’这个观点不是很靠谱。

闭包的产生时机

那么问题来了,怎么才算是产生了闭包?或者说,闭包产生的时机又是什么? 看这里:

//全局作用域下写了以下代码
function outFun (){
    var a = 0;
    function innerFun(b){
        a+=b;
        return a;
    }
}
outFun();

根据《你不知道的javascript》第五章第44页中的定义--“当函数作用域可以记住并访问所在的词法作用域时,就产生了闭包,不论函数是在当前词法作用域外还是内执行。”我们来看上面这段代码,从概念上来说产生了闭包(虽然是一个没什么作用的闭包),因为外部函数outFun的执行,声明了内部函数innerFun,而函数innerFun在声明的同时,记住并拥有了所在的词法作用域的访问权。

那么可以明确地一点是,闭包产生的时机是外部函数执行,内部函数声明的时候。

争议

事实上从概念的角度对闭包进行阐述是存在争议的,此处感谢 @茹挺进 的不同思考。大部分观点认为下文中技术角度阐述的闭包才算是真正的闭包,比如以下观点:

  1. 简单的说就是函数内部保存的变量不随这个函数调用结束而被销毁就算是产生了闭包。
  2. 能够完成信息隐藏,并进而应用于需要状态表达的某些编程范型中的才算是闭包。
  3. 闭包是由函数和与其相关的引用环境组合而成的实体。

但是完全从技术角度来阐述闭包的话,就意味着:

  1. 闭包与内存泄露的产生绑定在了一起(先不考虑后续对闭包的清理)。
  2. 同时,也再一次模糊了闭包产生的时机。

出于以上两点的考虑,我进行了区分理解,与此同时,还存在一种情况导致我决定将其区分,IIFE函数的存在,与概念上的闭包一样,你说他是,他有不同,你说他不是,他有具备一部分特点,所以这里大家可以有更多的探讨。

技术上的闭包

我们上面说到示例代码是一个没什么作用的闭包,因为闭包是一项用来解决实际问题的技术,那么尽管上面的写法在概念上来说算是闭包,但是从技术的角度来说,它又不是,因为它不能解决任何实际问题,那么怎么才能让它变得有用呢,这就需要我们想办法让内部函数变得可观察,我们来看下面的例子:

//全局作用域下写了以下代码
function outFun (){
    var a = 0;
    return function innerFun(b){
        a+=b;
        return a;
    }
}
var newFun = outFun();
var res1 = newFun(1);
console.log(res1);   //1
var res2 = newFun(2);
console.log(res2);   //3

我们通过return的方式,将内部函数的引用传递到了外面,同时又在外部函数所在的作用域中声明了一个变量去引用它,使得内部函数变得可操作,也可观察,从而制造了一个实用的闭包。

关于return的误解

此时可能会有另一些声音出现,他们将目光聚焦到了return这个操作上,认为闭包的标志就是是否使用了return,将内部函数的引用传递出去,这是一个误解,我们用三个新的例子,来证明return的使用并不是闭包所依赖的关键,它只是观察和操作的手段之一。

example1:
//全局作用域下写了以下代码
const obj = {};
function outFun (){
    var a = 0;
    obj.newFun = function innerFun(b){
        a+=b;
        return a;
    }
}
outFun();
var res1 = obj.newFun(1);
console.log(res1);   //1
var res2 = obj.newFun(2);
console.log(res2);   //3
example2:
//全局作用域下写了以下代码
let newFun;
function outFun (){
    var a = 0;
    newFun = function innerFun(b){
        a+=b;
        return a;
    }
}
outFun();
var res1 = newFun(1);
console.log(res1);   //1
var res2 = newFun(2);
console.log(res2);   //3
example3:
//全局作用域下写了以下代码
let arr = [];
function outFun (){
    var a = 0;
    function innerFun(b){
        a+=b;
        return a;
    }
    arr.push(innerFun);
}
outFun();
var res1 = arr[0](1);
console.log(res1);   //1
var res2 = arr[0](2);
console.log(res2);   //3

例子中并没有使用return,所以return跟闭包没有任何关系,事实上将内部函数变为可观察、可操作的核心并不拘泥于某种形式,而是在于将函数的引用传递出去即可。

从这里不难看出,闭包也包含了一种对引用关系的阐述。

真的内存泄露了吗?

既然我们已经明了了闭包的概念、产生及其意义,那么不可或缺的也要面对它的一些其他的特性,或者说问题---内存泄漏, 直白点说,内存泄漏就是指某一块内存因为某种原因无法被释放回收,从而造成可用内存出现缺失的状况。 相当一部分人习惯把闭包与内存泄漏捆绑在一起,这个观点是笼统的,我们只能说,闭包可能造成内存泄漏。

前置说明---检查内存泄漏的工具及使用方法:

传送门:你以为内存泄露怎么侦测

我们来看下面对比的两个个例子:

//全局作用域下写了以下代码
//发生内存泄漏的例子
var obj = {};
function outFun (){
    var a = 0;
    return function innerFun(b){
        a+=b;
        return a;
    }
}
setTimeout(function (){
    obj.newFunc = outFun();
    console.log(obj.newFunc(1)); //1
},3000);
setTimeout(function (){
    console.log(obj.newFunc(2)); // 3
},6000);
setTimeout(function (){
    obj.newFunc = null;
    console.log('clean'); //clean
},9000);
  1. 初始时内存快照,无变化,所以无内容
  2. 第一个settimeout之后,本次创建了闭包并占用了内存
  3. 本次变化未处理闭包,所以闭包内存无变化,即该闭包内存未释放
  4. 本次变化将引用闭包的对象属性置空,闭包占用的内存被释放
//全局作用域下写了以下代码
//未发生内存泄漏的例子
function outFun (){
    var a = 0;
    return function innerFun(b){
        a+=b;
        return a;
    }
}
setTimeout(function (){
    outFun();
    console.log('3秒后');
},3000);
  1. 初始时内存快照,无变化,所以无内容
  2. 第一个settimeout之后,outFun执行,本次创建了闭包,但执行完毕后内存立即被回收了,并没有占用内存

内存泄漏的真正原因

从这里我们不难看出,闭包产生内存泄漏的一个必须的条件是被传递出去的内部函数的引用地址,是否被别的作用域的变量所引用。 只有满足这个条件,才会发生内存泄漏,因为当函数outFun执行完毕后,js解释器根据垃圾回收机制,要回收内存的时候,发现内部函数被其他作用域所引用,而js解释器并不知道这个被引用出去的内部函数啥时候执行,所以只能保持内存不释放,以备随时的使用。 所以,结论跃然纸上,闭包不代表就一定会发生内存泄漏,仅仅是可能发生闭包,比如开发者创造了闭包后,没有及时清理。 与此同时,我们可以看出,闭包技术的特性是一把双刃剑,由于它能将作用域内存保持住,那么开发者就可以在后续对该作用域中能访问到的那些个变量做进一步处理,但同时,如果不能合理处理闭包,那么严重的内存泄漏将导致内存溢出,程序崩溃。

闭包的实用例子

简单的再说两个闭包的实用例子,

  1. bind方法已然是耳熟能详,当我们使用js去shim它的时候,就可以用到闭包,而这个过程,也可以被称之为柯理化,又或者称之为预参数,它们都是闭包的一种运用方式。

//这段代码可以类比一种场景,一个ul中有5个li,开发者收集到了5个li的节点后,for循环,通过addeventlistener分别给每一个li绑定click事件,打印当前遍历的索引,效果雷同。
for(var i=1;i<6;i++){
    setTimeout(function(){
        console.log(i);
    },i*1000);
}

以上代码我们期望的效果是过1秒输出1,过2秒输出2,过3秒输出3,过4秒输出4,过5秒输出5,然而结果是都输出的6,因为var声明的变量,在for循环所在的作用域中只有一个,后面的每一次赋值都会覆盖前面的值(这个情况本身跟异步没关系,只是异步了之后,凸显出了这个特点),当第五次循环完毕后,会再次自增1,变为6,只是6不满足循环条件,循环中断,然后打印出来的就都是6。 现在我们利用闭包改造下,

for(var i=1;i<6;i++){
    (function(j){
        setTimeout(function(){
            console.log(j);
        },j*1000);
    })(i)
}

现在就得到了我们想要的结果,过1秒输出1,过2秒输出2,过3秒输出3,过4秒输出4,过5秒输出5。 至于原因,上面关于闭包的分析里已经涵盖。

解决问题的方法永远不只一个

事实上解决这个问题的方式不一定非要使用闭包,

for(let i=1;i<6;i++){
    setTimeout(function(){
        console.log(i);
    },i*1000);
}

let以代码块{}为作用域边界,劫持作用域,使得每一个块内的i都是唯一的,互不干涉。

除此之外,还有别的方法解决该问题,

for(var i=1;i<6;i++){
    setTimeout(function(j){
        console.log(j);
    },i*1000,i);
}

这个原因详见settimeout的使用方法,不做赘述。

关于IIFE函数

有必要补充一点,关于自执行函数,即IIFE函数的一些说明。 IIFE函数,虽然的确创造了闭包,但是由于它本身并没有创建外部作用域,所以从严格上来讲,它不像是闭包。 (虽然IIFE函数也能够引用到括号所在作用域的变量,但那是由于词法作用域查找规则存在的原因,这个规则只是闭包的一部分。) (刚说了,闭包产生于其声明的时候和声明的位置,而IIFE在其声明的作用域内是被隐藏的、是找不到的,这也是其不像闭包的另一个原因。)但它能不少解决问题,这是最重要的

写在最后

需要声明的一点是,我不是一个教授者,我只是一个分享者、一个讨论者、一个学习者,有不同的意见或新的想法,提出来,我们一起研究。分享的同时,并不只是被分享者在学习进步,分享者亦是。

知识遍地,拾到了就是你的。

既然有用,不妨点赞,让更多的人了解、学习并提升。