陌上人如玉
公子世无双

11.3 手写 useDebounce / useThrottle

11.3 手写 useDebounce / useThrottle

一句话:防抖(debounce)是「停止触发 N 毫秒后才执行」,节流(throttle)是「每 N 毫秒最多执行一次」;在 React 里封装成 Hook 的关键是「用 useRef 存定时器/时间戳」「清理函数里清掉」「用 useRef 保存最新回调」。

一、防抖与节流的区别

防抖(debounce):
  事件触发 → 重置计时 → 只有在「停止触发 N 毫秒」后才真正执行
  ┌─────────────────────────────────────┐
  │ 输入: ●  ●  ●    ● ●          ●     │
  │ 执行:                          ↑     │  只有最后一次触发后执行
  └─────────────────────────────────────┘

节流(throttle):
  事件触发 → 每 N 毫秒最多执行一次(首/尾执行可配置)
  ┌─────────────────────────────────────┐
  │ 输入: ●  ●  ●    ● ●          ●     │
  │ 执行: ↑    ↑    ↑     ↑              │  按固定间隔执行
  └─────────────────────────────────────┘
维度 防抖 节流
执行时机 「停止触发后」 「按固定间隔」
适合 搜索输入、表单校验、窗口 resize 后的重算 滚动监听、鼠标移动、按钮防连点
是否会「丢失最后一次」 ❌(一定会执行最后一次) ⚠️(取决于配置,可能丢)
典型等待时间 300~500ms 100~200ms

二、纯函数版(先理解原理)

// ✅ 基础防抖(纯函数版)
function debounce<T extends (...args: any[]) => void>(fn: T, delay = 300) {
  let timer: ReturnType<typeof setTimeout> | null = null;

  return function (this: unknown, ...args: Parameters<T>) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  } as T;
}
// ⚠️ 为什么在 React 里「直接用」会有问题?
function Search() {
  const [keyword, setKeyword] = useState('');

  // ❌ 每次渲染都会创建一个新的防抖函数
  const debouncedSearch = debounce((v: string) => search(v), 300);
  // → 上一轮渲染创建的定时器「没人清理」(内存泄漏)
  // → 每次输入调用的是「不同的防抖函数」→ 防抖失效(每次都立即执行)

  return <input onChange={(e) => { setKeyword(e.target.value); debouncedSearch(e.target.value); }} />;
}
⭐ 三个问题:
① 每次渲染新建函数 → 上一次的定时器泄漏
② 「同一个输入」调用的是不同实例 → 防抖完全失效
③ 组件卸载时定时器没清理 → 卸载后仍在执行

✅ 解法:用 Hook 封装 —— 用 useRef 保存「定时器」与「稳定的防抖函数」

三、useDebouncedValue(值版,最常用)

import { useEffect, useState } from 'react';

/**
 * 返回「防抖后的值」——value 停止变化 delay 毫秒后才更新
 */
export function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);        // ⭐ value 变化时清掉上一个定时器
  }, [value, delay]);

  return debounced;
}
// ✅ 使用:搜索输入
function Search() {
  const [keyword, setKeyword] = useState('');
  const debouncedKeyword = useDebouncedValue(keyword, 300);

  // ⭐ 只有防抖后的值变化才发请求(依赖 debouncedKeyword 而不是 keyword)
  const { data, isLoading } = useQuery({
    queryKey: ['search', debouncedKeyword],
    queryFn: () => api.search(debouncedKeyword),
    enabled: debouncedKeyword.length > 0,
  });

  const isStale = keyword !== debouncedKeyword;     // ⭐ 可用来显示「正在输入」

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      {isStale && <span>输入中…</span>}
      {isLoading ? <Skeleton /> : <List items={data ?? []} />}
    </>
  );
}
⭐ 这个实现为什么正确?
① `useEffect` 每次 `value` 变化都会「清理上一个定时器 + 建新的」
   → 天然实现了「重置计时」
② `delay` 变化时也会重新计时(合理)
③ 组件卸载时清理(不会在卸载后 setState)
④ `setDebounced` 引用稳定(React 保证),不需要放进依赖

⚠️ 局限:它只适合「延迟使用某个值」的场景。
   如果要「延迟执行一个副作用/请求」,用「函数版」更合适。

