从零到一写出lodash的debounce和throttle(ts版)

426 阅读10分钟

防抖(debounce)和节流(throttle)是Web开发中常用的两种技术,用于控制函数的执行频率。尽管它们的概念广为人知,但我在阅读lodash库中debounce函数的源码时,发现了一些值得注意的细节:

  • lodashdebounce函数在基本实现之上增添了额外的功能,它的实现思路也别具一格
  • 有趣的是,节流(throttle)可以视为防抖的一种特殊形式(在lodash中,throttle正是通过debounce来实现的)

本文将分享如何从零开始构建lodash风格的debouncethrottle函数。我们将从最简单的版本开始,通过图解示例逐步深入,直至覆盖lodash提供的全部特性;在掌握了防抖的原理后,我们还将探讨它与节流的关系,并展示如何基于防抖实现节流功能

Debounce 防抖

Debounce 技术允许我们将多个顺序调用“分组”在一个调用中:当函数调用间隔在 wait 之内时,可被视为同一组,只会执行一次函数

实现简易 debounce 函数

我们先来看下如何实现一个基础版的防抖函数,然后再介绍lodash的不同实现思路

简易 debounce - 只实现 trailing 功能

trailing (尾部)版防抖的定义

以 wait 间隔结束点作为函数调用计时点,是我们平时用的最多的场景

