🔥「吊打面试官」一篇文章彻底搞定【JavaScript闭包】

29,162 阅读13分钟

欢迎大家来到"吊打面试官"系列,我在这个系列中,会和大家分享各种面试中的知识和“坑”,欢迎大家关注我,精彩内容不错过,如果碰到感兴趣的题目想讨论,欢迎大家通过留言告诉我,谢谢,但请记住:

面试或许可以应付,但不要糊弄自己,彻底掌握知识本身才是提升的关键 —— 我说的

闭包是面试和日常开发中都很常见的知识点,但是很多人没有搞清楚它的真实含义,看了一篇又一篇的文章越看越糊涂,本文将帮你彻底理解闭包,欢迎点赞、收藏、评论、转发

内容大纲

  • 闭包到底是个啥?
  • 闭包有什么用?
  • 面试&实例解析
  • 总结
  • 更新&补充

1-闭包到底是个啥?

正式开始前,先给大家三个结论,暂时看不懂没关系,先记住,读到最后你就会彻底明白:

  1. JavaScript中,每个函数都会创建自身的闭包
  2. 闭包没有所谓“用处”或者“好坏”,闭包是JS本身的一部分,你喜欢也好不喜欢也好,它就在那里
  3. (function (){})()不是闭包,这是个自执行函数

简单来说,闭包给了函数访问外部变量的能力,并将函数与外部变量(函数的环境)绑定在一起

1-什么是闭包

看到这里也许大家依然不明白闭包到底是个啥,那么请先跟我一起搞清楚两个问题,就会明白了:

  • 什么是作用域
  • 什么是生存周期

1.1-作用域

所谓作用域,就是一个东西(变量、函数、其他)起作用的范围,看个例子

function fn1() {
  //1-定义一个变量
  let str = 'Blue真帅';
}

function fn2() {
  //2-试图使用这个变量
  alert(str); //报错:str is not defined
}

fn1();
fn2();

打开F12我们可以看到,上面的例子报错了,当然,这很正常

2-程序报错

局部变量,就像函数的私有财产,局部变量只能在定义它的函数内使用

3-局部变量只能在定义它的函数内使用

但是,凡事有例外,局部变量在js也是一样,我们再来看另一个例子

function fn1() {
  let str = 'Blue真帅';

  function fn2() {
    alert(str); //正常弹出
  }

  fn2();
}

fn1();

原因非常简单,因为在JS中,子函数可以直接使用父函数的局部变量,就像人类社会,如果别人拿你的东西可以报警,儿子拿你的东西,警察都懒得管

而这是我们的第一个结论

4-闭包的含义1:子函数可以使用父函数的局部变量

1.2-生存周期

从垃圾回收说起

说起生存周期,我们需要先从“垃圾回收”这个概念说起,我们都知道,计算机的资源是有限的,所以用完的东西就要尽快释放掉,而垃圾回收粗略的可以分两个阶段:

  • 手动回收:C/C++为代表,需要malloc/free或者new/delete来手动回收
    • 如果程序员不够小心,就会产生内存泄漏的问题
  • 自动回收(Garbage Collection):Java、JS、Python等高级语言为代表
    • 自动回收内存,方便又安全,妈妈再也不用担心我内存泄漏的问题

GC(自动垃圾收集机制)是非常方便的,但垃圾回收需要一定的标准,不能把别人还在用的东西回收了

别人钱包掉了,直接就捡走了,警察来了还说我这不是偷,是回收垃圾,这就肯定说不过去了

所以这时候,有一个问题就很重要了——哪些变量是垃圾,可以被回收?

作用域决定了谁是垃圾

我们直接看一个程序,大家很容易找到感觉

function show() {
  //我们有一个局部变量a
  let a = '欢迎大家阅读blue的教程,如果喜欢,请点赞,谢谢';
}



//粗略的分为三个阶段:

//1.show执行前:a是不存在的,不占空间
show(); //2.show执行时:a被分配空间
//3.show结束后:a的生存周期结束,可以被回收

所以,局部变量(通常)在函数执行后,就会被回收(当然,变量其实并没有被立即回收,而是被标记为“可回收”,在下一次GC工作时被带走)

7-局部变量(通常)在函数执行后,就会被回收

