防抖(debounce)和节流(throttle)是Web开发中常用的两种技术,用于控制函数的执行频率。尽管它们的概念广为人知,但我在阅读lodash库中debounce函数的源码时,发现了一些值得注意的细节:
lodash的debounce函数在基本实现之上增添了额外的功能,它的实现思路也别具一格- 有趣的是,节流(
throttle)可以视为防抖的一种特殊形式(在lodash中,throttle正是通过debounce来实现的)
本文将分享如何从零开始构建lodash风格的debounce和throttle函数。我们将从最简单的版本开始,通过图解示例逐步深入,直至覆盖lodash提供的全部特性;在掌握了防抖的原理后,我们还将探讨它与节流的关系,并展示如何基于防抖实现节流功能
Debounce 防抖
Debounce 技术允许我们将多个顺序调用“分组”在一个调用中:当函数调用间隔在 wait 之内时,可被视为同一组,只会执行一次函数
实现简易 debounce 函数
我们先来看下如何实现一个基础版的防抖函数,然后再介绍lodash的不同实现思路
简易 debounce - 只实现 trailing 功能
trailing (尾部)版防抖的定义
以 wait 间隔结束点作为函数调用计时点,是我们平时用的最多的场景
实现思路与代码示例
它的思路很简单,就是每次调用都清除上一次的定时器,并设置一个新的定时器,时间到了后就执行函数
//防抖-代码示例
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 函数
- 当方法调用间隔 < wait 时,每次的调用都会清除上一次的定时器,最终只执行一次方法
- 当方法调用间隔 > wait 时,它们就可视为不同的组,每组会执行一次方法
简易 debounce - 只实现 leading 功能
leading(首位)版防抖的定义
leading(首位)版防抖:以 wait 间隔开始点作为函数调用计时点
实现思路与代码示例
每次调用,都清除上一次的定时器,并设置一个新的定时器,时间到了后设置 timerId 为 null
//领先防抖-代码示例
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,并新增一个定时器
- 当方法调用间隔 < wait 时,只执行一次方法
- 当方法调用间隔 > wait 时,每组执行一次方法
无论是以上哪一种实现,都一直在做清除和创建定时器的工作,而lodash有不一样的思路
引入 lodash 的 debounce 思路
只创建必要的定时器,到达时间节点时再判断下一步做什么
当用户第一次调用方法时,我们增加了一个定时器;这意味着实际调用方法的时间一定在该定时器之后;当到达定时器的时间时,此时需要判断实际要调用方法还是要继续后移,判断条件如下
- 当前时间 - 上次调用
debounce的时间 >=wait,调用实际方法 func
- 当前时间 - 上次调用
debounce的时间 <wait,表明还不能调用方法、需要新增定时器,设置延时时间为wait - (当前时间 - 上次调用时间)
下图是与简易debounce的思路对比,可以看到lodash省略掉了许多不必要的定时器;它不是在每次调用 debounce 方法时进行判断,而是在到达时间节点时再判断
实现 debounce 核心
根据 debounce 思路,那代码逻辑就是:记录下一个需要判断的时间节点,在该时间节点到来后,判断是执行函数还是设定下一个时间点
以下为对应代码,其实也就是 lodash 的 debounce 核心思路,现在读起来应该已经很轻松了
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,那无论调用多么频繁,都可以保证在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;
};
至此,我们已经实现了lodash中debounce函数的全部特性,接下来我们来探讨下lodash是如何巧妙地通过 debounce来实现节流功能的
Throttle 节流
throttle 技术也不允许函数在某间隔内被执行多次,它与 debounce 之间的主要区别是 throttle 保证定期执行函数,在特定间隔内至少执行一次。这效果,实际不就等同于使用了 maxWait 的 debounce 嘛!
使用 debounce 实现 throttle
通过给 debounce 的 maxWait 提供默认值就可以实现 throttle,lodash实际上也是这么做的
// 节流,防抖的一个特殊情况,是携带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 });
};
本文已介绍完lodash的 debounce 和 throttle 的实现思路,最后附带一张 lodash 实现执行效果图,用来自测是否真的理解通透:
参考文章:
两个闹钟,10 分钟教你写出 lodash 中的 debounce & throttle:通过闹钟实例的方式讲解了lodash的防抖、节流思路,这篇文章在我实际阅读源码时给我提供了很大帮助。其实这篇文章已经能够解释清楚源码原理,之所以我还自己写了一篇文章,一方面是为了巩固自己的知识,另外一方面是个人觉得闹钟示例还是有点复杂,特别是还因此引入了一些特创名词
通过示例解释防抖和节流:这篇文章解释了这两个函数的定义和使用场景,它最大的亮点是包含可交互的 demo,可以亲自动手来感受它们对函数执行频率的控制