没学过JavaScript也能看懂的闭包解释

740 阅读9分钟

技术源于生活

有一句甚广的话,相信很多人都听过:艺术源于生活又高于生活,意思是说艺术并不是凭空产生的,而是广大人民群众从日常生活中提炼出来的抽象表现。比如舞蹈,就是人们从具体劳动、生活、战争中抽象出来的肢体表达形式。而技术呢,也不是凭空产生的,也是源于生活,但是却是生活的低层次的抽象(或者叫模仿更加贴切)。所以说,技术源于生活但低于生活。典型的案例,就是面向对象思想,它就是对现实的世界对象的低层次抽象。为什么是低层次的?因为我们无法用代码表达出事物的每一个细节啊。

一个源于生活的场景

假如有一台老式的有线的拨号座机,然后电话铃响起了,我们拿起电话听筒接电话。这个过程大约是这个样子:

我们再具体分析一下这个场景。使用座机接电话时,我们都是拿起听筒来听电话,所以和我们的嘴巴与耳朵打交道的部分其实就是听筒。我们不需要关心电话主机的工作原理,更不用关心主机是如何接收到我们的声音信号,再把声音信号转换成电信号传输出去。我们又不是发明电话的贝尔,我们关心这些干什么?只要拿起听筒说话就好了。

但是,我们只关心听筒,并不意味着光有听筒就能完成打电话这件事情。我把听筒和主机之间的连线剪短了,我们光拿一个听筒说话,能完成接打电话这件事情么?显然不能啊。所以,我们关心什么东西是一回事,能不能完成这件事情是另一回事。听着耳熟对不对,我们前端向来不就是如此么?我们只关心有没有数据,有数据了,我就给你把内容显示在浏览器里,至于这个数据从哪里来的,我根本不关心。但是,如果这个数据不是真实的从服务端传递过来的,而是前端自己硬编码写的假数据,那么这个产品也就根本无法上线。所以说,有些事情,我们不关心它,但是并不表示它不起作用。

从电话这个产品的角度看:一个电话生产过程中,电话的研发人员,需要关心这个电话的主机内部的电路是如何设计的,放大器怎么设置,电路板怎么设计,这些细节都要关心,一个环节出了问题,那就是一个无法正常使用的电话。但是,一旦这个电话在生产线上变成了成品,那么电话生产者是希望把听筒交给使用者,让他们用听筒能接打电话就好了,难道还会让他们自己拆卸电话主机改变里面的电路结构?听着耳熟是吧,没错!就是模块封装好了之后,对外就暴露一个有效接口就好了。

用JS代码实现这个场景

我从这个场景中抽象出两个行为:

  • 拿起电话(pickUpPhone)
  • 通过听筒说话(speakByReceiver)

再抽象出电话的几个部分:

  • 主机(Host)
  • 听筒(Receiver)
  • 连线(Chain)

它们的代码实现大概是这个样子的:

# pickUpPhone.js
function pickUpPhone() {

  //The definition of Host is hidden in phone without being exposed to outside
  function Host(){
    this.transform = function(){
      console.log('I am a host for transforming sound to digital.')
    }
  }
  let host = new Host()

  //The receiver has to collaborate with a host
  function speakByReceiver(){
    console.log('You are speaking by a receiver depending on the host.')
    host.transform()
  }

  //The phone only exposes speakByReceiver to outside but it need the host's help
  return speakByReceiver
}
# main.js
let speakByReceiver = pickUpPhone()
speakByReceiver()

和JS语法无关的代码解释

我试图用通俗易懂的方式,来解释这段代码,从而引出闭包的涵义。所以,代码中省略了模块输出和输入(它们是干扰项),我在本机上模拟时,是用CommonJS导出和导入模块的,在node8上运行的。但是这都不重要,即使我们根本不知道这些,一点也不耽误我们理解接下来我要说的。

在一般的印象中,当一个函数运行结束后,该函数作用域内的局部变量,都会释放掉。所以,站在pickUpPhone()的角度看,当它执行结束后,它的局部变量host就释放掉了,所以host应该是undefined。但是,实际上,并非总是如此。在这个例子中,main.js中的代码运行结果如下:

You are speaking by a receiver depending on the host.
I am a host for transforming sound to digital.

这说明host并没有随着pickUpPhone()的执行完成而消失。