四、useDebouncedCallback(函数版,含保护)

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

interface DebouncedFn<Args extends unknown[]> {
  (...args: Args): void;
  /** 立即执行「待执行的调用」(如果有) */
  flush: () => void;
  /** 取消「待执行的调用」 */
  cancel: () => void;
}

/**
 * 返回一个「防抖后的稳定函数」——
 * 引用稳定(可以安全放进依赖/传给 memo 子组件),且总是调用「最新的 fn」
 */
export function useDebouncedCallback<Args extends unknown[]>(
  fn: (...args: Args) => void,
  delay = 300,
  options: { leading?: boolean } = {}
): DebouncedFn<Args> {
  const fnRef = useRef(fn);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lastArgsRef = useRef<Args | null>(null);
  const leadingCalledRef = useRef(false);

  // ⭐ 每次渲染同步最新回调(这样「引用稳定」与「读到最新 props」两不误)
  useEffect(() => {
    fnRef.current = fn;
  });

  const cancel = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
    leadingCalledRef.current = false;
  }, []);

  const flush = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
    if (lastArgsRef.current) {
      fnRef.current(...lastArgsRef.current);
      lastArgsRef.current = null;
    }
    leadingCalledRef.current = false;
  }, []);

  const debounced = useCallback(
    (...args: Args) => {
      lastArgsRef.current = args;

      // leading:第一次调用时「立即执行」
      if (options.leading && !leadingCalledRef.current && !timerRef.current) {
        leadingCalledRef.current = true;
        fnRef.current(...args);
        lastArgsRef.current = null;
      }

      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => {
        timerRef.current = null;
        // 如果 leading 已经执行过且没有新参数,就不再重复执行
        if (lastArgsRef.current) {
          fnRef.current(...lastArgsRef.current);
          lastArgsRef.current = null;
        }
        leadingCalledRef.current = false;
      }, delay);
    },
    [delay, options.leading]
  ) as DebouncedFn<Args>;

  // ⭐ 卸载时清理(避免「组件卸载后仍执行」)
  useEffect(() => () => cancel(), [cancel]);

  debounced.cancel = cancel;
  debounced.flush = flush;

  return debounced;
}
// ✅ 使用:自动保存草稿
function Editor({ content, onSave }: { content: string; onSave: (v: string) => void }) {
  const save = useDebouncedCallback((value: string) => {
    onSave(value);                       // ⭐ 引用稳定,但内部用最新 content
  }, 1000);

  return (
    <>
      <textarea defaultValue={content} onChange={(e) => save(e.target.value)} />
      <button onClick={() => save.flush()}>立即保存</button>       {/* ⭐ flush */}
      <button onClick={() => save.cancel()}>取消保存</button>       {/* ⭐ cancel */}
    </>
  );
}
⭐ 三个关键设计:
① `fnRef` 保存最新回调
   → 只用 useCallback 会把「创建时的 fn」闭包住(读到旧值)
   → 用 ref 可以「引用稳定 + 读到最新」
② `timerRef` 保存定时器
   → 用 ref 而不是 useState(不需要触发渲染)
③ 返回的函数是【稳定引用】
   → 可以放进 useEffect 依赖、可以传给 memo 子组件

五、useThrottledCallback

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

interface ThrottledFn<Args extends unknown[]> {
  (...args: Args): void;
  cancel: () => void;
}

/**
 * 返回「节流后的稳定函数」——每 delay 毫秒最多执行一次
 */
