手写防抖、节流 hook(ts版)

节流与防抖都是通过延迟执行,减少调用次数,来优化频繁调用函数时的性能。不同的是,对于一段时间内的频繁调用,防抖是 延迟执行 一次调用,节流是 延迟定时 多次调用

前言

不知道有多少人,简单的写了防抖、节流函数,然后遇到在 react hook 里失效的情况。

失效的原因: 每次 render 时,内部函数会重新生成并绑定到组件上去。

解决方案:也很简单,使用 useCallback ,依赖传入空数组,保证 useCallback 永远返回同一个函数。

上面呢,算是这个文章的一个契机吧。

关于手写防抖和节流的思路,个人认为关键在于都是对 闭包高阶函数 的应用,以这个为切入点去思考,手写的时候就不会脑子一片空白了。

防抖(debounce)

触发事件后在 n 秒内函数只能执行一次,如果在 n 秒内又触发了事件,则会重新计算函数执行时间。

初步
import { useCallback } from 'react';
/**
 * 防抖hook
 * @param func 需要执行的函数
 * @param wait 延迟时间
 */
export function useDebounce<A extends Array<any>, R = void>(
  func: (..._args: A) => R,
  wait: number,
) {
  let timeOut: null | NodeJS.Timeout = null;
  function debounced(..._args: A) {
    if (timeOut) {
      clearTimeout(timeOut);
      timeOut = null;
    }
    timeOut = setTimeout(() => {
      fn.apply(null, _args);
    }, wait);
  }
  return useCallback(debounced, []);
}

这可以用,但并不够好。想要进阶更高级的工程师,就需要将问题再想深一层,考虑到更复杂的情况,从而自身得到成长。

进阶版
  1. 首先想到的是要返回一个 Promise ,用来传递返回值。
  2. 其次考虑到异步的情况,增加 async。
  3. 最后是防抖化之后是否可以立即执行和取消,所以增加2个新函数。
import { useCallback } from 'react';
/**
 * 防抖hook
 * @param func 需要执行的函数
 * @param wait 延迟时间
 */
export function useDebounce<A extends Array<any>, R = void>(
  func: (..._args: A) => R,
  wait: number,
) {
  let timeOut: null | NodeJS.Timeout = null;
  let args: A;
  function debounce(..._args: A) {
    args = _args;
    if (timeOut) {
      clearTimeout(timeOut);
      timeOut = null;
    }
    return new Promise<R>((resolve, reject) => {
      timeOut = setTimeout(async () => {
        try {
          const result = await func.apply(null, args);
          resolve(result);
        } catch (e) {
          reject(e);
        }
      }, wait);
    });
  }
  //取消
  function cancel() {
    if (!timeOut) return;
    clearTimeout(timeOut);
    timeOut = null;
  }
  //立即执行
  function flush() {
    cancel();
    return func.apply(null, args);
  }
  debounce.flush = flush;
  debounce.cancel = flush;
  return useCallback(debounce, []);
}

关于防抖函数还有功能更丰富的版本,可以看下 lodashdebounce 函数

节流(throttle)

连续触发事件但是在 n 秒中只执行一次函数

节流函数的2种思路
  • 时间戳:通过记录上次执行的时间戳, 和当前时间戳比较来判断是否已到执行时间 ,如果是则执行,并更新上次执行的时间戳。(问题在于:事件停止触发时无法执行函数)

  • 定时器:如果已经存在定时器,则不执行方法,直到定时器触发后被清除,然后重新设置定时器。(问题在于:事件停止触发后必然会再执行函数)

整合版

把两个整合一下,根据场景、需求等来决定,最后是否需要事件停止触发后定时器执行函数。

/**
 * 节流hook
 * @param func 需要执行的函数
 * @param wait 延迟时间
 * @param isTimer 是否开启定时器响应事件结束后的回调
 */
export function useThrottle<A extends Array<any>, R = void>(
  func: (..._args: A) => R,
  wait: number,
  isTimer: boolean = false,
) {
  let timeOut: null | NodeJS.Timeout = null;
  let args: A;
  let agoTimestamp: number;
  function throttle(..._args: A) {
    args = _args;
    if (!agoTimestamp) agoTimestamp = +new Date();
    if (timeOut) {
      clearTimeout(timeOut);
      timeOut = null;
    }
    return new Promise<R>((resolve, reject) => {
      if (+new Date() - agoTimestamp >= wait) {
        try {
          const result = func.apply(null, args);
          resolve(result);
          agoTimestamp = +new Date();
        } catch (e) {
          reject(e);
        }
      } else if (isTimer) {
        timeOut = setTimeout(async () => {
          try {
            const result = await func.apply(null, args);
            resolve(result);
            agoTimestamp = +new Date();
          } catch (e) {
            reject(e);
          }
        }, agoTimestamp + wait - +new Date());
      }
    });
  }
  //取消
  function cancel() {
    if (!timeOut) return;
    clearTimeout(timeOut);
    timeOut = null;
  }
  //立即执行
  function flush() {
    cancel();
    return func.apply(null, args);
  }
  throttle.flush = flush;
  throttle.cancel = flush;
  return useCallback(throttle, []);
}

最后

有个地方有人可能有疑问,为什么没去用 useRef 去保存 timeOut 呢?

有人可能会认为这会有问题:因为每次组件重新渲染,都会执行一遍所有的 hooks,这样 useDebounce 高阶函数里面的 timeOut 就不能起到缓存的作用(在 useDebounce 里 console.log(timeOut),每次 render 时都打印出 null)。所以 timeOut 不可靠,防抖的核心就被破坏了。

但是呢,如果你在里面的函数 debounce 里 console.log(timeOut) 会发现,打印出来的,就是之前的 timeOut ,所以是没问题的。

最后,写的过程中,ts 才是我真正花费时间思考的地方。完成后,有点微妙的满足感。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):