闭包是什么

函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为“闭包”。——《JavaScript权威指南》

匿名函数经常被人误认为是闭包(closure)。闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。 ——《JavaScript高级程序设计第4版》

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。 ——《MDN Web Docs》

总结:
闭包是指有权访问另一个函数作用域中变量的函数。

闭包与作用域

JS 堆栈内存释放

  • 堆内存:存储引用类型值,对象类型就是键值对,函数就是代码字符串。
  • 堆内存释放:将引用类型的空间地址变量赋值成 null,或没有变量占用堆内存了浏览器就会释放掉这个地址
  • 栈内存:提供代码执行的环境和存储基本类型值。
  • 栈内存释放:一般当函数执行完后函数的私有作用域就会被释放掉。

但栈内存的释放也有特殊情况:
① 函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。
② 全局下的栈内存只有页面被关闭的时候才会被释放。

作用域链、变量(活动)对象、上下文

作用域链就是一个包含变量对象的指针的列表。而变量对象与上下文相关联。

变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。

上下文是一个抽象的概念,可以理解为一个代码集(包含定义、声明的变量和函数),具体是以变量对象的形式所利用,每一个上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数(其实只是指针)都存在于这个对象上。这个对象无法被用户访问。
全局上下文是最外层的上下文,在浏览器中是window对象。
上下文在其所有代码都执行完毕之后会被销毁(内存释放),包括定义在它上面的所有变量和函数(全局上下文在应用程序退出之前才会被销毁,如关闭网页或退出浏览器)

函数与作用域链

函数在定义时,会保存一个作用域链,链上最后一个必然是全局变量对象。
函数调用时,则会创建一个活动对象,该对象以arguments和命名参数作初始化。当执行流进入时,再将函数中其他所有的变量和函数加入该活动对象。然后将这个活动对象加入作用域链的最前端,当函数执行完之后,该活动对象随上下文销毁。

变量对象和活动对象其实是一样的,只不过变量对象用来描述在全局作用域下的变量组成的对象,即全局对象,浏览器中是window,node中是global

由于函数会保存他所定义时的一条作用域链,也就是说内部函数,即闭包,会保存一条作用域链,这条作用域链在定义时生成,链中从前往后依次是外部函数的活动对象,外外部函数的活动对象,直到全局变量对象,而内部函数因为尚未执行所以没有产生活动对象,更不会出现在作用域最前端。
在《JavaScript高级程序设计(第4版)》10.14小节中提到,这条作用域链会保存在定义的函数的内部[[Scope]]属性中。

作用域链的生成过程:

  1. 全局环境下的函数定义时就保存一条作用域链,当函数执行时,这个函数的上下文生效,从而产生该函数的活动对象,并把该活动对象加到作用域链前端,对于函数作用域内的一切变量和函数,都会与这条新的作用域链产生联系,最常见的就是按照作用域链去追溯去访问变量和函数
  2. 如果该函数还有一个内部函数(闭包),那么[外部函数,window]这条作用域链会在内部函数定义时添加到内部函数的[[Scope]]属性中
  3. 闭包执行,闭包自己的活动对象被添加到作用域链前端,在闭包内进行访问可根据这条作用域链进行。闭包活动对象的构建分两个阶段:
    1. 调用时,对象仅以arguments和命名参数作初始化
    2. 执行流进入时,将函数中其他所有的变量和函数(包括对其他函数作用域的引用)加入该活动对象,这个阶段就有对变量和函数溯源过程,可能会顺着作用域链进行访问。

通过下面的例子来展示作用域链:

  1. function createComparisonFunction(propertyName) {
  2. return function(object1, object2) {
  3. let value1 = object1[propertyName];
  4. let value2 = object2[propertyName];
  5. if (value1 < value2) {
  6. return -1;
  7. } else if (value1 > value2) {
  8. return 1;
  9. } else {
  10. return 0;
  11. }
  12. };
  13. }
  14. let compare = createComparisonFunction('name');
  15. let result = compare({ name: 'Nicholas' }, { name: 'Matt' });

image.png

  1. createComparisonFunction()保存一条作用域链,由于它定义在全局作用域下,于是它的作用域链是这样的——createComparisonFunction作用域--全局作用域,作用域链上的都是指针,指向各自的变量(活动)对象。
  2. 而匿名函数,由于是在createComparisonFunction()中定义的,所以会继承createComparisonFunction()的作用域链,并在执行时在作用域链前面添加自己的活动对象,因为该匿名函数可以访问其他函数作用域的变量(propertyName是属于createComparisonFunction活动对象的变量),所以该匿名函数是一个闭包。

