闭包其实很简单——快速明了闭包原理

282 阅读13分钟

前言

实际上,闭包根本不需要特殊的学习,但是你确实需要了解和认识到你写的代码属于“闭包”,才能充分运用这一特性来谱写不同的代码,也就是说:认识闭包——这才是我们对闭包运用最重要的学习。

1. “日常”的闭包

1.1 认识闭包发生的场景

我们先来看一段再普通不过的代码:

function apple() {
  var a = 2;
  function banana() {
    console.log(a); //2
  }
  banana();
}
apple();

这段代码算是闭包吗?看起来是,但又不是。

然而并不是我故意在做谜语人,因为这个是又不是确实是有道理的。

咋一看,a是基于词法作用域的查找范围(先在函数自身找,找不到再跳到外层去找,沿用函数自身声明时所在的位置去一层层向外寻找),但是利用词法作用域去查找函数(apple)内部的函数(banana)的变量的查找规则,只不过是闭包的一部分。

事实上,banana()是一个涵盖了apple()作用域的闭包,同时它可访问的辐射范围还包括了外界的全局作用域,但是我们在这里不关心全局作用域,我们只关心包含了banana()的函数apple()。

事实上,我们只需要对上面的代码再多加一点描述,你就能很清晰地看到闭包的全貌了。

function apple() {
  var a = 2;
  function banana() {
    console.log(a); //2
  }
  return banana;
}
var fruit = apple();
fruit();

在上述这段代码中,有一个非常重要的关键字,也就是return。

我们能看到,banana的词法作用域在向外利用RHS查询去查找变量a时,会沿着词法作用域持续向外访问,直到访问apple()的内部作用域时,找到了变量a,然后利用return将函数banana()作为返回丢出去,赋值给定义的新变量fruit,同时调用fruit。

fruit();

这段简单的代码中,我们可以直接捕捉到两个信息:

1.banana()是基于词法作用域一层一层进行查询的,当它在自身没找到变量a时,它会跑到包裹它的apple()函数上去继续查找。

2.apple()函数有一个关键字return,通过return将函数banana()当做结果”抛“给了外面等着被赋值的fruit,看起来被调用的是fruit,实际上只是通过fruit这个引用调用了apple()内部的函数banana()。

我们可以把形成闭包的函数称为子函数,而包裹其产生闭包函数的函数称之为父函数。

这意味着函数内部的函数,只要通过return抛出去,外面是可以接收一个包含了父函数内部作用域的子函数,还能够通过接收子函数的引用来调用这个子函数,并且保证子函数所使用的父函数内部变量不会丢失。

正常来说,在apple()执行完毕以后,JS引擎就饥渴难耐地等待apple()整个内部作用域被销毁后回收,然而正因为闭包的存在,apple()函数的内部作用域依然存在,而这一切正是因为banana()恰好在apple()函数的内部,它拥有涵盖apple()内部作用域的闭包,阻止了JS引擎对apple()的回收工作。

banana()牢牢地持有对该作用域的引用,而这个引用,正是闭包。

现在,我们可以义正言辞地定义闭包:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即便该函数是在当前词法作用域之外被调用/执行的。

只要是对函数类型的值进行传递,当函数在别处被调用时,都会产生闭包。

只要我们确保在外部,形成闭包的函数内部的函数被调用了,那我们就可以观察到闭包的存在。

function apple() {
  var a = 2;
  function banana() {
    console.log(a); //2
  }
  fruit(banana);
}
function fruit(fn) {
  fn(); //这是闭包
}
apple();

把banana当做参数传给了外部的fruit()函数并在父函数(apple())内调用该外部函数时,实际上也就是父函数内部的子函数在外部被调用了,我们由此可以观察到闭包。其核心的原理都不会脱离于上面的闭包字面说明。

可能有些同学就要问,那你这个闭包,我直接在全局作用域先自定义一个用于引用的变量,然后通过赋值来接收这个函数引用,再手动在外部函数里面调用,能产生闭包不?

我前面讲过的闭包书面解释里面,“即便该函数是在当前词法作用域之外被调用/执行的”已经包含了所有的外部调用场景,即无论通过何种手段将内部函数传递到所在的词法作用域之外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

看下面的代码实现:

var fn;
function apple() {
  var a = 2;
  function banana() {
    console.log(a);
  }
  fn = banana;
}
function fruit() {
  fn(); //2
}
apple(); //先使闭包生效
fruit(); //调用闭包函数

我们都应该知道,setTimeout()是JS内置的一个工具函数,其基于全局作用域调用,我们一定写过不少类似于此的闭包函数:

