跳到主要内容

JS 节流与防抖(附 React Hooks)

· 阅读需 10 分钟

在搜索框里敲「react」:r、re、rea、reac、react——如果每次敲键都发一次请求,一次输入就白白浪费 4 次网络请求,而且前 4 次的结果你根本来不及看,它们已经在回来的路上了。再往下想:滚动页面时 onScroll 一秒钟触发几十次,resize 拖拽时更是每帧一次。

这类问题有一个共同的名字:事件风暴——短时间内触发太多次,而真正需要执行的只有少数几次。

一、回答两个问题

事件风暴的本质是:触发频率远高于执行需求。那么「到底该执行哪几次、什么时候执行」,其实只取决于你对这件事的期望。把期望问清楚,答案自己就出来了。

问题 1:我在乎的是「最后的结果」吗?

输入搜索、窗口 resize、表单项校验……这些场景里,过程不重要,结果才重要。你敲了一长串,真正需要的只是「停下来之后」那一次的搜索结果;窗口拖了一路,真正需要的只是「停住之后」那一次的布局重算。

防抖的定义用一句话说:持续触发时一律不执行,直到停止触发后过一段时间,才执行最后一次。

一个贴切的比喻是电梯关门:有人进出,电梯就重新计时关门,等人流彻底安静了,才关门上楼。你不在乎期间谁进进出出,只在乎「最后走的那一刻」。

问题 2:需要在过程中「持续跟进」,但不能太频繁吗?

滚动加载更多、滚动进度条、拖拽坐标跟随、游戏里的连点……这些场景里,过程是有价值的——滚到一半就该触发加载,进度条该跟着走。但你不能让每一次 scroll 都触发,那太浪费了。

节流的定义用一句话说:保证一定时间间隔内最多执行一次,但会持续执行,直到结束。

贴切的比喻是班车时刻表:每 10 分钟一班,不管站台多少人、什么时候来人,到点就发。你保证的是「规律性」——不会因为人挤就狂发车,也不会因为没人就永远不发。

心智模型:

防抖(debounce)节流(throttle)
模型电梯等人,人静才关门班车到点发车
关心的事最后一次的结果过程中的规律性
触发密集时全部取消,只等最后固定节奏放行

二、防抖

防抖的核心:新来的调用,作废旧的计时。 翻译成代码:

function debounce(fn, wait = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer); // ① 取消上一个还没生效的定时器
timer = setTimeout(() => fn(...args), wait); // ② 重新计时
};
}

逐行看为什么必须这么写:

  • 为什么要返回一个新函数、用闭包存 timer 因为每次触发都要「看到」同一个 timer 才能取消它。timer 藏在闭包里,新函数每次调用共享这一个变量。
  • 为什么先 clearTimeoutsetTimeout 这一句就是防抖的全部秘密。时间线:t=0 调用 → 定 300ms → t=100ms 又调用 → 取消 t=0 那次、重排到 t=400ms → 只要你一直在敲,它就永远在重排、永远不执行 → 你一停,300ms 后执行最后一次。

这个「重置」动作,就是前面说的电梯重新计时

补一个坑:this 会丢

如果直接用 () => fn(...args)this 就丢了。当防抖函数被用在事件监听里(el.addEventListener('click', debounced)),监听器内部的 this 是那个元素;但 setTimeout 回调里的 this 不是它。所以要先记住:

function debounce(fn, wait = 300) {
let timer = null;
return function (...args) {
const context = this; // 记住调用时的 this
clearTimeout(timer);
timer = setTimeout(() => fn.apply(context, args), wait);
};
}

到这里,一个「停下来才执行」的防抖就好了——这是**后缘(trailing)**版本,也是输入搜索最常用的形态。

进一步:有时候第一次就该立即执行

有个反例:防连点。用户快速双击「提交」按钮,你希望第一次点击就立即执行,后面的点击才被防抖掉。如果只用上面的 trailing 版本,第一次点击也会被延迟 300ms,体验很差。

于是防抖又有了 前缘(leading):一个周期开始先立即执行一次,之后进入等待。

function debounce(fn, wait = 300, { leading = false } = {}) {
let timer = null;
return function (...args) {
const context = this;
const isIdle = timer === null; // 当前没有在等待 = 一个周期的开始
clearTimeout(timer);
timer = setTimeout(() => {
timer = null; // 等待结束,回到空闲
if (!leading) fn.apply(context, args); // trailing 模式在结束时补执行
}, wait);
if (leading && isIdle) fn.apply(context, args); // leading 模式:空闲时立即执行
};
}

注意这里的取舍:leading 模式第一次立即执行、之后防抖;trailing 模式全部延迟、只在安静后执行最后一次。两者通常二选一。

三、从「闸门」长成节流

节流的核心:记上次执行的时间,间隔不够就拦下。

function throttle(fn, wait = 200) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= wait) { // 距上次执行够久了 → 放行
lastTime = now;
fn.apply(this, args);
}
};
}

这就是时间戳闸门:第一次调用时 lastTime=0now 是很大的毫秒时间戳,now - 0 远超 wait,直接放行并记录;之后 wait 毫秒内的调用全被 if 拦下;时间一到,第一次撞上来的调用放行。

它有个优点:首次立即执行,绝不空等。但也有个隐藏的坑:被拦在闸门内的最后一次调用,被永久丢弃了。

场景很真实:滚动到底部触发「加载更多」,如果最后一次 scroll 恰好滚到了底,却撞在闸门内被丢掉,加载就永远不触发。所以你还需要一个兜底——闸门放行的同时,给「卡在门里」的调用排一个定时器,等间隔到了补执行最后一次。