闭包的作用、用途

模拟私有变量和私有方法

在上面我们已经归纳出闭包的一个重要特点——可以访问其他函数作用域的变量,那么我们可以保存一些私有变量,只通过闭包函数进行访问。

  1. function data(){
  2. let _students=[
  3. {
  4. name:"Jack",
  5. age:23,
  6. id:2021111523,
  7. major:'Information Security'
  8. },
  9. {
  10. name:"Mark",
  11. age:22,
  12. id:2021111322,
  13. major:'Information Security'
  14. }
  15. ]
  16. let getInfoAll = function(){
  17. console.log(JSON.stringify(_students,null,'--'));
  18. }
  19. let searchStuById = function(id){
  20. for(let info of _students){
  21. if(info.id == id){
  22. console.log('get '+id+JSON.stringify(info,null,'--'));
  23. return;
  24. }
  25. }
  26. console.log('not found');
  27. }
  28. return {getInfoAll,searchStuById};
  29. }
  30. let stuDataFunc = data();
  31. stuDataFunc.getInfoAll();
  32. stuDataFunc.searchStuById(2021111523);
  33. stuDataFunc.searchStuById(2021111522);
  34. /*
  35. [
  36. --{
  37. ----"name": "Jack",
  38. ----"age": 23,
  39. ----"id": 2021111523,
  40. ----"major": "Information Security"
  41. --},
  42. --{
  43. ----"name": "Mark",
  44. ----"age": 22,
  45. ----"id": 2021111322,
  46. ----"major": "Information Security"
  47. --}
  48. ]
  49. get 2021111523{
  50. --"name": "Jack",
  51. --"age": 23,
  52. --"id": 2021111523,
  53. --"major": "Information Security"
  54. }
  55. not found
  56. */

在上面的例子中,_students就成了一个私有的变量了。这是不是跟面向对象编程很像了。
同样的,我们可以设置一个私有方法,只让返回的函数去调用。

函数工厂

用一个抽象的函数来描述某一类行为,然后通过传参了具象化。比如增长行为属于一类,更具体的增长行为表现在增长速度上,是每次加5还是加10,通过传参就可以实现更具体的行为:

  1. function makeAdder(x) {
  2. return function(y) {
  3. return x + y;
  4. };
  5. }
  6. var add5 = makeAdder(5);
  7. var add10 = makeAdder(10);
  8. console.log(add5(2)); // 7
  9. console.log(add10(2)); // 12

上面出现的createComparisonFunction()也是这样的,将比较这一行为抽象出来,然后生成由传参决定具体对那种属性进行比较这一更具体的行为。

又比如通过传参设置字体大小:

  1. function makeSizer(size) {
  2. return function() {
  3. document.body.style.fontSize = size + 'px';
  4. };
  5. }
  6. var size12 = makeSizer(12);
  7. var size14 = makeSizer(14);
  8. var size16 = makeSizer(16);
  9. document.getElementById('size-12').onclick = size12;
  10. document.getElementById('size-14').onclick = size14;
  11. document.getElementById('size-16').onclick = size16;

将原材料处理生成可用的产品,就像工厂生产一样。

闭包的不足

不要在循环时创建闭包

  1. function showHelp(help) {
  2. document.getElementById('help').innerHTML = help;
  3. }
  4. function setupHelp() {
  5. var helpText = [
  6. {'id': 'email', 'help': 'Your e-mail address'},
  7. {'id': 'name', 'help': 'Your full name'},
  8. {'id': 'age', 'help': 'Your age (you must be over 16)'}
  9. ];
  10. for (var i = 0; i < helpText.length; i++) {
  11. var item = helpText[i];
  12. document.getElementById(item.id).onfocus = function() {
  13. showHelp(item.help);
  14. }
  15. }
  16. }
  17. setupHelp();

