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

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

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

防抖 :事件被触发后,等待一段时间,如果在这段时间内事件又被触发,则重新计时。只有在最后一次触发后的一段时间内没有再次触发 ,才执行目标函数。

典型场景 :搜索框输入联想、窗口 resize 结束后重新计算布局。

节流 :无论事件触发有多频繁,目标函数在固定时间间隔内最多只执行一次

典型场景:滚动加载更多、按钮防重复点击(但要配合状态防重)。

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

二、手写防抖和节流

1. 防抖(Debounce)

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

使用示例:

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

input.addEventListener('input', handleInput);

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

javascript 复制代码
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)

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

javascript 复制代码
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);
    }
  };
}

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

javascript 复制代码
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(对值进行防抖)

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

tsx 复制代码
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 保存回调,避免过期闭包:

tsx 复制代码
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 类似,保证在一定间隔内值只变化一次:

tsx 复制代码
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 保持最新回调:

tsx 复制代码
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. 搜索框输入联想(防抖值)

tsx 复制代码
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. 按钮防重复点击(防抖回调)

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

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

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

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

tsx 复制代码
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 可以使代码更简洁、更符合声明式的编程习惯。

相关推荐
甜味弥漫1 小时前
React 从类组件到 Hooks:为什么 Hooks 改变了 React?
前端·javascript·react.js
黄敬峰1 小时前
🚀 掘金独家:打造最值钱的 AI Agent——从核心概念到高性能代码实战
面试
天蓝色的鱼鱼1 小时前
让 AI 写代码半年后,我发现了几个藏在项目里的安全隐患
前端·javascript·ai编程
随风一样自由1 小时前
【前端+跨端技术新格局】Tauri vs Electron 完整性能对比(2026实测数据+底层原理)
前端·javascript·electron
L-影1 小时前
Pydantic 模型:为你的 Web 数据装上“安检系统”
前端
Reload.2 小时前
GJ航司,验证码网易易盾 JS逆向 纯算轨迹
开发语言·前端·javascript
L-影2 小时前
FastAPI 请求头与 Cookie:Web 世界的“身份证”与“通行证”
前端·fastapi
IT_陈寒2 小时前
Vue的响应式更新把我坑惨了,原来是这个原因
前端·人工智能·后端
铁皮饭盒2 小时前
Bun.js 集成了 SQL/Redis/S3,为什么 Java/Go/Python 不集成?
javascript