JavaScript 函数漫谈,以及防抖和节流

226 阅读6分钟

让我们先从这样一个需求说起:如何保证一个函数只被调用 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 个问题:

  1. 布尔变量 called 污染了全局环境。
  2. 函数 f 实际上还是被调用了,只是在进入函数体后就马上退出了。
  3. 这种实现是侵入式的,我们只能这样修改我们自己定义的函数。假如你是某个开发框架的作者,要保证用户提供的函数只被调用 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 值。

  1. 作为普通函数调用时,如 f(),这时函数体中的 this 值为 window,或者严格模式下的 undefined
  2. 作为对象的方法调用时,如:o.g(),这时函数体中的 this 值为该对象 o
  3. 作为构造器调用时,如:const o = new F(),这时函数体中的 this 值为新构造的对象 o

我们写死成 f(...args) 后,函数 fthis 的值就永远是 windowundefined 了。

怎么办呢?

我们发现,其实用户直接调用的函数并非 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)
        }
    }
}