export function useThrottledCallback<Args extends unknown[]>(
  fn: (...args: Args) => void,
  delay = 200,
  options: { leading?: boolean; trailing?: boolean } = { leading: true, trailing: true }
): ThrottledFn<Args> {
  const fnRef = useRef(fn);
  const lastTimeRef = useRef(0);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const lastArgsRef = useRef<Args | null>(null);

  useEffect(() => {
    fnRef.current = fn;
  });

  const cancel = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
    lastArgsRef.current = null;
  }, []);

  const throttled = useCallback(
    (...args: Args) => {
      const now = Date.now();
      const elapsed = now - lastTimeRef.current;
      const { leading = true, trailing = true } = options;

      // ① 首次(或距上次执行已超过 delay)→ 立即执行
      if (elapsed >= delay) {
        if (leading) {
          lastTimeRef.current = now;
          fnRef.current(...args);
          return;
        }
        lastTimeRef.current = now;
      }

      // ② 否则「安排一次尾部执行」(trailing)
      if (trailing) {
        lastArgsRef.current = args;

        if (!timerRef.current) {
          const remaining = delay - elapsed;
          timerRef.current = setTimeout(() => {
            timerRef.current = null;
            if (lastArgsRef.current) {
              lastTimeRef.current = Date.now();
              fnRef.current(...lastArgsRef.current);
              lastArgsRef.current = null;
            }
          }, remaining);
        }
      }
    },
    [delay, options]
  ) as ThrottledFn<Args>;

  useEffect(() => () => cancel(), [cancel]);

  throttled.cancel = cancel;
  return throttled;
}
// ✅ 使用:滚动监听
function BackToTop() {
  const [visible, setVisible] = useState(false);

  const onScroll = useThrottledCallback(() => {
    setVisible(window.scrollY > 400);
  }, 200);

  useEffect(() => {
    window.addEventListener('scroll', onScroll);
    return () => window.removeEventListener('scroll', onScroll);
  }, [onScroll]);                       // ⭐ onScroll 引用稳定 → 不会反复绑定

  return visible ? <button onClick={() => window.scrollTo({ top: 0 })}>回到顶部</button> : null;
}
⭐ 一个更简单的「节流值」版本(不常用,但知道就好):
function useThrottledValue<T>(value: T, interval = 200): T {
  const [throttled, setThrottled] = useState(value);
  const lastRef = useRef(Date.now());

  useEffect(() => {
    const now = Date.now();
    if (now - lastRef.current >= interval) {
      lastRef.current = now;
      setThrottled(value);
    } else {
      const timer = setTimeout(() => {
        lastRef.current = Date.now();
        setThrottled(value);
      }, interval - (now - lastRef.current));
      return () => clearTimeout(timer);
    }
  }, [value, interval]);

  return throttled;
}

六、什么时候用哪个版本

需求 用哪个
「输入停止后发请求/计算」 useDebouncedValue(值版,最简单)
「延迟执行一个副作用」(如自动保存) useDebouncedCallback(函数版)
「滚动/拖拽里限制执行频率」 useThrottledCallback
「需要立即执行/取消防抖」 函数版 + leading / flush / cancel
「只是想让渲染降优先级」 不用防抖,用 useDeferredValue(见 2.10)
⚠️ 「防抖」与「useDeferredValue」的区别(容易混):
· 防抖:**时间维度**,会「减少执行次数」(跳过中间输入)→ 适合**发请求**
· useDeferredValue:**优先级维度**,**不减少执行次数**,只是「不阻塞紧急更新」
  → 适合**同步的昂贵渲染**
· 实践上常组合:请求用防抖、渲染用 deferred(见 5.7)

七、进阶:更完整的功能

// ✅ 支持「返回值 Promise」的防抖(如防抖的搜索接口调用)
export function useDebouncedAsync<T extends (...args: any[]) => Promise<any>>(
  fn: T,
  delay = 300
): [(...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined>, { cancel: () => void }] {
  const fnRef = useRef(fn);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const resolversRef = useRef<Array<(v: any) => void>>([]);

  useEffect(() => {
    fnRef.current = fn;
  });

  const cancel = useCallback(() => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = null;
    resolversRef.current.forEach((resolve) => resolve(undefined));   // ⭐ 取消时 resolve undefined
    resolversRef.current = [];
  }, []);

  const debounced = useCallback(
    (...args: Parameters<T>) => {
      return new Promise<Awaited<ReturnType<T>> | undefined>((resolve) => {
        if (timerRef.current) clearTimeout(timerRef.current);
        resolversRef.current.push(resolve);

        timerRef.current = setTimeout(async () => {
          timerRef.current = null;
          const resolvers = resolversRef.current;
          resolversRef.current = [];
          try {
            const result = await fnRef.current(...args);
            resolvers.forEach((r) => r(result));
          } catch (err) {
            resolvers.forEach((r) => r(undefined));
          }
        }, delay);
      });
    },
    [delay]
  );

  useEffect(() => () => cancel(), [cancel]);

  return [debounced, { cancel }];
}