那……跟闭包有啥关系

相信上面说的,大家都明白,但是跟我们说的闭包有啥关系呢?别急,再看个例子

function show() {
  let str = '欢迎观看blue的教程';

  document.onclick = function() {
    alert(str);
  };
}

//执行前,str不存在
show(); //执行时,str被创建
//执行后,str“本应”被回收

在上面的例子中,我们show中的局部变量str,本应在函数结束后被回收(局部变量在函数结束后被回收),但是实际情况是,不论你10分钟还是1天以后点击页面,这个str都还在,并没有被回收,原因很简单

函数的存在,延长了外层局部变量的生存周期,只要这个onclick函数还在,那么它(onclick函数)外面的局部变量就不会回收

5-闭包的含义2:函数的存在,延长了外层局部变量的生存周期

但是这时候还有三个延伸的问题:

  • js怎么知道你用了哪些局部变量的?要保留哪些局部变量不回收?
  • js如何确定onclick消失后要回收哪些变量?
  • 父级的变量不回收,那父级的父级呢?

延伸问题1-js如何确定哪些父级变量要保留?

先说结论:js不需要确定,它直接保留全部父级变量,用没用到都保留

来看个例子

function show() {
  let a = 12;
  let b = 5;

  document.onclick = function() {
    alert(eval(prompt('请随便输入一个表达式')));
  };
}

show();

在这个例子中,我们并没有显式的引用任何一个父级变量,而是完全用运行时eval来运行,但我们可以看到,a和b其实都在

6-运行结果

这时大家可能会想,“用不用到都保留,那这多浪费资源啊”、“js为什么不识别一下啊”,啥的,其实原因很简单

  • 健壮性:代码是一个极其复杂的东西,理论上官方不可能100%不出错的确定哪些用了哪些没用,而如果误回收了变量,会导致程序崩溃,所以全都保留是出于程序健壮性的考虑
  • 性能考虑:就算假设有办法判定一段代码里到底用了什么,也会是非常复杂的,会造成大量的运行时开销,反而造成程序性能降低,得不偿失

结论是,闭包会保留全部父级变量,不论用没用,这是非常正确的选择

8-闭包会保留全部父级变量,不论用没用

延伸问题2-引用计数机制

那js如何知道在onclick失效时去回收哪些变量呢?这就要说到引用计数机制,简单看个例子:

let a = { name: "blue", age: 18 }; //{json}被a引用,所以json的引用计数加1
//引用计数=1

let b = a; //{json}又被b引用,引用计数为2
//引用计数=2

a = null; //a不再引用{json},引用计数减1,还剩1个
//引用计数=1

//此时依然有b在引用{json},所以并不回收

b = null; //引用计数又减1
//引用计数=0

//至此,{json}被标记为“可回收”

所谓引用计数,就是标明一个东西,在被几个人引用,一旦引用计数归零,则被标记为“可回收”

而上面的onclick也是一样的道理

function show() {
  let a = 12; //引用计数:1

  document.onclick = function() {}; //引用计数:2
}

show();  //show运行中,引用计数:2
//函数结束,let a相关的引用消失,但并不为零,因为onclick还在引用自己的父级变量
//引用计数:1






//直到有一天...
document.onclick = null; //function没了,所以引用计数减1
//引用计数:0
//a标记为“可回收”

所以,js(包括java等依赖gc的语言都是)依靠引用计数机制,确定一个东西是否能回收

9-js依靠引用计数机制,确定一个东西是否能回收

延伸问题3-父级的父级呢?

闭包的存在,不仅延长父级的局部变量有效期,父级的父级、父级的父级的父级...一直到全局的,其实都会延长

function fn1() {
  let a = 12; //保留

  function fn2() {
    let b = 5; //保留

    document.onclick = function() {
      //a和b在onclick中都有可能被用到,所以,都延长
    };
  }
  fn2();
}

fn1();

10-闭包的含义2:函数的存在,延长了所有外层局部变量的生存周期

1.3-什么是闭包

把我们上面说的所有东西结合起来,闭包的结论就出来了

1-什么是闭包

4-闭包的含义1:子函数可以使用父函数的局部变量

10-闭包的含义2:函数的存在,延长了所有外层局部变量的生存周期

