引言
众所周知,Javascript作为一门解释性的语言,本身是不支持重载的。至于不支持重载的原因我们没有必要去深究,每种语言都有自己的特点。目前大多数工程师对于该问题的解释是“因为Javascript中定义多个同名方法,最后一个会覆盖前面所有的方法~”。但是这种回答不免有点“因为这样,所以这样”的嫌疑。因此,如果有人向你提出这个问题,你大可以回敬他——设计就是如此。如此一来,大家的重点转移到了如何在Javascript中实现重载这一课题上来。在很早的时候JQuery之父John Resig就已经实现了这个需求。他利用闭包的特性巧妙的构造了一个addMethod函数来注册函数,以此替代直接声明。以下是实现方法: 不得不说这是一个非常巧妙的构思,但是我们今天要讨论的重点并非在于如何实现Javascript中函数的重载(因为已经有现成的答案了),而是着重研究这段代码的构建思路,特别是其中对于Javascript一些特殊机制的运用值得我们深思,并拓展到其他的需求当中。
源码剖析
我们首先来分析一下这段代码。首先我们创建了一个reload对象用于储存不同的foo函数,接着构造了一个addMethod函数。给reload对象中的同一个属性赋值多次,显然之前所有的值都会被最后一次覆盖,这样就无法保存多个同名但不同参的函数了,addMethod函数中引入了一个变量oldFn来储存上一次被赋值的函数。接着就调用addMethod函数注册了三个方法,到这里准备部分的工作就已经完成了。按照预想的结果,在堆内存中应该已经保存了三个同名不同参的函数供我们调用。显而易见,接下来的调用结果也印证了我们的猜测。但是顺着整个思路下来,大家是否感到某些地方有些不是那么的“显而易见”?
抛出疑问
让我们聚焦于addMethod函数的三次调用:
addMethod(reload, 'foo', () => {
console.log('no arguments')
})
addMethod(reload, 'foo', (a) => {
console.log(a)
})
addMethod(reload, 'foo', (a,b) => {
console.log(a,b)
})
分别记:
fn1 = () => {console.log('no arguments')}
fn2 = (a) => {console.log(a)}
fn3 = (a,b) => {console.log(a,b)}
在这个过程中,似乎reload.foo的值应该等于一个与fn3有关的函数,当接收两个参数时才执行传入的fn,为什么在执行reload.foo();和reload.foo(1);时仍然可以调用?
闭包的运用
如何构成闭包
显然答案应该就藏在addMethod函数中,让我们再仔细观察一下它的构造。我们注意到这是一个嵌套结构的函数,且内部嵌套的函数引用了外部函数作用域中的变量。到这里想必熟背八股文的朋友已经能联想到这是一个经典的闭包结构了,但是也许还是存有疑虑——这个函数中并没有返回值。在很多网课和教材书中都有这样的一种观点,即闭包的构成条件是外层函数嵌套内层函数,内层函数引用了外层函数作用域中的变量,且外层函数返回了内层函数。 然而外层函数必须要返回了内层函数才能构成闭包吗?其实这种说法是浅显的。事实上只要函数在当前词法作用域外被引用,就构成了闭包。 在整个代码片段中reload.foo() 的三次执行都引用了addMethod函数中的内层函数,使得该内层函数在原本的块级作用域外部被引用了,因此构成了闭包。
通过闭包保留oldFn
闭包的相关特性相信大家都有所了解,这也并非是本文的讨论重心。通过闭包的特性可知,oldFn不会随着addMethod函数内部作用域的销毁而销毁,而是保留了下来。下面我们用数学思维来写一段伪代码:
//addMethod第一次执行,设foo1为reload.foo的第一个值,oldFn1为oldFn的第一个值,foo1与oldFn1和fn1有关,故设foo1=F(fn1, oldFn1),以此类推
const oldFn1=undefined
const foo1 = F(fn1, oldFn1)
//addMethod第二次执行
const oldFn2 = foo1 = F(fn1, oldFn1)
const foo2 = F(fn2, oldFn2) = F(fn2, foo1) = F(fn2, F(fn1, oldFn1))
//addMethod第三次执行
const oldFn3 = foo2 = F(fn2, F(fn1, oldFn1))
const foo3 = F(fn3, oldFn3) = F(fn3, F(fn2, F(fn1, oldFn1)))
经过以上分析,我们会发现这个结构与原型链的结构特别相似。对于addMethod中的ifelse语句,满足if执行逗号左边,否则执行逗号右边,即F(if,else)。
简化执行过程
至此我们已经将代码执行的过程,接下来对三次函数的调用逐条分析。(由于最后一次赋值时reload.foo被赋值为F(fn3, oldFn3),故从F(fn3, F(fn2, F(fn1, oldFn1))) 开始执行)
reload.foo();
执行F(fn3, F(fn2, F(fn1, oldFn1)))
fn3.length = 2; arguments.length = 0 执行逗号右边-> F(fn2, F(fn1, oldFn1))
fn2.length = 1; arguments.length = 0 执行逗号右边-> F(fn1, oldFn1)
fn1.length = 0; arguments.length = 0 执行逗号左边-> fn1 // no arguments
reload.foo(1);
执行F(fn3, F(fn2, F(fn1, oldFn1)))
fn3.length = 2; arguments.length = 1 执行逗号右边-> F(fn2, F(fn1, oldFn1))
fn2.length = 1; arguments.length = 1 执行逗号左边-> fn2 // 1
reload.foo(1, 2);
执行F(fn3, F(fn2, F(fn1, oldFn1)))
fn3.length = 2; arguments.length = 2 执行逗号左边-> fn3 // 1, 2
经过分析,结果就如我们看到的一样。
结语
这是本人第一次在技术社区中发表文章,一些论述上的不严谨之处还望各位读者多多包涵并提出指正。其实我一开始也没想到,本次写作遇到的最大困难在于一旦理清了思路就难以回到原先错误的想法上去了,以至于在抛出疑问这一小节停滞了很久,看来下次遇到问题,在解决之前要先把心中的疑问记录下来,这样等问题解决之后再回头去看,也许会有不一样的体会。
闭包是Javascript中的一座大山,很多时候我们因为害怕在山中迷路,仅仅只能停留在山腰或山脚驻足欣赏它美丽的风景,而对于它的巍峨奇诡则望而却步,殊不知只有翻过这座山,才能看到山另一边那不一样的天地。愿各位在上山的道路上砥砺前行,不畏险阻。故能在下山途中见证别人不曾见过的奇迹。
参考文献
《你不知道的Javascript(上卷)》 —— KYLE SIMPSON 著/ 赵望野 梁杰 译