JavaScript 防抖和节流


JavaScript 防抖和节流

场景

网络上已经存在了大量的有关 防抖节流 的文章,为何吾辈还要再写一篇呢?事实上,防抖和节流,吾辈在使用中发现了一些奇怪的问题,并经过了数次的修改,这里主要分享一下吾辈遇到的问题以及是如何解决的。

为什么要用防抖和节流?

因为某些函数触发/调用的频率过快,吾辈需要手动去限制其执行的频率。例如常见的监听滚动条的事件,如果没有防抖处理的话,并且,每次函数执行花费的时间超过了触发的间隔时间的话 – 页面就会卡顿。

演进

初始实现

我们先实现一个简单的去抖函数

function debounce(delay, action) {
  let tId
  return function(...args) {
    if (tId) clearTimeout(tId)
    tId = setTimeout(() => {
      action(...args)
    }, delay)
  }
}

测试一下

// 使用 Promise 简单封装 setTimeout,下同
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
  let num = 0
  const add = () => ++num

  add()
  add()
  console.log(num) // 2

  const fn = debounce(10, add)
  fn()
  fn()
  console.log(num) // 2
  await wait(20)
  console.log(num) // 3
})()

好了,看来基本的效果是实现了的。包装过的函数 fn 调用了两次,却并没有立刻执行,而是等待时间间隔过去之后才最终执行了一次。

this 怎么办?

然而,上面的实现有一个致命的问题,没有处理 this!当你用在原生的事件处理时或许还不觉得,然而,当你使用了 ES6 class 这类对 this 敏感的代码时,就一定会遇到 this 带来的问题。

例如下面使用 class 来声明一个计数器

class Counter {
  constructor() {
    this.i = 0
  }
  add() {
    this.i++
  }
}

我们可能想在 constructor 中添加新的属性 fn

class Counter {
  constructor() {
    this.i = 0
    this.fn = debounce(10, this.add)
  }
  add() {
    this.i++
  }
}

但很遗憾,这里的 this 绑定是有问题的,执行以下代码试试看

const counter = new Counter()
counter.fn() // Cannot read property 'i' of undefined

会抛出异常 Cannot read property 'i' of undefined,究其原因就是 this 没有绑定,我们可以手动绑定 this .bind(this)

class Counter {
  constructor() {
    this.i = 0
    this.fn = debounce(10, this.add.bind(this))
  }
  add() {
    this.i++
  }
}

但更好的方式是修改 debounce,使其能够自动绑定 this

function debounce(delay, action) {
  let tId
  return function(...args) {
    if (tId) clearTimeout(tId)
    tId = setTimeout(() => {
      action.apply(this, args)
    }, delay)
  }
}

然后,代码将如同预期的运行

;(async () => {
  class Counter {
    constructor() {
      this.i = 0
      this.fn = debounce(10, this.add)
    }
    add() {
      this.i++
    }
  }

  const counter = new Counter()
  counter.add()
  counter.add()
  console.log(counter.i) // 2

  counter.fn()
  counter.fn()
  console.log(counter.i) // 2
  await wait(20)
  console.log(counter.i) // 3
})()

返回值呢?

不知道你有没有发现,现在使用 debounce 包装的函数都没有返回值,是完全只有副作用的函数。然而,吾辈还是遇到了需要返回值的场景。
例如:输入停止后,使用 Ajax 请求后台数据判断是否已存在相同的数据。

修改 debounce 成会缓存上一次执行结果并且有初始结果参数的实现

function debounce(delay, action, init = undefined) {
  let flag
  let result = init
  return function(...args) {
    if (flag) clearTimeout(flag)
    flag = setTimeout(() => {
      result = action.apply(this, args)
    }, delay)
    return result
  }
}

调用代码变成了

;(async () => {
  class Counter {
    constructor() {
      this.i = 0
      this.fn = debounce(10, this.add, 0)
    }
    add() {
      return ++this.i
    }
  }

  const counter = new Counter()

  console.log(counter.add()) // 1
  console.log(counter.add()) // 2

  console.log(counter.fn()) // 0
  console.log(counter.fn()) // 0
  await wait(20)
  console.log(counter.fn()) // 3
})()

看起来很完美?然而,没有考虑到异步函数是个大失败!

尝试以下测试代码

;(async () => {
  const get = async i => i

  console.log(await get(1))
  console.log(await get(2))
  const fn = debounce(10, get, 0)
  fn(3).then(i => console.log(i)) // fn(...).then is not a function
  fn(4).then(i => console.log(i))
  await wait(20)
  fn(5).then(i => console.log(i))
})()

会抛出异常 fn(...).then is not a function,因为我们包装过后的函数是同步的,第一次返回的值并不是 Promise 类型。

除非我们修改默认值

;(async () => {
  const get = async i => i

  console.log(await get(1))
  console.log(await get(2))
  // 注意,修改默认值为 Promise
  const fn = debounce(10, get, new Promise(resolve => resolve(0)))
  fn(3).then(i => console.log(i)) // 0
  fn(4).then(i => console.log(i)) // 0
  await wait(20)
  fn(5).then(i => console.log(i)) // 4
})()

支持有返回值的异步函数

支持异步有两种思路

  1. 将异步函数包装为同步函数
  2. 将包装后的函数异步化

第一种思路实现

