2026年7月31日
React useThrottle Hook:节流值与回调(2026)
scroll 事件的触发频率全看合成器心情——通常一秒 60 次,有时 120 次。mousemove 更夸张。把它们直接灌进 setState,滚轮每动一下你的组件树就以帧率重渲染一遍;灌进埋点上报或网络请求,你就亲手对自己的后端发起了一次小型 DDoS。防抖(debounce)在这里是错误的药方:防抖后的滚动处理器要等滚动停下来才执行,于是阅读进度条在滚动途中直接冻住,到头了才猛地跳一下。你真正想要的是一个稳定节奏——事件持续到来时,每 N 毫秒至多执行一次。这就是节流(throttle)。
@reactuses/core 的 useThrottle 和 useThrottleFn 就是这个节奏的 hook 形态——一个管值,一个管回调——底层是久经沙场的 lodash throttle,外面包了一层,把 React 里两种经典翻车方式(闭包陈旧、计时器比组件活得久)直接封死。这篇文章会走读真实实现、leading/trailing 两个旋钮、cancel/flush 两个逃生舱,以及一个被测试套件钉死的挂载时机细节。TypeScript 优先。
useThrottle —— 节流一个值
useThrottle 接收一个变化太快的值,返回一份以文明速度更新的副本:
import { useState } from 'react';
import { useThrottle } from '@reactuses/core';
function MarkdownEditor() {
const [source, setSource] = useState('');
const throttledSource = useThrottle(source, 500);
return (
<div className="editor">
<textarea value={source} onChange={e => setSource(e.target.value)} />
{/* 即使全速打字,每秒也至多重新解析两次 */}
<Preview markdown={throttledSource} />
</div>
);
}
输入框保持完全跟手——source 每次击键都更新。被节流的只有昂贵的消费方:throttledSource 在第一次变化时立即更新,之后只要变化持续到来就每 500 ms 至多更新一次,停止后落在最终值上。对比这个编辑器的防抖版本:打字期间预览会一脸茫然地空着,只在停顿的间隙追上来。节流让它保持活着,只是刷新率低一点。
签名如下:
function useThrottle<T>(value: T, wait?: number, options?: ThrottleSettings): T;
ThrottleSettings 就是 lodash 的——{ leading?: boolean; trailing?: boolean }——下文细说。
useThrottleFn —— 节流一个回调
当需要减速的是一个函数而不是值时,useThrottleFn 把它包起来并交还一组控制器:
import { useState } from 'react';
import { useThrottleFn, useEventListener } from '@reactuses/core';
function ScrollSpy({ sectionIds }: { sectionIds: string[] }) {
const [active, setActive] = useState(sectionIds[0]);
const { run } = useThrottleFn(() => {
setActive(computeActiveSection(sectionIds, window.scrollY));
}, 200);
useEventListener('scroll', run);
return <TableOfContents ids={sectionIds} active={active} />;
}
滚动事件以帧率触发;computeActiveSection 一秒只跑五次。返回值是包含三个函数的对象:
const { run, cancel, flush } = useThrottleFn(fn, wait, options);
run(...args)—— 节流后的函数。参数与fn相同,返回fn的结果(调用被抑制时返回最近一次的结果——标准 lodash 语义)。cancel()—— 丢弃挂起的 trailing 调用。一个清空界面的「重置」按钮,不应该在 200 ms 后被上一次滚动事件的幽灵更新反杀;交互被放弃时就调cancel()。flush()—— 反过来:不等窗口关闭,挂起的调用现在就执行。经典用法:节流的自动保存 + 「提交」时flush(),让最终状态在跳转前落库,而不是 2 秒之后。
这些不是摆设——库的测试套件用假计时器驱动了一整条 run/cancel/flush 时间线,钉死了每一个中间计数:leading 调用同步触发、被抑制的调用坍缩成一次携带最新参数的 trailing 调用、cancel() 真的会丢弃挂起调用、flush() 真的会提前执行它。
源码解析:lodash 加两处修补
实现短到一杯咖啡就能读完:
export function useThrottleFn<T extends (...args: any) => any>(
fn: T, wait?: number, options?: ThrottleSettings,
) {
const fnRef = useLatest(fn);
const throttled = useMemo(
() =>
throttle(
(...args: [...Parameters<T>]): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[wait, JSON.stringify(options)],
);
useUnmount(() => {
throttled.cancel();
});
return { run: throttled, cancel: throttled.cancel, flush: throttled.flush };
}
节流引擎是 lodash-es 的 throttle——有十年生产里程的计时逻辑,不是手搓的 setTimeout 杂技。这个 hook 补上的,恰好是你自己在组件里调 lodash.throttle 时必然踩的两个坑:
- 没有闭包陈旧。 朴素写法
useMemo(() => throttle(fn, wait), [])会把首次渲染的fn——连同首次渲染的 props 和 state——冻结整个组件生命周期。这里 memo 住的 throttle 调用的是fnRef.current,一个由useLatest维护、每次渲染都指向最新fn的 ref。计时状态住在一个稳定的 throttle 实例里;它调用的代码永远是最新的。 - 没有比组件命长的计时器。
useUnmount会调用throttled.cancel(),挂起的 trailing 调用不可能打进一个已卸载的组件。测试套件断言卸载后计时器数量就是零。
依赖数组里有个小彩蛋:JSON.stringify(options)。你可以内联传 { trailing: false }——每次渲染都是新对象——而不会重建 throttle 实例,因为 memo 按内容而非引用比较 options。而 useThrottle 本身就是这个 hook 对准 state 的产物——useThrottleFn(() => setThrottled(value), wait, options) 加一个在 value 变化时调 run() 的 effect。一个计时引擎,两种形态。
调参:leading 与 trailing
两个边缘都默认 true,这也是你通常想要的行为——首次响应即时、最终值不丢:
useThrottle(value, 500); // 立即触发,之后每 ≤500ms 一次,最后落在终值
useThrottle(value, 500, { leading: false }); // 跳过即时的首次更新
useThrottle(value, 500, { trailing: false }); // 跳过落到终值的收尾更新
leading: false把首次调用推迟到窗口结束。适合突发事件流里第一个事件本身没有意义的场景——比如上报「用户正在滚动」的埋点,你不希望单独一格滚轮就触发。trailing: false表示窗口中途的调用直接丢弃而非延后。对连续数据流没问题,反正下个窗口会带来新读数;但凡最后一个值重要就不行(你的进度条会停在差一点到 100% 的地方)。- 两个都
false是 lodash 的陷阱——函数只能在没有窗口打开时被调用才会执行,对稳定事件流来说约等于永远不会。别这么干。
挂载陷阱——第一次变化可能要等
这是 useThrottle(值版本)值得知道的细节。它内部在挂载时的 effect 里调用了 run()——那次 leading 调用只是把初始值重新 set 了一遍,肉眼不可见。但它同时打开了节流窗口。后果是:挂载后 wait 毫秒内到来的值变化不会立即更新,leading 也救不了——它处在窗口中间,只能等 trailing 边缘。测试套件写得明明白白:
const { result, rerender } = renderHook(props => useThrottle(props, 100), {
initialProps: 0,
});
rerender(1); // 挂载后立刻变化
jest.advanceTimersByTime(50);
expect(result.current).toBe(0); // 还是旧值——推迟到 t=100
第一个窗口过期之后,落在空档里的变化会拿到自己的 leading 边缘、立即显示。所以稳态下 useThrottle 的手感是先即时后节流,与宣传完全一致——但如果组件挂载和值变化几乎同时发生(hydration 交接、挂载即返回的请求结果),第一次变化最多会迟到 wait 毫秒。在意的话,要么调小 wait,要么改用 useThrottleFn 去节流源头而不是值。
节流还是防抖?
三十秒速览,毕竟它们是同一根谱系的两端:
- 防抖 = 「等待安静」。事件停止
wait毫秒之前什么都不发生。适合边打边搜、自动保存、resize 结束后的布局计算——只有最终状态重要的场景。对应useDebounce/useDebounceFn。 - 节流 = 「稳定心跳」。在活动进行中以限定频率执行。适合滚动位置、鼠标跟踪、拖拽反馈、进度上报——用户需要在过程中看到反馈的场景。
判别口诀:如果功能的防抖版本在交互过程中给人冻住的感觉,你要的是节流。两个 hook 并排的完整决策指南见《React 中的 Debounce vs Throttle》。
限频家族
useDebounce/useDebounceFn—— 同样的值/回调二人组,「等待安静」的计时策略,同一个 lodash 内核、同样的闭包与卸载修补。useRafFn—— 按显示器的节奏而非毫秒预算节流:每个动画帧执行一次回调。给渲染供数据的工作(元素高亮、canvas 绘制),一帧一次胜过任何手挑的wait。useRafState—— setter 在下一帧才提交的useState;一帧内多次高频 set 坍缩成一次渲染。治mousemove驱动状态的最轻量方案。useScroll与useMouse—— 位置追踪 hooks,通常出现在节流的输入端。
SSR 安全
useThrottleFn 在渲染期创建 lodash throttle,但创建不启动任何计时器——计时器在 run() 被调用时才启动,而所有调用点都在 effect 或事件处理器里,服务端渲染期间永远不会执行。服务端不碰 window、不碰 document、不碰时钟:你的 Next.js / Remix 构建渲染初始值、干净地完成 hydration,节流在客户端接管后苏醒。与 @reactuses/core 的所有 hook 一样,SSR 安全是构造使然。
要点回顾
- 节流是节奏,防抖是等待。 用户在交互过程中盯着看的东西——滚动、拖拽、鼠标、实时预览——用
useThrottle/useThrottleFn,别用防抖。 - 值和回调是同一引擎的两种形态:
useThrottle字面上就是对准setState的useThrottleFn。 - 这层封装赚回了两次门票:
useLatest杀死闭包陈旧,useUnmount取消挂起计时器——每个手搓 lodash.throttle-in-React 迟早都会上线的两个 bug。 cancel()和flush()是逃生舱——交互被放弃时丢弃挂起调用,用户提交时强制执行。- 留意挂载窗口:挂载后
wait毫秒内的值变化要等 trailing 边缘——测试套件验证过,不是感觉。 - SSR 安全,零配置——客户端接管之前,没有计时器、没有浏览器全局对象。
装上 @reactuses/core,把 useThrottle 对准你最吵的那个值,让渲染循环拥有脉搏,而不是抽搐。