八、常见坑

// ① 在组件里「直接」创建防抖函数(每次渲染新建 → 泄漏 + 失效)
const debounced = debounce(fn, 300);              // ❌

// ② 用 useState 存定时器(会触发多余渲染)
const [timer, setTimer] = useState(null);         // ⚠️ 应该用 useRef

// ③ 忘记清理(卸载后仍在执行 → 在已卸载组件里 setState)
useEffect(() => { /* 没 return */ }, []);

// ④ useCallback 里「闭包住了旧的回调」
const debounced = useCallback(() => fn(value), [value]);   // ⚠️ value 变化 → 引用变化
// ✅ 用 useRef 保存最新回调,useCallback 依赖写 []

// ⑤ 依赖数组里放「不稳定的值」→ 防抖函数反复重建
useEffect(() => { ... }, [options]);              // ⚠️ options 是对象字面量

// ⑥ 用「防抖」处理「滚动」→ 滚动停止前没有反馈(应该用节流)
// ⑦ 用「节流」处理「搜索输入」→ 仍会发多次请求(应该用防抖)
// ⑧ 忘记「防抖的延迟也要清理」(delay 变化时重新计时是正确的,但要清理旧的)
// ⑨ 「leading」与「trailing」都开 → 首次与末次都执行(有时会「重复执行」一次)
// ⑩ 认为「防抖能降低「渲染次数」」
//    → 它降低「副作用/请求次数」;渲染次数取决于 state 更新(用 useDeferredValue)
// ⑪ 在「受控输入」上用防抖 → 输入框会「卡字」(受控值不能被防抖)
//    ✅ 受控值保持实时(setKeyword),只对「派生计算/请求」防抖
// ⑫ 用「全局变量」存定时器(多实例互相覆盖)→ 必须用 useRef
// ⑬ 忘记「flush」(用户点「保存」时应立即执行待保存的内容)
// ⑭ 在 SSR 中访问 window/setTimeout(要判环境或放 useEffect 里)
// ⑮ 测试里用真实定时器(慢且不稳)→ 用 vi.useFakeTimers()(见 10.4)
// ⑪ 的正确做法
function Search() {
  const [keyword, setKeyword] = useState('');         // ⭐ 受控值【不防抖】
  const debouncedKeyword = useDebouncedValue(keyword, 300);   // ⭐ 只对「使用」防抖

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <Results keyword={debouncedKeyword} />
    </>
  );
}
// ⑫ 的说明
let timer: number | null = null;                      // ❌ 模块级
function useBad() {
  useEffect(() => {
    timer = setTimeout(fn, 300);                       // 两个组件实例会互相覆盖
  }, []);
}

function useGood() {
  const timerRef = useRef<number | null>(null);        // ✅ 每个实例独立
  useEffect(() => {
    timerRef.current = window.setTimeout(fn, 300);
    return () => { if (timerRef.current) clearTimeout(timerRef.current); };
  }, []);
}