上面的代码中,在showHelp函数接收到的始终是'Your age (you must be over 16)'。原因是赋值给 onfocus 的是闭包(有权访问另一个函数作用域中变量的函数)。这些闭包是由他们的函数定义和在 setupHelp 作用域中捕获的环境所组成的。这三个闭包在循环中被创建,但他们共享了同一个作用域——setupHelp的函数作用域,在这个作用域中存在一个变量item。这是因为变量item使用var进行声明,由于变量提升,所以可以在setupHelp函数作用域内可用。当onfocus的回调执行时,item.help的值被决定。由于循环在事件触发之前早已执行完毕,变量对象item(被三个闭包所共享)已经指向了helpText的最后一项。

如果想继续用闭包的方式解决这个问题,则应该利用函数工厂创建一个新的函数作用域作为隔离,写法如下:

  1. function showHelp(help) {
  2. document.getElementById('help').innerHTML = help;
  3. }
  4. function makeHelpCallback(help) {
  5. return function() {
  6. showHelp(help);
  7. };
  8. }
  9. function setupHelp() {
  10. var helpText = [
  11. {'id': 'email', 'help': 'Your e-mail address'},
  12. {'id': 'name', 'help': 'Your full name'},
  13. {'id': 'age', 'help': 'Your age (you must be over 16)'}
  14. ];
  15. for (var i = 0; i < helpText.length; i++) {
  16. var item = helpText[i];
  17. document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  18. }
  19. }
  20. }
  21. setupHelp();

在这里makeHelpCallback会分别把每一个传入的参数item.help存入三个不同的活动对象之中,于是每一个回调都对应一个新的上下文和活动对象,在这些环境中,help 指向 helpText 数组中对应的字符串。

另一种方法就是使用匿名函数:

  1. function showHelp(help) {
  2. document.getElementById('help').innerHTML = help;
  3. }
  4. function setupHelp() {
  5. var helpText = [
  6. {'id': 'email', 'help': 'Your e-mail address'},
  7. {'id': 'name', 'help': 'Your full name'},
  8. {'id': 'age', 'help': 'Your age (you must be over 16)'}
  9. ];
  10. for (var i = 0; i < helpText.length; i++) {
  11. (function() {
  12. var item = helpText[i];
  13. document.getElementById(item.id).onfocus = function() {
  14. showHelp(item.help);
  15. }
  16. })(); // 马上把当前循环项的item与事件回调相关联起来
  17. }
  18. }
  19. setupHelp();

这里同样是通过创建新的作用域来隔离,每一个onfocus的回调函数都会对应了一个独立的活动对象,且该活动对象都分别加入了不同的item(因为这个匿名函数立即执行了)。

更好的解决方法就是使用新语法里的let了:

  1. function showHelp(help) {
  2. document.getElementById('help').innerHTML = help;
  3. }
  4. function setupHelp() {
  5. var helpText = [
  6. {'id': 'email', 'help': 'Your e-mail address'},
  7. {'id': 'name', 'help': 'Your full name'},
  8. {'id': 'age', 'help': 'Your age (you must be over 16)'}
  9. ];
  10. for (var i = 0; i < helpText.length; i++) {
  11. let item = helpText[i]; //用let关键字创建块级作用域
  12. document.getElementById(item.id).onfocus = function() {
  13. showHelp(item.help);
  14. }
  15. }
  16. }
  17. setupHelp();

性能问题

如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。

函数执行完,但是函数的私有作用域内有内容被栈外的变量还在使用的,栈内存就不能释放里面的基本值也就不会被释放。因此被闭包引用的函数作用域对应的栈内存无法被释放。

例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造函数中。原因是这将导致每次构造器被调用时,方法都会被重新赋值一次(也就是说,对于每个对象的创建,方法都会被重新赋值)。

  1. function MyObject(name, message) {
  2. this.name = name.toString();
  3. this.message = message.toString();
  4. this.getName = function() {
  5. return this.name;
  6. };
  7. this.getMessage = function() {
  8. return this.message;
  9. };
  10. }

在上面的代码中,我们并没有利用到闭包的好处,因此可以避免使用闭包。修改成如下:

  1. function MyObject(name, message) {
  2. this.name = name.toString();
  3. this.message = message.toString();
  4. }
  5. MyObject.prototype.getName = function() {
  6. return this.name;
  7. };
  8. MyObject.prototype.getMessage = function() {
  9. return this.message;
  10. };

这样的修改后,继承的原型可以为所有对象共享,不必在每一次创建对象时定义方法。

参考文献

闭包——《MDN Web Docs》
面试 | JS 闭包经典使用场景和含闭包必刷题
《JavaScript高级程序设计》第4版