效果说明(图片来源:https://css-tricks.com/debouncing-throttling-explained-examples/)

实现思路与代码示例

它的思路很简单,就是每次调用都清除上一次的定时器,并设置一个新的定时器,时间到了后就执行函数

//防抖-代码示例
export const debounce = <T extends (...args: any[]) => any>(func: T, wait: number) => {
  let timerId: ReturnType<typeof setTimeout> | null; 
  let result: ReturnType<T>; // 调用函数的结果

  const debounced = (...args: Parameters<T>) => {
    if (timerId) {
      //  每次触发 都清除当前timer,重新设置时间
      clearTimeout(timerId);
    }
    timerId = setTimeout(() => {
      result = func.apply(this, args);
    }, wait);

    return result;
  };

  return debounced;
};
图解说明

先说明只调用一次方法的的情景:在某个时刻调用 debounce,那么将在 wait 时间后才会真正触发 func 函数

防抖示例:方法只被调用1次

  • 当方法调用间隔 < wait 时,每次的调用都会清除上一次的定时器,最终只执行一次方法

防抖示例:方法在wait间隔内被调用4次

  • 当方法调用间隔 > wait 时,它们就可视为不同的组,每组会执行一次方法

防抖示例:方法超过wait间隔进行调用

简易 debounce - 只实现 leading 功能

leading(首位)版防抖的定义

leading(首位)版防抖:以 wait 间隔开始点作为函数调用计时点

“领先”防抖的效果说明(图片来源:https://css-tricks.com/debouncing-throttling-explained-examples/)

实现思路与代码示例

每次调用,都清除上一次的定时器,并设置一个新的定时器,时间到了后设置 timerIdnull

//领先防抖-代码示例
export const debounce = <T extends (...args: any[]) => any>(func: T, wait: number) => {
  let timerId: ReturnType<typeof setTimeout> | null;
  let result: ReturnType<T>; // 调用函数的结果

  const debounced = (...args: Parameters<T>) => {
    if (timerId) {
      //  每次触发 都清除当前timer,重新设置时间
      clearTimeout(timerId);
    }

    if (!timerId) {
      result = func.apply(this, args);
    }

    timerId = setTimeout(() => {
      timerId = null;
    }, wait);

    return result;
  };

  return debounced;
};
图解说明
  • 当方法首次被调用时,此时timerId为空,会执行一次方法 func,并新增一个定时器

“领先”防抖示例:方法只被调用1次

  • 当方法调用间隔 < wait 时,只执行一次方法

“领先”防抖示例:方法在wait间隔内被调用4次

  • 当方法调用间隔 > wait 时,每组执行一次方法

“领先”防抖示例:方法超过wait间隔进行调用

无论是以上哪一种实现,都一直在做清除和创建定时器的工作,而lodash有不一样的思路

引入 lodash 的 debounce 思路

只创建必要的定时器,到达时间节点时再判断下一步做什么

当用户第一次调用方法时,我们增加了一个定时器;这意味着实际调用方法的时间一定在该定时器之后;当到达定时器的时间时,此时需要判断实际要调用方法还是要继续后移,判断条件如下

  • 当前时间 - 上次调用 debounce 的时间 >= wait,调用实际方法 func

image.png

  • 当前时间 - 上次调用 debounce 的时间 < wait,表明还不能调用方法、需要新增定时器,设置延时时间为 wait - (当前时间 - 上次调用时间)

image.png

下图是与简易debounce的思路对比,可以看到lodash省略掉了许多不必要的定时器;它不是在每次调用 debounce 方法时进行判断,而是在到达时间节点时再判断

image.png

实现 debounce 核心

根据 debounce 思路,那代码逻辑就是:记录下一个需要判断的时间节点,在该时间节点到来后,判断是执行函数还是设定下一个时间点

以下为对应代码,其实也就是 lodashdebounce 核心思路,现在读起来应该已经很轻松了

import { now } from 'lodash';

// 思路:定义下个时间点的判断,在下个时间点到来时进行判断是否需要执行函数
// 防抖
export const debounce = <T extends (...args: any[]) => any>(func: T, wait: number): T => {
  let timerId: ReturnType<typeof setTimeout> | null;
  let result: any; // 调用函数的结果
  let lastCallTime: number | undefined; // 外部函数的上次调用时间
  let lastArgs: any[] | null = null; // 调用时的参数

  // 2. 判断是执行函数还是继续推迟执行
  const timerExpired = () => {
    const time = now();
    // 如果可以执行函数
    if (shouldInvoke(time)) {
      return trailingEdge();
    }
    // 确定下一个判断的时间节点
    timerId = setTimeout(timerExpired, remainingWait(time));
  };

  const shouldInvoke = (time: number) => {
    const timeSinceLastCall = time - lastCallTime!;
    const isWaitEnoughTime = timeSinceLastCall >= wait;
    return isWaitEnoughTime;
  };

  // 定时器结束时的调用
  const trailingEdge = () => {
    // 完成一轮延迟,可以继续下一轮
    timerId = null;
    if (lastArgs) {
      return invokeFunc();
    }
    lastArgs = null;
    return result;
  };

  // 实际执行函数
  const invokeFunc = () => {
    const args = lastArgs || [];
    lastArgs = null;
    // 在实际上下文中调用函数
    const result = func.apply(this, args);
    return result;
  };

  const remainingWait = (time: number) => {
    const timeSinceLastCall = time - lastCallTime!;
    // 距离下一个时间点的时间差
    return wait - timeSinceLastCall;
  };

  // 1. 主流程:时间轴位置的确定
  const debounced = (...args: Parameters<T>) => {
    const time = now();
    // 记录调用时间
    lastCallTime = time;
    lastArgs = args;

    if (!timerId) {
      timerId = setTimeout(timerExpired, wait);
    }
    return result;
  };

  return debounced as T;
};

丰富 debounce 特性

支持 leading 功能

leading 是以 wait 间隔开始点作为函数调用计时点,它实际只是与 trailling 的调用计时点不同,放置定时器的逻辑是一致的,所以通过在核心代码中新增执行时机的设置功能,就可以实现 leading

我们也新增一个 options 参数来接收用户的设置,同时提供默认值:仅启用 trailing

import { now } from 'lodash';

interface IDebounceOptions {
  leading?: boolean;
  trailing?: boolean;
}

// 思路:定义下个时间点的判断,在下个时间点到来时进行判断是否需要执行函数
// 防抖
export const debounce = <T extends (...args: any[]) => any>(func: T, wait: number, options: IDebounceOptions): T => {
  let timerId: ReturnType<typeof setTimeout> | null;
  let result: any; // 调用函数的结果
  let lastCallTime: number | undefined; // 外部函数的上次调用时间
  let lastArgs: any[] | null = null; // 调用时的参数

  // 传递进的options
  // 默认仅启用 trailing
  const { leading = false, trailing = true } = options;

  ...

  const shouldInvoke = (time: number) => {
    const timeSinceLastCall = time - lastCallTime!;
+    const isFirstToCall = lastCallTime === undefined; // 判断是否为初次执行时
    const isWaitEnoughTime = timeSinceLastCall >= wait;
    return isFirstToCall || isWaitEnoughTime;
  };

+  // 初次执行时的调用
+  const leadingEdge = () => {
+    // 重新计算下一次的判断时间
+    timerId = setTimeout(timerExpired, wait);
+    return leading ? invokeFunc() : result;
+  };

 // 定时器结束时的调用
  const trailingEdge = () => {
    // 完成一轮延迟,可以继续下一轮
    timerId = null;
+    if (trailing && lastArgs) { // 使用 trailing 参数判断是否需要调用方法
      return invokeFunc();
    }
    lastArgs = null;
    return result;
  };
   ...

  const debounced = (...args: Parameters<T>) => {
    const time = now();
    lastCallTime = time;
    lastArgs = args;

+    const isInVoking = shouldInvoke(time);
+    if (isInVoking) {
+      if (!timerId) {
+        return leadingEdge();
+      }
+    }

    if (!timerId) {
      timerId = setTimeout(timerExpired, wait);
    }
    return result;
  };

  return debounced as T;
};

支持 maxWait 特性

该特性用于保证在 maxWait 间隔内至少执行 1 次,以下是这特性说明:

  • 如果没设置 maxWait,那么对于尾部防抖,当debounce一直被频繁调用时,理论上来说就没法调用到真实的 func

没设置 maxWait 时:频繁调用导致func一直没被调用

  • 而如果设置了 maxWait,那无论调用多么频繁,都可以保证在 maxWait 间隔内至少执行 1 次

设置 maxWait 后:保证在 maxWait 间隔内至少执行 1 次

从图中可以看出,该特性影响的是到达时间节点时的判断逻辑

  • 判断是否能调用方法
- 是否可以调用方法 = 当前时间 - 上次调用 debounce 的时间 >= wait 
+ 是否可以调用方法 = 当前时间 - 上次调用 debounce 的时间 >= wait
+                   || 当前时间 - 上次实际调用 func 的时间 >= maxWait
  • 判断定时器后延时间
- 后延时间 = wait - (当前时间 - 上次调用 debounce 的时间)
+ 后延时间 = Math.min(wait - (当前时间 - 上次调用 debounce 的时间) , 
+            maxWait - (当前时间 - 上次实际调用 func 的时间))

将逻辑思路转为具体代码,调整如下:

import { now } from "lodash";

interface IDebounceOptions {
  leading?: boolean;
  trailing?: boolean;
  maxWait?: number;
}

interface IDebouncedFunc<T extends (...args: any[]) => any> {
  (...args: Parameters<T>): ReturnType<T>;
  cancel(): void;
  flush(): ReturnType<T> | undefined;
}

// 思路:定义下个时间点的判断,在下个时间点到来时进行判断是否需要执行函数
// 防抖
export const debounce = <T extends (...args: any[]) => any>(
  fn: T,
  wait: number,
  options: IDebounceOptions
): T => {
  ...
+  let lastInvokeTime = 0; // fn实际的上次调用时间
  ...

  // 传递进的options
  // 默认仅启用 trailing
  const { leading = false, trailing = true } = options;
+  const maxWait = options.maxWait ? Math.max(wait, options.maxWait) : undefined;
+  const maxing = !!maxWait; // 是否有延迟底线
 
 ...
 
  const shouldInvoke = (time: number) => {
    const timeSinceLastCall = time - lastCallTime!;
    const isFirstToCall = lastCallTime === undefined;
    const isWaitEnoughTime = timeSinceLastCall >= wait;
+    const isMaxWaitEnoughTime = maxing && time - lastInvokeTime >= maxWait;

    return (leading && isFirstToCall) || isWaitEnoughTime || isMaxWaitEnoughTime;
  };

  // 初次执行时的调用
  const leadingEdge = (time: number) => {
+     // 重置 maxWait 的计算时间 
+    lastInvokeTime = time;
    // 重新计算下一次的判断时间
    timerId = setTimeout(timerExpired, wait);
    return leading ? invokeFunc(time) : result;
  };


  // 实际执行函数
  const invokeFunc = (time: number) => {
    const args = lastArgs || [];
    lastArgs = null;
+    lastInvokeTime = time; // 记录上次调用 func 的时间
    // 在实际上下文中调用函数
    result = fn.apply(this, args);
    return result;
  };
  const remainingWait = (time: number) => {
    const timeSinceLastCall = time - lastCallTime!,
      timeWaiting = wait - timeSinceLastCall,
+      timeSinceLastInvoke = time - lastInvokeTime;

+    return maxing ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting;
  };

  const debounced = (...args: Parameters<T>) => {
    const time = now();
    lastCallTime = time;
    lastArgs = args;

    const isInVoking = shouldInvoke(time);
    if (isInVoking) {
      if (!timerId) {
        return leadingEdge(lastCallTime);
      }
+      // 边界情况的处理,保证在紧 loop 中能正常保持触发
+      if (maxing) {
+        timerId = setTimeout(timerExpired, wait);
+        return invokeFunc(lastCallTime);
+      }
    }

    if (!timerId) {
      timerId = setTimeout(timerExpired, wait);
    }
    return result;
  };

  return debounced as T;
};

支持 cancel / flush 方法

其中 cancel 用于取消要延迟执行的函数 func,代码如下:

   // 取消要延迟调用的函数 func
      const cancel = () => {
        if (timerId !== null) {
          clearTimeout(timerId);
        }
        lastInvokeTime = 0;
        lastArgs = null;
        timerId = null;
        lastCallTime = undefined;
      };

调用该方法,相当于移除了这一轮的定时器,重新开启一轮新的延迟周期

其中 flush 的代码如下:

 function flush() {
        return timerId === undefined ? result : trailingEdge(now());
      }

该方法会立马执行被延迟执行的函数 func(如果有等待执行的调用的话)

将这两个方法放到我们返回的包装函数 debounced 中,作为该函数的方法

import { now } from "lodash";

interface IDebounceOptions {
  leading?: boolean;
  trailing?: boolean;
  maxWait?: number;
}

interface IDebouncedFunc<T extends (...args: any[]) => any> {
  (...args: Parameters<T>): ReturnType<T>;
  cancel(): void;
  flush(): ReturnType<T> | undefined;
}

export const debounce = <T extends (...args: any[]) => any>(
  fn: T,
  wait: number,
  options: IDebounceOptions
): IDebouncedFunc<T> => {
  ...
    
  debounced.cancel = cancel;
  debounced.flush = flush;
  // 返回包装函数
  return debounced;
};

至此,我们已经实现了lodashdebounce函数的全部特性,接下来我们来探讨下lodash是如何巧妙地通过 debounce来实现节流功能的

Throttle 节流

throttle 技术也不允许函数在某间隔内被执行多次,它与 debounce 之间的主要区别是 throttle 保证定期执行函数,在特定间隔内至少执行一次。这效果,实际不就等同于使用了 maxWait debounce 嘛!

使用 debounce 实现 throttle

通过给 debouncemaxWait 提供默认值就可以实现 throttlelodash实际上也是这么做的

// 节流,防抖的一个特殊情况,是携带maxWait的防抖
export const throttle = <T extends (...args: any[]) => any>(
  fn: T,
  wait: number,
  options: IDebounceOptions
): IDebouncedFunc<T> => {
  const defaultOption: IDebounceOptions = { leading: true, trailing: true, maxWait: wait };
  return debounce(fn, wait, { ...defaultOption, ...options });
};

本文已介绍完lodashdebouncethrottle 的实现思路,最后附带一张 lodash 实现执行效果图,用来自测是否真的理解通透:

loadash 执行效果图(图片来源:https://segmentfault.com/a/1190000012102372)

参考文章:

两个闹钟,10 分钟教你写出 lodash 中的 debounce & throttle:通过闹钟实例的方式讲解了lodash的防抖、节流思路,这篇文章在我实际阅读源码时给我提供了很大帮助。其实这篇文章已经能够解释清楚源码原理,之所以我还自己写了一篇文章,一方面是为了巩固自己的知识,另外一方面是个人觉得闹钟示例还是有点复杂,特别是还因此引入了一些特创名词

通过示例解释防抖和节流:这篇文章解释了这两个函数的定义和使用场景,它最大的亮点是包含可交互的 demo,可以亲自动手来感受它们对函数执行频率的控制

手撕源码系列 —— lodash 的 debounce 与 throttle

聊聊lodash的debounce实现