为了帮大家加深印象,这里放一个官方对js闭包的定义

developer.mozilla.org/en-US/docs/…

11-官方定义

2-闭包有什么用?

那么,大家已经掌握了闭包的含义,可是这东西有什么用呢?其实,“没什么用”:

  • 闭包是js自身语法的一部分,只要你访问了父函数的局部变量,都算是“用到了”闭包,这是一个自然的过程,无需刻意使用
  • 闭包保证了js自身的运行,如果错误的将外部变量回收掉,会导致js崩溃,所以闭包更多的是为js语言本身服务,而不是我们

不过,既然了解了闭包的机制,我们确实可以在写代码时注意一些问题,让我们的代码更加高效

性能方面的建议

当你使用任何函数时,记得它会创建一个闭包,也就是说,在这个函数失效前,所有外层变量都会被保留,如果有一些大型对象(例如组件、复杂的数据、图片之类的),会消耗大量资源

来看个例子吧

/*
  假设,我们有一个用于初始化canvas的函数,它需要做几件事
  1-读取图片
  2-绘制到画布
  3-定义一些操作
*/
function initCanvas() {
  let img = new Image();
  img.src = 'http://xxx/一张很大的图片.jpg';

  let gd = canvas.getContext('2d');
  gd.drawImage(img); //为了简化代码,没有加onload

  //问题出现!
  canvas.onclick = function() {
    // 一些操作
  };
}

上面的代码有个问题,onclick会延长img变量的生存周期,而img属于很占资源的东西,所以会导致大量内存浪费

不过不用担心,有很多办法优化这个东西,比如块级作用域、拆分函数等,随便举个例子

function initCanvas() {
  let img = new Image();
  img.src = 'http://xxx/一张很大的图片.jpg';

  let gd = canvas.getContext('2d');
  gd.drawImage(img);
}
//img对象,会在initCanvas结束后顺利回收


function initEvent() {
  canvas.onclick = function() {
    // 一些操作
  };
}
//onclick并没有跟img发生关系,所以,它不会使img一直被保留

3-面试&实例解析

实例1:真·绝对私有成员

面试题:JavaScript中实现私有属性

简单说一下前因,js目前还不支持真正的私有成员

class Box {
  private name = 'blue'; //报错:js没有private关键字(目前)
}

所以,解决方法也非常简单,直接上局部变量就行

(function() {
  let name = 'blue'; //这个变量,是任何人从外部都访问不到的
})();

然后

(function() {
  let name = 'blue';

  //Box中的方法可以访问name,因为“闭包”
  class Box {
    get name() {
      return name;
    }
    set name(val) {
      //如果需要,可以校验一下val
      name = val;
    }
  }
})();

最后,就可以返回出去用了

//2-接收这个class,以便在外部使用
const Box = (function() {
  let name = 'blue';

  //1-不直接暴露name,而是class出去
  return class {
    get name() {
      return name;
    }
    set name(val) {
      name = val;
    }
  }
})();

let box = new Box();

//3-妄图直接使用name
name = 'xxx'; //报错

//4-只能通过setter来访问
box.name = 'name2'; //正确

实例2:烂大街的i

这是一个非常经典的案例,大家也许见过,不过从闭包的层面再来说说,能加深理解

首先,一个很简单的代码

<!DOCTYPE html>
<html lang="en" dir="ltr">

<head>
  <meta charset="utf-8">
  <title>欢迎大家来到blue的教程</title>
</head>

<body>
  <button type="button">点赞</button>
  <button type="button">收藏</button>
  <button type="button">加关注</button>

  <script>
    let aBtn = document.querySelectorAll('button');

    //注意这里的i,是var定义的,这个问题在let下已经不存在了——后面会说
    for (var i = 0; i < aBtn.length; i++) {
      aBtn[i].onclick = function() {
        alert(i); //这里很简单,就是想点“第几个按钮”就“弹几”
      };
    }
  </script>
</body>

</html>

看起来很简单对吧?但是运行效果非常“惊人”

12-运行结果

你会发现,不论点哪个按钮,出来都是“3”,这太邪乎了吧?不会的,万事万物都有原因

问题1:为什么是3