function debounce(delay, action, init = undefined) {
  let flag
  let result = init
  return function(...args) {
    if (flag) clearTimeout(flag)
    flag = setTimeout(() => {
      const temp = action.apply(this, args)
      if (temp instanceof Promise) {
        temp.then(res => (result = res))
      } else {
        result = temp
      }
    }, delay)
    return result
  }
}

调用方式和同步函数完全一样,当然,是支持异步函数的

;(async () => {
  const get = async i => i

  console.log(await get(1))
  console.log(await get(2))
  // 注意,修改默认值为 Promise
  const fn = debounce(10, get, 0)
  console.log(fn(3)) // 0
  console.log(fn(4)) // 0
  await wait(20)
  console.log(fn(5)) // 4
})()

第二种思路实现

const debounce = (delay, action, init = undefined) => {
  let flag
  let result = init
  return function(...args) {
    return new Promise(resolve => {
      if (flag) clearTimeout(flag)
      flag = setTimeout(() => {
        result = action.apply(this, args)
        resolve(result)
      }, delay)
      setTimeout(() => {
        resolve(result)
      }, delay)
    })
  }
}

调用方式支持异步的方式

;(async () => {
  const get = async i => i

  console.log(await get(1))
  console.log(await get(2))
  // 注意,修改默认值为 Promise
  const fn = debounce(10, get, 0)
  fn(3).then(i => console.log(i)) // 0
  fn(4).then(i => console.log(i)) // 4
  await wait(20)
  fn(5).then(i => console.log(i)) // 5
})()

可以看到,第一种思路带来的问题是返回值永远会是 旧的 返回值,第二种思路主要问题是将同步函数也给包装成了异步。利弊权衡之下,吾辈觉得第二种思路更加正确一些,毕竟使用场景本身不太可能必须是同步的操作。而且,原本 setTimeout 也是异步的,只是不需要返回值的时候并未意识到这点。

避免原函数信息丢失

后来,有人提出了一个问题,如果函数上面携带其他信息,例如类似于 jQuery$,既是一个函数,但也同时含有其他属性,如果使用 debounce 就找不到了呀

一开始吾辈立刻想到了复制函数上面的所有可遍历属性,然后想起了 ES6 的 Proxy 特性 – 这实在是太魔法了。使用 Proxy 解决这个问题将异常的简单 – 因为除了调用函数,其他的一切操作仍然指向原函数!

const debounce = (delay, action, init = undefined) => {
  let flag
  let result = init
  return new Proxy(action, {
    apply(target, thisArg, args) {
      return new Promise(resolve => {
        if (flag) clearTimeout(flag)
        flag = setTimeout(() => {
          resolve((result = Reflect.apply(target, thisArg, args)))
        }, delay)
        setTimeout(() => {
          resolve(result)
        }, delay)
      })
    },
  })
}

测试一下

;(async () => {
  const get = async i => i
  get.rx = 'rx'

  console.log(get.rx) // rx
  const fn = debounce(10, get, 0)
  console.log(fn.rx) // rx
})()

实现节流

以这种思路实现一个节流函数 throttle

/**
 * 函数节流
 * 节流 (throttle) 让一个函数不要执行的太频繁,减少执行过快的调用,叫节流
 * 类似于上面而又不同于上面的函数去抖, 包装后函数在上一次操作执行过去了最小间隔时间后会直接执行, 否则会忽略该次操作
 * 与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作, 而非仅执行最后一次操作
 * 注: 该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值
 * 注: 返回函数结果的高阶函数需要使用 {@link Proxy} 实现,以避免原函数原型链上的信息丢失
 *
 * @param {Number} delay 最小间隔时间,单位为 ms
 * @param {Function} action 真正需要执行的操作
 * @return {Function} 包装后有节流功能的函数。该函数是异步的,与需要包装的函数 {@link action} 是否异步没有太大关联
 */
const throttle = (delay, action) => {
  let last = 0
  let result
  return new Proxy(action, {
    apply(target, thisArg, args) {
      return new Promise(resolve => {
        const curr = Date.now()
        if (curr - last > delay) {
          result = Reflect.apply(target, thisArg, args)
          last = curr
          resolve(result)
          return
        }
        resolve(result)
      })
    },
  })
}

总结

嘛,实际上这里的防抖和节流仍然是简单的实现,其他的像 取消防抖/强制刷新缓存 等功能尚未实现。当然,对于吾辈而言功能已然足够了,也被放到了公共的函数库 rx-util 中。


文章作者: rxliuli
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 rxliuli !
 上一篇
JavaScript 微任务/宏任务踩坑 JavaScript 微任务/宏任务踩坑
JavaScript 微任务/宏任务踩坑场景 SegmentFault 在使用 async-await 时,吾辈总是习惯把它们当作同步,终于,现在踩到坑里去了。使用 setTimeout 和 setInterval 实现的基于 Promi
2019-05-15 rxliuli
下一篇 
layui-layer load 弹窗自动关闭的问题 layui-layer load 弹窗自动关闭的问题
layui-layer load 弹窗自动关闭的问题场景项目中的 Ajax 加载时的 loading 框有时候会关闭了弹窗之后很久页面上的数据才加载出来,而且这个问题是随机出现的,有些页面存在,有些页面则正常。 最小复现代码 <!DO
2019-04-12 rxliuli
  目录