九、面试延伸

  1. 「手写一个 useDebounce」
    两种形式:① 值版(最常用)——useDebouncedValue(value, delay):用一个 useState 存「防抖后的值」,在 useEffect 里 setTimeout 延迟更新,清理函数 clearTimeout(这样 value 每次变化都会「清掉上一个定时器、建新的」→ 天然实现「重置计时」);② 函数版——useDebouncedCallback(fn, delay):用 useRef 保存定时器与最新回调,返回一个 useCallback 包裹的稳定函数。关键点是「定时器放 ref 不放 state」「回调放 ref 保证读到最新」「卸载时清理」。

  2. 「为什么不能在组件里直接 debounce(fn, 300)?」
    因为「每次渲染都会创建一个新的防抖函数实例」(debounce 内部用闭包保存了定时器)。后果有三:① 防抖失效——每次调用的是「不同的实例」,各自有独立的定时器,所以「每次都立即执行」(等于没防抖);② 内存泄漏——上一轮渲染创建的定时器没人清理;③ 卸载后仍执行——旧实例的定时器会在组件卸载后触发(可能导致「在已卸载组件里 setState」)。所以必须用 Hook 把「定时器」与「防抖函数」提升到「跨渲染存活」的地方(useRef)。

  3. 「为什么「回调」要放在 useRef 里?」
    为了同时满足两个需求:① 返回的函数引用稳定(可以安全放进 useEffect 的依赖数组、或作为 props 传给 React.memo 子组件而不破坏优化);② 调用时读到最新的回调(如果直接用 useCallback(fn, [fn]),那么 fn 一变化引用就变,稳定性就没了;如果用 useCallback(fn, []),则会闭包住创建时的 fn,读到旧值)。所以标准做法是「用 useRef 保存最新 fn(在无依赖的 effect 里每次渲染更新),然后 useCallback 依赖写 []」。

  4. 「防抖和节流分别用在哪?」
    ① 防抖:搜索输入(停止输入后才请求)、表单校验(输入完成后校验)、窗口 resize 后的重算、自动保存草稿——特点是「只关心最终状态」;② 节流:滚动监听(如「回到顶部」按钮的出现、滚动位置上报)、鼠标移动(拖拽跟随、画布绘制)、按钮防连点——特点是「需要持续反馈但频率可控」。一句话:「防抖等停止、节流按间隔」。

  5. 「防抖和 useDeferredValue 有什么区别?」
    两个完全不同的维度:① 防抖是「时间维度」——通过「延迟固定毫秒」来减少执行次数(跳过中间输入),适合「发请求」(避免每次输入都请求);② useDeferredValue 是「优先级维度」——不减少执行次数,只是让「非紧急的更新」不阻塞「紧急更新」(如输入回显),适合「同步的昂贵渲染」。实践上常组合使用:请求用防抖(减少网络请求),渲染用 deferred(保持输入流畅)。

  6. 「为什么说『受控输入不能被防抖』?」
    因为「受控输入的值必须实时更新」——<input value={debouncedKeyword} onChange={setKeyword} /> 会让「输入框的显示」也延迟,表现为「打字卡顿/掉字」(用户打了一个字符,要等 300ms 才显示出来)。正确做法是:受控值保持实时(setKeyword(e.target.value)),只对「派生计算或请求」做防抖——即「keyword 实时,useDebouncedValue(keyword) 用于请求」。

  7. 「leading / trailing / flush / cancel 分别是什么?」
    leading——是否在「第一次调用时立即执行」(默认 false,防抖通常只执行末次);trailing——是否在「等待结束后执行」(默认 true);flush——立即执行「当前待执行的调用」(如用户点「保存」时不想等防抖)、cancel——取消「待执行的调用」。这些选项让防抖/节流更灵活(如「按钮防连点」用 leading: true 更符合直觉;「自动保存」需要 flush 支持「立即保存」)。

  8. 「怎么测试防抖 Hook?」
    三条:① 用 fake timers——vi.useFakeTimers() + vi.advanceTimersByTime(300)(避免真的等 300ms,测试又快又稳);② 断言「中间状态不触发」——快进一半时间时断言「还没被调用」,快进完整时间后断言「被调用一次」;③ 断言「上一次被取消」——连续触发多次后只应执行一次;④ 用 renderHook(Testing Library)测试 Hook 本身(不需要渲染组件)。测试完记得 vi.useRealTimers()。

一句话速记

防抖 = 停止触发 N 毫秒后执行(搜索输入/表单校验/自动保存,只关心最终状态),节流 = 每 N 毫秒最多一次(滚动/鼠标移动/防连点,需要持续反馈);在 React 里必须封装成 Hook(否则每次渲染新建实例 → 防抖失效 + 定时器泄漏);useDebouncedValue(值版,useEffect + clearTimeout 清理,最简单常用)、useDebouncedCallback(函数版,useRef 存定时器、useRef 保存最新回调、useCallback 依赖写 [] 保证引用稳定、卸载时 cancel);常用选项是 leading/trailing/flush/cancel;受控输入的值不能防抖(会掉字)——只对「派生计算/请求」防抖;别用 useState 存定时器(用 useRef)、别用模块级变量(多实例冲突);防抖(时间维度,减少次数)≠ useDeferredValue(优先级维度,不减少次数),两者常组合。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.3 手写 useDebounce / useThrottle

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