站在使用者main.js的角度看,它根本不关心speakByReceiver()是怎么实现的,它就想调用一下这个函数就好了,这个函数爱咋实现咋实现,和main.js半毛钱关系都没有。但是,不关心,不等于不存在。如果没有host,这个段代码就无法正确的执行(对比打电话,没有电话主机,打电话这事就没法完成)。

站在speakByReceiver()的角度看,这个函数定义的时候,所在的context中,有一个叫做host的外部变量,而该函数在执行的时候必须要使用它。看起来,pickUpPhone()似乎有一种先知能力,它知道未来自己要用到一个不属于它自己的变量host,为了不让这个不属于它的变量,脱离它的掌控,它使用了一种类似于“锁链”的东西,将host牢牢锁在自己身边,不让它离开(释放)。

说到这,闭包的涵义[1]就呼之欲出了。用白的不能再白的大白话说:闭包就是一条锁链,它的一头是某函数的定义context,而领一头是该函数的执行context,这条锁链把本来不应该产生交集的两个context系到了一起,使得该函数在运行时,仍然能够毫无障碍的使用它被定义的时候,所在的context的变量

把生活场景和JS代码联系起来

生活场景讲完了,JS代码也讲完了,完成了一个从生活到技术的简单映射。下面,我们通过这幅图,把两者联系起来看

从这幅图上,直观的不能再直观的告诉你了:那根弯弯的线,就是闭包啊

那这根细细的线有什么好处呢?主要的好处就是把电话主机和听筒连起来了,既让我们可以通过听筒完成打电话这个事情,又让我们在不关注主机如何工作的情况下,成功的把语音转化为电信号给传了出去。

那闭包的好处是什么呢?主要的好处和电话线一模一样:

一个JS模块只对外暴露部分接口,而隐藏细节实现,当被暴露出去的接口被调用时,又可以毫无顾忌使用模块中的任何资源,并且这些资源又不会散落在外,而污染全局

我的天啊,怎么还有这样的美事呢?这显然就是又让贼吃肉,还不用挨打的典范啊。等等!你说什么?大点声!闭包会引起内存泄露?

闭包会引起内存泄露么?

有部分同学是持有这个观点的,至少在我面试的人中,就有同学对我说:闭包会引起内存泄露。现在,就让我们彻底分析一下,到底闭包和内存泄露有没有关系。

首先,我们不考虑神马技术问题,让我们还是回到打电话这个场景中。如果上面打电话的那位帅哥打完电话之后,并没有把听筒撂下,而是拿着听筒到处走,会发生什么事情?显然,听筒连着电话主机,会把让整个电话,通过听筒都挂在他身上,只要把不撂下电话,那么他就一直带着这个电话。上班,下班,约会,睡觉,洗澡,都会带着这个电话。如果这件事情真的发生了:

  • 人们会投诉这个电话的生产厂商么?说,你这个电话设计的有问题,严重影响了别人的生活,打过电话后,电话会一直挂在身上,下不来,增加了个人的体重,让人更加的臃肿了。

  • 亦或是,为了不让电话影响到正常的生活,而一刀子剪断听筒和主机之间的连线。

显然,只要脑子还没坏掉,就绝对不可能做以上两件事情。我们只需要轻轻的把听筒放下就好了。

然后,让我们回到技术层面。一个封装好的JS模块,暴露给外界部分函数,而这些函数的执行又依赖于模块内部的资源,这个模块本身,完全没有问题。实际上,在没有CommonJS, AMD, ES6 Module之前,模块封装就是利用JS函数来完成的。一个函数内定义若干局部变量和内部函数,然后把一部分暴露给外界,这就是一个JS的独立模块。

而对于函数式编程支持特别好的语言,都是支持闭包的。因为只有闭包,才能在不污染全局的前提下,为被执行的函数提供运行所需的,又并非来自参变量的数据。

所以,假设上面的代码只是整个程序的一小部分,当speakByReceiver()执行完后,程序并没有退出,那么只需要加上一句,就可以彻底杜绝内存泄露的隐患。

speakByReceiver = null

所以,不正确的使用闭包才会造成内存泄露,正确利用闭包,是不存在内存泄露的

总结

说了这么多,只要记住一句话就好:那条电话线就是闭包。翻译成术语就是:能够访问函数内部变量的函数就是闭包

[1] 这里我用了“涵义”这个词,而非本质,其原因是,本质涉及大道,而技术的大道就是数学,所以闭包的本质是数学的“闭集”。《易经》曰:“形而上者谓之道,形而下者谓之器”。我自认为没有使用大白话阐述“道”的能力,但是对于解释“器”,自诩有几分心得。