防抖与节流:从原理到 React Hook 封装

0 阅读3分钟

前端高频事件如 scrollresizeinputclick 很容易在短时间内被大量触发,如果不加以控制,可能导致服务端压力激增、页面卡顿甚至逻辑错误。防抖和节流是两种经典的时间控制方案,但很多开发者对它们的区别和使用边界仍然模糊。本文从原理出发,手写实现,并封装适用于 React 的自定义 Hook,帮你彻底搞懂并在项目中正确落地。

Slide 4_3 - 26.png

一、防抖(Debounce)与节流(Throttle)的本质区别

防抖:事件被触发后,等待一段时间,如果在这段时间内事件又被触发,则重新计时。只有在最后一次触发后的一段时间内没有再次触发,才执行目标函数。
典型场景:搜索框输入联想、窗口 resize 结束后重新计算布局。

节流:无论事件触发有多频繁,目标函数在固定时间间隔内最多只执行一次
典型场景:滚动加载更多、按钮防重复点击(但要配合状态防重)。

  • 防抖是“你一直点,我就不理你,等你停下来我才干活”。
  • 节流是“你尽管点,我每秒钟最多干一次”。

二、手写防抖和节流

1. 防抖(Debounce)

function debounce(fn, delay = 300) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

使用示例:

const handleInput = debounce((e) => {
  fetch(`/api/suggest?q=${e.target.value}`);
}, 300);

input.addEventListener('input', handleInput);

如果需要立即执行一次(比如按钮点击第一次立即响应),可以增加一个 immediate 参数:

function debounce(fn, delay = 300, immediate = false) {
  let timer = null;
  return function (...args) {
    const callNow = immediate && !timer;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) fn.apply(this, args);
    }, delay);
    if (callNow) fn.apply(this, args);
  };
}

2. 节流(Throttle)

时间戳版本(第一次立即执行):

function throttle(fn, interval = 200) {
  let lastTime = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

定时器版本(最后一次也会执行):

function throttle(fn, interval = 200) {
  let timer = null;
  return function (...args) {
    if (!timer) {
      timer = setTimeout(() => {
        timer = null;
        fn.apply(this, args);
      }, interval);
    }
  };
}

实际中常用时间戳版本,保证第一次立即执行,适合滚动加载等需要即时响应的场景。

三、在 React 中封装自定义 Hook

在 React 函数组件中,普通的防抖/节流函数会遇到闭包问题——防抖函数内部捕获的 state 是旧值。需要用 useRef 保持最新的回调引用,或者配合 useCallback 进行封装。

1. useDebounce(对值进行防抖)

将频繁变化的值进行防抖,常用于搜索关键词:

import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

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

  useEffect(() => {
    if (debouncedKeyword) {
      fetch(`/api/search?q=${debouncedKeyword}`);
    }
  }, [debouncedKeyword]);
}

这种方式将防抖逻辑和副作用分离,非常清晰。

2. useDebouncedCallback(防抖回调)

如果需要直接得到一个防抖后的函数,并且函数内部能访问到最新的 state,可以用 useRef 保存回调,避免过期闭包:

import { useRef, useCallback } from 'react';

function useDebouncedCallback(fn: (...args: any[]) => void, delay: number) {
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const fnRef = useRef(fn);
  fnRef.current = fn; // 每次渲染保持最新

  return useCallback(
    (...args: any[]) => {
      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => {
        fnRef.current(...args);
      }, delay);
    },
    [delay]
  );
}

3. useThrottle(对值进行节流)

useDebounce 类似,保证在一定间隔内值只变化一次:

function useThrottle<T>(value: T, interval: number): T {
  const [throttledValue, setThrottledValue] = useState(value);
  const lastUpdated = useRef(Date.now());

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

  return throttledValue;
}

4. useThrottledCallback(节流回调)

用于函数调用频率限制,例如滚动加载,内部同样使用 ref 保持最新回调:

function useThrottledCallback(fn: (...args: any[]) => void, interval: number) {
  const lastTimeRef = useRef(0);
  const fnRef = useRef(fn);
  fnRef.current = fn;

  return useCallback(
    (...args: any[]) => {
      const now = Date.now();
      if (now - lastTimeRef.current >= interval) {
        lastTimeRef.current = now;
        fnRef.current(...args);
      }
    },
    [interval]
  );
}

四、真实场景落地

1. 搜索框输入联想(防抖值)

const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);

useEffect(() => {
  if (!debouncedQuery) return;
  fetch(`/api/suggest?q=${debouncedQuery}`)
    .then(res => res.json())
    .then(setSuggestions);
}, [debouncedQuery]);

2. 按钮防重复点击(防抖回调)

const handleSubmit = useDebouncedCallback(async () => {
  await api.submit(formData);
  message.success('提交成功');
}, 1000);

return <Button onClick={handleSubmit}>提交</Button>;

注意:如果提交是异步且有 loading 状态,更推荐使用状态控制(disabled={loading})来从 UI 层面阻止重复点击,防抖只能作为辅助。

3. 滚动加载更多(节流回调)

const handleScroll = useThrottledCallback(() => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
    loadMore();
  }
}, 200);

useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);

五、注意事项与常见坑

  1. 过期闭包:在直接使用普通防抖函数时,如果内部读取了 React state,需要使用 ref 保持最新值,否则会读到旧的 state。
  2. 防抖时间设置:搜索输入通常 200-500ms;按钮防重复 500-1000ms;窗口 resize 300-500ms。
  3. 节流间隔设置:滚动 100-200ms;鼠标移动 50-100ms。
  4. 清理定时器:在组件卸载时务必清除定时器,否则会导致内存泄漏或尝试更新已卸载组件的状态。
  5. 防抖与节流的选择:如果关注的是“连续操作停止后的最终结果”,用防抖;如果关注的是“操作过程中的平滑控制”,用节流。

六、总结

防抖和节流是前端性能优化的基石,它们从时间维度上控制了昂贵操作的执行频率。在 React 中,将它们封装为 Hook 可以使代码更简洁、更符合声明式的编程习惯。