function throttle(fn, wait = 200, { leading = true, trailing = true } = {}) {
let lastTime = 0;
let timer = null;
let lastArgs, lastCtx;

const invoke = () => {
lastTime = Date.now();
timer = null;
fn.apply(lastCtx, lastArgs);
};

return function (...args) {
lastCtx = this;
lastArgs = args;
const now = Date.now();
const remaining = wait - (now - lastTime);

if (remaining <= 0) { // 间隔已到 → 立即放行(leading)
clearTimeout(timer);
invoke();
} else if (!timer) { // 还没到 → 排定时器,兜住最后一次(trailing)
timer = setTimeout(invoke, remaining);
}
};
}

于是节流从「时间戳闸门」生长成了「闸门 + 定时器兜底」:闸门保证节奏(leading),定时器保证不丢最后一次(trailing)。这已经是生产级的形态了。

四、leading / trailing:把两个概念统一到一个视角

看到这里你会发现,防抖和节流的完整版都有 leading / trailing 两个开关——它们其实是同一对「前后缘」概念:

leading(前缘)trailing(后缘)
防抖周期开始立即执行一次,之后防抖周期结束(安静后)执行最后一次
节流间隔到点立即执行结束时补执行被拦住的最后一次
一句话「先做再说」「做完收尾」

配上场景,选择就顺理成章了:

场景选型原因
输入框实时搜索防抖(trailing)只关心停下来的最终结果
按钮防连点防抖(leading)第一次要立刻响应
滚动加载更多 / 滚动进度节流(leading + trailing)过程中要触发,且不能丢最后
resize 重算布局防抖等 resize 停下来再做
mousemove 坐标跟随节流跟住但不每帧执行

一个补充:如果你要的是「每帧执行一次」的视觉类工作(动画、拖拽预览),那其实还有第三个更专门的工具——requestAnimationFrame。它本质上是「以浏览器帧率为间隔的节流」,浏览器会帮你合并到帧回调里,视觉场景下优先用它。

五、React 自定义 Hook

在 React 里,上面的闭包逻辑要套上 hook 的「生命周期」才能用对——尤其注意两点:回调要始终拿到最新闭包(用 ref 兜底),卸载时要清理定时器(避免 setState 在卸载后执行)。

值防抖:useDebounce(输入框最佳搭档)

最常用的形态是「防抖一个值」——输入内容进 state,经过防抖的值才去发请求:

import { useEffect, useState } from 'react';

function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value);

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // 依赖变化 → 取消上一次计时 = 电梯重新关门
}, [value, delay]);

return debounced;
}

用法——搜索框只跟着防抖后的值发请求:

function SearchBox() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);

useEffect(() => {
if (debouncedKeyword) fetchSearch(debouncedKeyword);
}, [debouncedKeyword]);

return <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />;
}

回调防抖:useDebouncedCallback

把「动作」本身防抖,返回一个稳定引用(useCallback 缓存),不会让子组件因每次渲染拿到新函数而重新渲染:

import { useCallback, useEffect, useRef } from 'react';

function useDebouncedCallback<T extends (...args: any[]) => void>(
callback: T,
delay = 300,
) {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout>>();

useEffect(() => {
callbackRef.current = callback; // 始终拿到最新闭包
});

useEffect(() => () => clearTimeout(timerRef.current), []); // 卸载清理

return useCallback(
(...args: Parameters<T>) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
},
[delay],
);
}

回调节流:useThrottledCallback(首次立即 + 末尾兜底)

把「闸门 + 定时器兜底」完整封装:

function useThrottledCallback<T extends (...args: any[]) => void>(
callback: T,
delay = 200,
) {
const callbackRef = useRef(callback);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const lastTimeRef = useRef(0);
const lastArgsRef = useRef<Parameters<T>>();

useEffect(() => {
callbackRef.current = callback;
});
useEffect(() => () => clearTimeout(timerRef.current), []);

return useCallback(
(...args: Parameters<T>) => {
lastArgsRef.current = args;
const now = Date.now();
const remaining = delay - (now - lastTimeRef.current);

if (remaining <= 0) {
// 间隔已到 → 立即执行,并取消待决的定时器
clearTimeout(timerRef.current);
timerRef.current = undefined;
lastTimeRef.current = now;
callbackRef.current(...args);
} else if (!timerRef.current) {
// 还没到 → 排定时器,兜住最后一次
timerRef.current = setTimeout(() => {
timerRef.current = undefined;
lastTimeRef.current = Date.now();
callbackRef.current(...lastArgsRef.current!);
}, remaining);
}
},
[delay],
);
}

用法——滚动加载更多,节流 + 不丢最后一次:

const loadMore = useThrottledCallback(() => fetchNextPage(), 200);
useEffect(() => {
window.addEventListener('scroll', loadMore);
return () => window.removeEventListener('scroll', loadMore);
}, [loadMore]);

六、总结

回到开头的问题。节流和防抖不是两个需要背的公式,而是你对同一件事的两个追问:

  • 「我在乎最后的结果吗?」 → 防抖。种子是「重置」——新调用作废旧计时,电梯等人静才关门。
  • 「我需要在过程中跟住吗?」 → 节流。种子是「闸门」——间隔不到就拦下,班车到点才发。

两个概念到完整形态后,会汇聚到同一对开关上:leading(先做再说)和 trailing(做完收尾)。理解了这对开关,你就能按场景自由选择,而不是背模板。