JavaScript 防抖和节流

本文最后更新于:2020年3月11日 晚上

场景

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

为什么要用防抖和节流?

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

演进

初始实现

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

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 中。