function loadResponse(msg) {
  setTimeout(function waitRes() {
    console.log(msg);
  }, 100);
}
loadResponse('hello,world');

这里的闭包实现原理非常简单:那就是将loadResponse()的内部函数waitRes()传递给setTimeout()。而waitRes()因为被loadResponse()所包裹,故涵盖了loadResponse()的作用域,生成了对应的闭包。

即便在setTimeout()执行完毕之后,loadResponse()的内部作用域也不会被销毁回收,而其内部作用域由waitRes()所持有,这就是闭包。

闭包最重要的定义在于:函数应该在它本身的词法作用域以外的位置去执行。

1.2 此闭包非彼闭包

这也就意味着:以下代码并不是闭包:

var a = 2;
(function imdate() {
  console.log(a); //2
})();

我们可以看到,imdate()并不是在它自身词法作用域以外的地方所执行的,而是在表达式内部基于自身所在作用域立即执行的。

我们对变量a的查找只不过是基于最普通的词法作用域去查到的,并没有利用“在外部调用函数查找闭包所持有的内部作用域”这一核心要素,所以这只是普通的词法作用域查找。

2.闭包和循环

接下来这段代码,我敢保证你非常的熟悉:

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

我们谱写这段代码最纯粹的愿望就是依次在每秒输出:1,2,3,4...10。

然而最终的输出结果却是10个11。

我们一点一点来剖析这段经典代码:

最后i为什么是11,道理很简单,那就是当i为10时,循环体内部的代码会继续执行(符合i<=10),当条件最终成立时i的值即为11。

造成这一结果的重要原因即是——延迟函数的回调会在循环结束时才去执行,这个时候黄花菜都凉了——i早就执行到11了。

这个和延迟的时间没有任何关系,你定0秒也是一样,setTimeout本身就定义为延迟回调的函数类型。

这段代码很多人称之为JS的“缺陷”,但在我看来这不是JS设计本身的缺陷,而是使用这段代码的用户对JS设计思想的不解。我们会习惯性地假设每次循环产生的迭代中,我们会默认获取一个i的副本。

实际上,虽然根据词法作用域原理,尽管for循环上下文在循环中产生了10个函数,同时每个函数确实都是在各个迭代中被单独定义和生成的,但问题是它们全部被封闭在一个共享的全局作用域中,导致其共享同一个i的引用。

换言之,i虽然是在for上下文中产生,然而for循环(包括if())所定义的代码块,和你直接在全局作用域环境定义一个代码块{...}没有任何区别,它们都是在全局作用域中生成的。再碰上延迟函数的回调导致其包裹的内部函数只会在循环结束时才执行,直接就输出了最后一个共享的变量的最终结果。

上面的代码去掉延时器,手动触发也是一样的:

for (var i = 1; i <= 10; i++) {
  function timer() {
    console.log(i); 
  }
}
timer();

每轮迭代所生成的函数,来来去去调用的都是同一个全局变量环境的i,在此基础上累加迭代,timer()并没有由此获取一个封闭的作用域。你可以粗暴的理解为timer()函数创建了10轮,最后调用的时候不过是“吃到了最后一个包子(i=11)”。

所以,在这里,我们必须要构建一个完全的闭包作用域,以令其在每次迭代中生成一个“自己”的作用域,那就是通过建立一个私有的函数包裹其延时器部分的代码,以此创建封闭环境,达到了闭包产生的“必备”条件。

2.1 立即执行函数表达式创建闭包

上面的问题,我们可以先给出一个解决方案:通过立即执行函数表达式创建闭包!

上面的代码中,我们可以令延时器内部的timer()变成父函数内部的子函数,通过setTimeout在外部调用实现“闭包”效果。

当然,先别急,先别急,我猜你现在的状态大概是“哦丞相我悟了”,但是你悟的那个不一定是对的,你很可能会这样写:

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

针不戳,你又获得了10个11。

那么问题出在哪里呢?

你说啊不对啊老哥,这不是每个延迟函数都会讲立即执行函数表达式在每次迭代中创建的作用域封闭起来了吗?

如果你这么说,我只能说你还是没有搞明白闭包的必要条件——

你必须在闭包所覆盖的内部作用域中创建一个变量,这是父函数的内部作用域所持有的变量,在这里就是立即执行函数表达式里面的作用域创建一个变量。

你必须要定义立即执行函数表达式自己内部作用域的定义变量,以供外部调用!

你要是不定义,正如上述例子所表示,最后用的还是全局作用域上面的变量i,这时候早就“已经结束了啦!”。