任何语言中(当然包括js),变量虽然叫“变”量,但它不会没事儿自己变来变去的

变量会保留最后一次赋给它的值,直到下次赋值为止

所以我们拆解一下这个程序,就很容易理解

for (var i = 0; i < aBtn.length; i++) {
  aBtn[i].onclick = function() {
    alert(i);
  };
}

//完全等价于
var i = 0;

{
  aBtn[i].onclick = function() {
    alert(i);
  };
  i++;
}
{
  aBtn[i].onclick = function() {
    alert(i);
  };
  i++;
}
{
  aBtn[i].onclick = function() {
    alert(i);
  };
  i++;
}

但是,为什么是3?

再变一下

var i = 0;

{
  i++;
}
{
  i++;
}
{
  i++;
}

alert(i); //你猜,i等于几?

结果当然是3

13-i是几

因为onclick执行时,i早就加到3了,所以不是3才奇怪

问题3:为什么let没事

但是,更好玩的事发生了,这里如果我们用let,这个问题就消失了

<!DOCTYPE html>
<html lang="en" dir="ltr">

<head>
  <meta charset="utf-8">
  <title></title>
</head>

<body>
  <button type="button">点赞</button>
  <button type="button">收藏</button>
  <button type="button">加关注</button>

  <script>
    let aBtn = document.querySelectorAll('button');

    //啥也没改,就是var变成let了
    for (let i = 0; i < aBtn.length; i++) {
      aBtn[i].onclick = function() {
        alert(i);
      };
    }
  </script>
</body>

</html>

你会(又)震惊的发现,它居然好了

14-运行结果

原因很简单,因为let带有块级作用域

{
  var a = 12;
}
alert(a); //能出来,因为var是函数级作用域



{
  let b = 5;
}
alert(b); //报错,因为let带有块级作用域,仅在定义它的代码块中生效

那为什么let能解决上面的问题?

//let带有块级作用域
//“大家一人一个i,谁也别跟别人抢哈”
{
  let i=0;
  aBtn[i].onclick = function() {
    alert(i);
  };
}

{
  let i=1;
  aBtn[i].onclick = function() {
    alert(i);
  };
}

{
  let i=2;
  aBtn[i].onclick = function() {
    alert(i);
  };
}

问题4:如何用闭包的思想解决这个问题

这个问题现在随着let的流行,其实已经不是问题了,但是没关系,我们就是要用一用闭包,咋滴吧

思路很重要:

  • let没问题,因为let是块级作用域,所以“一人一个i”
  • var有问题,因为var是函数级作用域,所以“大家抢一个i”

那么,解决的思路就来了,var能不能也做到一人一个呢?当然,加个函数就好

let aBtn = document.querySelectorAll('button');

for (var i = 0; i < aBtn.length; i++) {
  //参数,其实相当于一个局部变量,从而达到一人一个index的目的
  (function(index) {
    aBtn[index].onclick = function() {
      alert(index);
    };
  })(i);
}

当然,有些时候为了方便(同时提升代码B格),我们可以让参数也叫i,这没什么,参数和外部变量同名没关系的

let aBtn = document.querySelectorAll('button');

for (var i = 0; i < aBtn.length; i++) {
  //不用找哈,只有参数名字变了
  (function(i) {
    aBtn[i].onclick = function() {
      alert(i);
    };
  })(i);
}

4-结论

是时候梳理一遍我们讲过的东西了,那么首先

15-三连

我们来总结一下,这次说到的内容:

  • 闭包=函数+外层变量
  • 闭包的第一重含义:子函数可以访问父函数的局部变量
  • 闭包的第二重含义:子函数的存在,延长了外层变量的生存周期(垃圾回收、引用计数)

闭包的应用:

  • 闭包是JS自身语法的一部分,99%的情况下不需要刻意使用它,它一直在发挥作用
  • 优化建议:注意所有子函数,都会延长父级变量的存在时间(图片等大型数据对象的性能问题)
  • 典型应用1:真·绝对私有成员
  • 典型应用2:解决i的问题(现在其实let更好了)

5-有bug?想补充?

感谢大家观看这篇教程,有任何问题或想和我交流,请直接留言,发现文章有任何不妥之处,也请指出,提前感谢