让我们先从这样一个需求说起:如何保证一个函数只被调用 1 次。
很简单。
let called = false
function f() {
if (called) {
return
}
called = true
console.log('function called')
// write your logic here
}
f() // log: 'function called'
f() // log nothing
复制代码
我们定义了一个函数 f,只有首次调用,它才会执行逻辑,后续的调用会在函数开始执行时直接 return。
这个需求实现的不够好,因为有以下 3 个问题:
- 布尔变量 called 污染了全局环境。
- 函数 f 实际上还是被调用了,只是在进入函数体后就马上退出了。
- 这种实现是侵入式的,我们只能这样修改我们自己定义的函数。假如你是某个开发框架的作者,要保证用户提供的函数只被调用 1 次,怎么办呢?
也很简单。
function once(f) {
let called = false
return function (...args) {
if (called) {
return
}
called = true
f(...args)
}
}
复制代码
我们只需要把第 1 版的代码放在一个函数里包起来,将用户提供的函数当做参数,然后再将它包在一个匿名函数里返回。
这里我们用到了闭包,将变量 called 持久的存储在闭包中,用它来控制函数是否被调用过。
其实,对于一个初级开发者来说,第 2 版的代码并不容易理解,因为它略过了一些很重要的细节。
比如:什么是闭包?为什么函数可以当做参数和返回值?
还有最重要的一点:为什么我们要把用户提供的函数包起来?
让我们先从什么是函数开始,一步一步回答上面的问题。
在 JavaScript 中,函数是一个对象,或者更简单的说,就是一个值。一个函数跟一个数字 42
或者一个字符串 hello world
没有本质的区别。如果非要说有区别,那么就是函数这个对象,它可以被调用,并且它有一个内部属性(对开发者不可见)用来存储函数体,而函数体,就是一条或多条语句组成的字符串。
函数就是一个值,这是 JavaScript 最大的秘密。
既然函数是一个值,那当然可以当做其他函数的参数和返回值。
那闭包呢?
其实函数还有一个内部属性,叫做 Scope,它保存了函数能够读写的所有上层变量。多层次的 Scope 组成了一条作用域链。所谓的闭包就是由函数和它上层的可读写的变量组成的一个实体。
这里我们不用去关心闭包这个名词,我们只需要知道,函数可以读写它所有上层的变量即可,函数体中用到了哪个变量,该变量就会一直存放在闭包中,占用内存。
那函数的作用域是在什么时候确定的呢?答案是在写代码的时候,所以 JavaScript 的作用域也称为词法作用域。
有意思的是,函数的函数体也是在写代码的时候就确定了。
所以你无法在运行时更改函数的作用域或者函数体。
接下来我们就可以回答那个最重要的问题了:为什么我们要把用户提供的函数包起来?
因为我们要寻求对函数调用过程的控制。
我们只有把用户提供的函数用另外一个函数包起来,然后在这个外层函数里写我们的控制逻辑,来控制用户函数的调用时机以及调用方式。理解了这句话,后面我们要讲的防抖和节流就很容易了。
想要控制函数的调用过程,其实涉及到了元编程的概念。什么是元编程?普通的编程是写代码操作数据;元编程是写代码操作代码。比如 Proxy
这个类,就是写代码操作其他代码中对某个对象的属性的增删改查操作。正常我们删除一个对象的某个属性 delete o.a
,删除了也就删除了,真正删除的过程你是无法控制的,而 Proxy
就提供了让你控制的方法,你可以在删除属性的过程中做一些事情。同样,我们调用一个函数 f()
,调用了也就调用了,在你调用之后,函数体的执行过程你在运行时是无法控制的。
但我们现在需要控制这个调用过程,JavaScript 没有提供类似 Proxy
这样的类来劫持函数的调用,事实上也不需要,因为我们可以通过函数的嵌套来做到这一点。
细心的同学可能已经发现了,我们第 2 版的代码有一个问题,如果用户这样使用 once
函数会怎样?
const o = {
a: 1,
g: once(function () {
console.log(this.a)
}),
}
o.g()
复制代码
答案是 log
函数会打印出 undefined
(在严格模式下会报错),而不是 1
。
因为我们的 once
工厂函数改变了入参函数的调用方式:写死成 f(...args)
了。
但是函数调用有 3 种方式,每种方式对应着不同的 this
值。
- 作为普通函数调用时,如
f()
,这时函数体中的this
值为window
,或者严格模式下的undefined
。 - 作为对象的方法调用时,如:
o.g()
,这时函数体中的this
值为该对象o
。 - 作为构造器调用时,如:
const o = new F()
,这时函数体中的this
值为新构造的对象o
。
我们写死成 f(...args)
后,函数 f
中 this
的值就永远是 window
或 undefined
了。
怎么办呢?
我们发现,其实用户直接调用的函数并非 f
,而是 once
返回的那个匿名函数。此匿名函数在运行时,函数体中的 this 就是正确的 this(我们不用关心它的值到底是什么),所以我们只需要调用函数的 apply 方法,把 this
传给它即可。
function once(f) {
let called = false
return function (...args) {
if (called) {
return
}
called = true
f.apply(this, args)
}
}
复制代码
防抖
现在我们来说防抖。
防抖的本质是:我们要控制函数在一段时间(wait)之后才调用,如果这段时间没有走完,函数又被调用了,我们要取消上一次调用,回到原点重新开始计时。也就是我们上面说的,寻求对函数调用过程的控制。
根据上述本质,我们可以很容易的写出如下简单的实现:
function debounce(f, wait) {
let timer
return function (...args) {
clearTimeout(timer)
timer = setTimeout(() => {
f.apply(this, args)
}, wait)
}
}
复制代码
节流
接下来说节流。
节流的本质是:我们要控制函数每隔一段时间(interval)后就执行,时间间隔内的调用要被取消。也就是我们要对函数进行周期性的调用。你看,说白了还是寻求对函数调用过程的控制。
根据上述本质,我们可以很容易的写出如下简单的实现:
function throttle(f, interval) {
let start = 0
return function (...args) {
const now = Date.now()
if (now - start >= interval) {
start = now
f.apply(this, args)
}
}
}
复制代码