for (var i = 1; i <= 10; i++) {
  (function (j) {
  //var j= i; 这里我们不需要再反复定义,只需要将立即执行函数表达式的i以传参的方式送进去就行
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}

在for循环的每次迭代中,在立即执行表达式中都会生成一个新的作用域,使得延迟函数的回调可以老老实实地调用已然封闭内部作用域的父函数中的子函数,子函数包含了父函数的内部作用域。

2.2 块作用域“联动”闭包

这一块就属于老生常谈了,唯一要聊聊的只有两件事:

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

1.这里你不创建闭包的内部作用域变量(let j = i;)行不行?行,没问题,由let劫持的块级作用域本身就是一个封闭的上下文,只不过如果你不定义let j = i;,就不是闭包的块级作用域了。

只是单纯的由let劫持的块级作用域,这里定义了封闭作用域的“私有”变量j,会令其变成一个固定的闭包,并保有由闭包产生的j的值。

2.for循环头部执行的let声明,并非只声明一次,而是每次迭代都会重复声明,每次迭代都会使用上一个迭代结束时的值来初始化这个变量,而非重复声明固定的值1。

2.3 小结

要生成闭包,那就必须要求函数本身要么返回(return)一个函数供外部的变量接收并调用,要么直接从外部调用父函数内部的子函数。

3. 闭包在模块中的运用

我们对比一下两个代码的区别:

function fruit() {
  var apple = 'apple';
  var buyIn = [1, 2, 3];
  function showName() {
    console.log(apple);
  }
  function stringArr() {
    console.log(buyIn.join('!'));
  }
}
function fruitModule() {
  var apple = 'apple';
  var buyIn = [1, 2, 3];
  function showName() {
    console.log(apple);
  }
  function stringArr() {
    console.log(buyIn.join('!'));
  }
  return {
    showName: showName,
    stringArr: stringArr
  };
}
var getFruit = fruitModule(); //调用该函数!构建实例
/* 调用该实例的内部方法,通过return返回的对象中的函数实现 */
getFruit.showName();
getFruit.stringArr();

你发现两者的区别了吗?第二段代码也被称之为模块。

当然fruitModule()只是一个函数,必须通过调用它来创建一个模块实例。如果我们没有通过这个函数构建一个实例,它的内部作用域和闭包都无法被创建。

getFruit通过fruitModule()以一个对象字面量语法构建,其是一个对象,内部含有对内部函数的引用,我们并没有直接对内部数据变量进行操作。

stringArr()和showName()毫无意外地涵盖了fruitModule()的内部作用域,当我们在外部调用这两个内部的子函数时,就可以了观察闭包了。

从上面那两段代码的对比中,我相信你已经发现了不同。

是的,第一段代码,确实有闭包,但是其内部的两个函数却无法生成一个闭包,由此可见生成闭包的模块需要具备以下两个条件:

1.必须有外部的封闭函数来调用需要闭包处理的函数,也就是包含内部函数的父函数在外部被调用。

2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域形成闭包,我们一般通过控制内部函数访问或者修改模块实例内的变量。

一个从函数调用所返回的,只有数据属性没有闭包函数的对象并不是真正的模块。

3.1 模块的定制

我们当然可以对模块进行定制,因为模块本身也是普通函数,可以接受任何参数的“定制”。

function fruitModule(name) {
  function showName() {
    console.log(name);
  }
  return {
    showName: showName
  };
}
var apple = fruitModule('apple');
var orange = fruitModule('orange');
apple.showName(); //apple
orange.showName(); //orange

我们通过传参定义好了一个用于调用的闭包函数的输出,然后通过对象内的函数引用来调用该函数。

同样,我们可以对模块中要输出的对象进行命名和定义,以达到在模块内修改输出对象的方法。

var apple = (function fruitModule(name) {
  function changeSub() {
    publicApi.showSub = sub2;
  }
  function sub1() {
    console.log(name);
  }
  function sub2() {
    console.log(name.split('').reverse().join('')); //字符串反转
  }
  var publicApi = {
    changeSub: changeSub,
    showSub: sub1
  };
  return publicApi;
})('apple');
apple.showSub(); //apple
apple.changeSub();
apple.showSub(); //elppa

通过在模块实例的内部对公共API对象进行内部引用,可以在函数内作用域对模块实例进行增删改查对应的值。

以上就是对闭包的总结和梳理,希望你能通过本篇彻底明了闭包的机制:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即便该函数是在当前词法作用域之外被调用/执行的。