JS闭包和构造函数对比研究

闭包(或闭包函数)的概念在JS社区讨论的频率异常的高,有人甚至 把闭包原理看作像是外语里语法的那样重要。闭包的确是JS开发的基石(之一),然而它的基础作用为何,可能还需进一步廓清。

有一定OOP基础的人一定会发现,闭包函数和构造函数(必须使用new操作符的JS函数)的性质和意义,有点相似。本文尝试分析它们的共通和不同的地方,以期达到深刻认识闭包的意义。

源起——计算功能 的制造种类

最近半个月一直在为重新出山面试作准备,在完成Promise(异步编程)的研习后,我又回到了JS 的基础复习中,其中的研习主题都是围绕着闭包、高阶函数、柯里化,和函数式编程。在这个过程,我开始酝酿着一种新看法,闭包,高阶函数,柯里化这些概念或技术都是以 「函数是一等公民」为前提的,它们都是在 「把函数作值处理的 」技术(请将它和 以数据作值处理相比较)。这种现象应该是 函数式编程范式的表现,目前我还没有对这种 「把函数作值处理的 」现象总结出完整的结论(初步使用「功能编程」概括之),研习还在进行中,不过我已经将它们进行归类,并且辨识出它各自的意义:

功能编程中功能制造的种类

这里有一类首先引起了我特别的注意,就是创建新的一类。

闭包函数和构造函数的相似性

闭包的常识是说,它制作(返回)一个新函数,并且是带私有数据的新函数,从「把函数作值处理的 」的角度看,就是 新建一个「 函数值」注1,或者说新建一变量,它的值是 函数。如果是这个任务,我就想,构造函数 的意义不也是这样吗,只是构造函数返回的一个新对象实例(内部可this指向)。不过如果闭包返回的是带私有数据的函数,这也不是一个对象吗?

为了证明我的猜想,我实验一下将 使用闭包函数的实例改用构造函数 ,看看会怎么样。

带私有数据的事件处理函数

前端开发中,闭包函数使用常见一个例子就是 为DOM对象的交互事件填写 事件处理函数,而这个Event Handler是带参数数据的。例如页面提供了调整字体大小的交互功能(例子来自MDN ) ,分别用大中小三个按键的点击事件提供。每个按键的事件处理逻辑是相似的,只是字体样式不同。

<body>
  <div id="app">
    我们的文本尺寸调整按钮可以修改 body 元素的 font-size
    属性,由于我们使用相对单位,页面中的其它元素也会相应地调整。
    <a href="#" id="size-s"></a>
    <a href="#" id="size-m"></a>
    <a href="#" id="size-x"></a>
  </div>
</body>

由于每个按键的事件处理逻辑是相似的,所以不必为它们编写单独的事件处理函数,我们可以把这些函数实例进行抽象,归纳出一个 「事件处理函数类」,这就是闭包派上用场了。

function makeSizerHandler(size) {
  // 传进来的size 是属于一个本地私有变量  
  return function () {
    document.body.style.fontSize = size + "px";
  };
}

var sizes_hdlr = makeSizerHandler(12);
var sizem_hdlr = makeSizerHandler(14);
var sizex_hdlr = makeSizerHandler(16);

这个makeSizerHandler 就是闭包函数,这里非常简单的,只有一个参数和一条功能语句注2。然后,我们可绑定事件处理了:

document.getElementById("size-s").onclick = sizes_hdlr;
document.getElementById("size-m").onclick = sizem_hdlr;
document.getElementById("size-x").onclick = sizex_hdlr;

现在我们尝试使用构造函数改写这段代码。

用对象实例的方法替换闭包实例

JS构造函数是创建 对象(Object)的标准方法,虽然大部分情况大家推荐使用字面量创建。为了相区分,我们取一个名词:

function SizerHandler(size) {
  this.size = size;
  this.make = function () {
    document.body.style.fontSize = size + "px";
  };
}

我们如期使用new分别创建三个实例,并绑定:

var SHs = new SizerHandler(12);
var SHm = new SizerHandler(14);
var SHx = new SizerHandler(16);

document.getElementById("size-s").onclick = SHs.make;
document.getElementById("size-m").onclick = SHm.make;
document.getElementById("size-x").onclick = SHx.make;

注意到,我们并不能像闭包那样直接用实例名,而还要指定make方法。代码测试通过,功能和闭包一样。

this丢失的分析

这里我注意到一个点,当我在SizerHandler.make实现里使用实例的数据(this.size)时,测试不通过:

... this.make = function () { document.body.style.fontSize = this.size + "px"; ...

简单分析后,原因其实很简单,对象的方法被传递是不带父环境(this指向)的,当把SMs.make赋值给onclick后(就是将函数作值传递),make的执行父环境是那个按键对象,而不再是SMs了。

这就是this丢失。这里也有一个有趣的思考问题:

  • 第一,为什么前面创建时使用初始size参数,可以测试通过?
  • 第二,闭包例子为什么又可访问到数据呢?

这里就涉及了你怎么定了 「函数的完整形式」。例如当你初始化时就使用size,那么size就是make的形式定义的一部分,它不会丢失;但如果make是使用了对象实例其他属性数据,例如通用this.size引用,那make的形式就依赖了this。如果将make“移走”而不带这个this,它的形式是不完整的,故功能会不工作。这里的make 功能比较简单,它可不依赖this,但是如果make比较复杂必须依赖父对象时,我们如果要 将make 功能移走他用,则必须考虑make的完整形式,才能正确复用make的功能。如果我们不理解这个原理,则会被this丢失困扰。

而第二个,闭包的情况比较的特殊,闭包的涵义就是封装,强调形式的完整性,它将整个功能移走,而不仅仅语法上的一个函数,所以它可以访问到数据。

总结

闭包是返回函数的函数,与构造函数,在制作计算功能的任务上——用一个模板类派生具体功能实例——是一致的。我们可以初步总结:==闭包语法是制作带记忆(状态或私有数据)计算功能的轻便工具==,例如本文例子中制作带字体大小的点击事件处理函数。

闭包函数和构造函数的比较概括如下:

  • 相同:都是用来创建新计算功能单元的「抽象类」
  • 不同:闭包语法更轻便;构造函数使用语言支持的new操作符,是OO范式;闭包使用 函数作值传递,是FP范式

当然应用场景也有不同,计算功能比较精简时使用闭包,当功能比较复杂时,可能需要使用构造函数了。


  1. 是函数作值,还是计算功能 作值?实质应该是计算功能作值,以函数为载体,以call apply为值使用!
  2. 分析代码重复的情况,和利用闭包制作 功能类(makeSizerHandler)就是 功能编程过程;而使用makeSizerHandler实例化 处理函数,和绑定事件处理,可归为 结果编程过程
裸男
Nakeman.cn 2023 Build by Gatsby and Tailwind, Deploy on Netlify.