跳到主要内容
预计阅读 36 分钟

Javascript篇 | 防抖与节流

前言

防抖(debounce)和节流(throttle)是前端面试的”送分题”——但前提是你真的搞懂了。

面试中你可能遇到这些场景:

  • “请手写一个防抖函数” —— 这是最基础的考法
  • “说说防抖和节流的区别?分别在什么场景下使用?” —— 考你对概念的理解
  • “如何实现一个支持 leading 和 trailing 选项的节流?” —— 考你对细节的掌握

这两个概念在实际开发中也非常实用。搜索框输入、窗口 resize、滚动加载、按钮防重复点击——到处都能用到。搞懂原理和实现,不仅面试加分,日常开发也更得心应手。

诊断自测

在开始之前,试着回答以下问题:

1. 防抖和节流的核心区别是什么?用一句话概括。

2. 以下代码有什么问题?

input.addEventListener('input', debounce(function(e) {
  fetchSearchResults(e.target.value);
}));

3. 以下场景分别应该用防抖还是节流?

  • 搜索框输入实时搜索
  • 页面滚动时计算位置
  • 按钮提交表单防重复点击
点击查看答案

第1题: 防抖是”等你停下来再执行”,节流是”每隔一段时间执行一次”。防抖重置计时器,节流不重置。

第2题: 缺少 delay 参数。应该是 debounce(fn, 300) 这样。另外,如果组件卸载或 DOM 移除,需要注意清理(cancel),否则可能产生副作用。

第3题:

  • 搜索框输入 → 防抖(用户停止输入后再请求,避免每个字符都发请求)
  • 页面滚动计算位置 → 节流(需要持续响应但降低频率)
  • 按钮防重复点击 → 防抖(leading 模式)或节流(只要在一定时间内只执行一次就行)

如果都答对了,说明你基础不错!继续阅读可以查漏补缺。

从问题出发:为什么需要防抖和节流?

先来看一个真实场景。假设有一个搜索框,你希望用户输入时实时展示搜索建议:

const input = document.getElementById('search');

input.addEventListener('input', function(e) {
  // 每次输入都发请求
  fetchSearchResults(e.target.value);
});

用户输入 “javascript” 这 10 个字符,就会触发 10 次请求!这不仅浪费带宽,还可能因为请求返回顺序不一致导致结果错乱。

再看另一个场景——窗口 resize:

window.addEventListener('resize', function() {
  // 调整布局,涉及大量 DOM 操作
  recalculateLayout();
});

拖动窗口边缘时,resize 事件一秒能触发几十次,每次都重新计算布局,页面直接卡死。

这就是防抖和节流要解决的问题:控制高频事件的执行频率

防抖(Debounce)

核心思想

事件触发后等待一段时间再执行,如果在等待期间又触发了事件,就重新计时。

打个比方:你坐电梯,门准备关了(开始计时),这时又有人进来(重新计时),等最后一个人进来后,过了几秒没人了,门才关上(执行)。

基础实现

function debounce(fn, delay) {
  let timer = null;

  return function(...args) {
    // 每次触发都清除之前的定时器
    clearTimeout(timer);

    // 重新开始计时
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// 使用
const search = debounce(function(keyword) {
  console.log('搜索:', keyword);
}, 300);

input.addEventListener('input', function(e) {
  search(e.target.value);
});

执行流程

假设 delay = 300ms,用户快速输入 “abc”:

时间线:0ms    50ms   100ms   400ms
事件:  输入a   输入b   输入c
定时器:设置→   清除→   清除→   执行fn('abc')
        设置    设置    设置

只有最后一次输入(c)300ms 后没有新输入,才会真正执行。

进阶:支持 leading 模式

有时候我们希望第一次触发就立即执行,后续的触发才走防抖逻辑。比如按钮点击——第一次点击应该立刻响应:

function debounce(fn, delay, options = {}) {
  let timer = null;
  const { leading = false } = options;

  return function(...args) {
    const isFirstCall = timer === null;

    clearTimeout(timer);

    if (leading && isFirstCall) {
      fn.apply(this, args);
    }

    timer = setTimeout(() => {
      if (!leading) {
        fn.apply(this, args);
      }
      timer = null; // 重置,允许下一轮 leading 执行
    }, delay);
  };
}

// 第一次点击立即执行,后续快速点击被忽略
const handleClick = debounce(submitForm, 1000, { leading: true });

进阶:支持 cancel

function debounce(fn, delay, options = {}) {
  let timer = null;
  const { leading = false } = options;

  function debounced(...args) {
    const isFirstCall = timer === null;
    clearTimeout(timer);

    if (leading && isFirstCall) {
      fn.apply(this, args);
    }

    timer = setTimeout(() => {
      if (!leading) {
        fn.apply(this, args);
      }
      timer = null;
    }, delay);
  }

  debounced.cancel = function() {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}

// React 组件中使用
useEffect(() => {
  const debouncedSearch = debounce(fetchResults, 300);
  input.addEventListener('input', debouncedSearch);

  return () => {
    debouncedSearch.cancel(); // 组件卸载时取消
    input.removeEventListener('input', debouncedSearch);
  };
}, []);

节流(Throttle)

核心思想

在一段时间内只执行一次,无论事件触发了多少次。

打个比方:射击游戏里枪有射速限制,你可以疯狂点鼠标,但子弹每隔固定时间才会发射一颗。

实现方式一:时间戳版

function throttle(fn, interval) {
  let lastTime = 0;

  return function(...args) {
    const now = Date.now();

    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

特点: 第一次触发立即执行,最后一次触发如果不满足间隔不会执行

时间线:  0ms    100ms  200ms  300ms  400ms  500ms  600ms
事件:    触发    触发    触发    触发    触发    触发    触发
interval=300ms
执行:    ✅      ❌     ❌     ✅     ❌     ❌     ✅

实现方式二:定时器版

function throttle(fn, interval) {
  let timer = null;

  return function(...args) {
    if (timer) return; // 有定时器在等待,直接忽略

    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, interval);
  };
}

特点: 第一次触发不会立即执行(等 interval 后才执行),最后一次触发会执行

时间线:  0ms    100ms  200ms  300ms  400ms  500ms  600ms  900ms
事件:    触发    触发    触发    触发    触发    触发    触发
interval=300ms
执行:                          ✅                  ✅           ✅

两种方式对比

特性时间戳版定时器版
首次触发立即执行延迟执行
最后一次可能不执行会执行
实现方式对比时间差定时器锁

进阶:leading + trailing 结合版

实际项目中(比如 lodash),我们往往希望首次触发立即执行,最后一次触发也能执行。这就需要结合两种方式:

function throttle(fn, interval, options = {}) {
  let timer = null;
  let lastTime = 0;
  const { leading = true, trailing = true } = options;

  return function(...args) {
    const now = Date.now();

    // 如果不需要 leading,且是第一次调用,把 lastTime 设为当前时间
    if (!leading && lastTime === 0) {
      lastTime = now;
    }

    const remaining = interval - (now - lastTime);

    if (remaining <= 0) {
      // 时间到了,立即执行
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      lastTime = now;
      fn.apply(this, args);
    } else if (!timer && trailing) {
      // 设置定时器,确保最后一次触发能执行
      timer = setTimeout(() => {
        lastTime = leading ? Date.now() : 0;
        timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}

防抖 vs 节流:一图看懂

假设用户在 0-1000ms 内疯狂触发事件(每 50ms 一次),delay/interval = 300ms:

原始事件:  ||||||||||||||||||||
防抖:                              |  (只在最后一次 300ms 后执行)
节流:      |         |         |   |  (每 300ms 执行一次)

选择原则:

  • 只关心最终状态 → 防抖(搜索框、表单验证)
  • 需要持续响应但降低频率 → 节流(滚动、拖拽、动画)

实际应用场景

搜索框(防抖)

function SearchInput() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);

  // 用 useCallback + useRef 避免闭包问题
  const fetchRef = useRef();
  fetchRef.current = async (value) => {
    const data = await fetch(`/api/search?q=${value}`);
    setResults(await data.json());
  };

  const debouncedSearch = useMemo(
    () => debounce((value) => fetchRef.current(value), 300),
    []
  );

  useEffect(() => {
    return () => debouncedSearch.cancel();
  }, []);

  const handleChange = (e) => {
    const value = e.target.value;
    setKeyword(value);
    debouncedSearch(value);
  };

  return (
    <div>
      <input value={keyword} onChange={handleChange} />
      <ul>
        {results.map(item => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  );
}

滚动加载(节流)

function InfiniteList() {
  const handleScroll = useMemo(
    () => throttle(() => {
      const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
      if (scrollTop + clientHeight >= scrollHeight - 100) {
        loadMore();
      }
    }, 200),
    []
  );

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

  return <div>{/* 列表内容 */}</div>;
}

窗口 resize(防抖)

useEffect(() => {
  const handleResize = debounce(() => {
    setDimensions({
      width: window.innerWidth,
      height: window.innerHeight
    });
  }, 250);

  window.addEventListener('resize', handleResize);
  return () => {
    handleResize.cancel();
    window.removeEventListener('resize', handleResize);
  };
}, []);

按钮防重复提交(防抖 leading)

const handleSubmit = debounce(
  async () => {
    await submitForm(formData);
    showSuccess();
  },
  2000,
  { leading: true }
);

面试中的代码考察要点

面试官手写代码时,重点关注以下几点:

  1. this 指向:用 fn.apply(this, args) 而不是直接 fn(args)
  2. 参数传递:用 ...args 收集剩余参数
  3. clearTimeout:防抖要记得清除上一个定时器
  4. cancel 方法:加分项,展示工程思维
  5. leading/trailing 选项:进阶要求,展示对细节的理解

最简版手写模板(背下来):

// 防抖 —— 最后一次触发后 delay ms 执行
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 节流 —— 每 interval ms 最多执行一次
function throttle(fn, interval) {
  let lastTime = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

常见误区

误区1:防抖和节流效果差不多,用哪个都行

虽然两者都是”降低执行频率”,但行为差异很大:

// 场景:用户连续滚动 2 秒

// 防抖(delay=300ms):只在停止滚动 300ms 后执行 1 次
// 用户滚动过程中看到的是"卡住"的效果

// 节流(interval=300ms):滚动过程中每 300ms 执行 1 次
// 用户滚动过程中能看到持续更新的效果

滚动场景用防抖会导致用户在滚动过程中看不到任何变化,体验很差。持续性交互用节流,终态型交互用防抖

误区2:在 React 组件中直接使用 debounce

// ❌ 错误:每次渲染都创建新的 debounce 函数,等于没有防抖
function SearchInput() {
  const handleChange = debounce((value) => {
    fetchResults(value);
  }, 300);

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

// ✅ 正确:用 useMemo 或 useRef 保持引用稳定
function SearchInput() {
  const handleChange = useMemo(
    () => debounce((value) => fetchResults(value), 300),
    []
  );

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

误区3:节流的时间戳版和定时器版效果完全一样

前面已经对比过了,时间戳版首次立即执行但最后一次可能丢失,定时器版首次延迟但最后一次会执行。面试时一定要说清楚区别。

误区4:debounce 的 delay 越小越好

delay 设置太小等于没有防抖。太大又会让用户觉得”不响应”。一般经验值:

  • 搜索框:200-500ms
  • resize/scroll:100-300ms
  • 按钮点击:500-2000ms

小结

防抖和节流是前端性能优化的基本功,面试中手写代码是标配考点。

核心要点

  1. 防抖:最后一次触发后等待 delay 再执行,期间有新触发就重新计时
  2. 节流:固定间隔内最多执行一次,不受触发频率影响
  3. 选择原则:关心最终状态用防抖,需要持续响应用节流
  4. leading/trailing:首次是否立即执行、最后一次是否保证执行
  5. React 中使用:注意用 useMemo/useRef 保持引用稳定,组件卸载时 cancel

本章思维导图

防抖与节流
  • 防抖(Debounce)
    • 核心:最后一次触发后延迟执行
    • 实现:clearTimeout + setTimeout
    • leading 模式:首次立即执行
    • cancel 方法:清理定时器
    • 场景:搜索框、表单验证、resize
  • 节流(Throttle)
    • 核心:固定间隔执行一次
    • 时间戳版:首次立即,末次可能丢
    • 定时器版:首次延迟,末次保证
    • 结合版:leading + trailing
    • 场景:滚动、拖拽、动画
  • 选择原则
    • 终态型交互 → 防抖
    • 持续性交互 → 节流
  • React 中的使用
    • useMemo 保持引用稳定
    • useRef 保存最新回调
    • useEffect cleanup cancel

练习挑战

挑战一:基础(⭐)

手写一个基础的 debounce 函数,确保 this 指向和参数传递正确。然后用它实现:每次 input 输入后 500ms 打印输入值。

答案与解析
function debounce(fn, delay) {
  let timer = null;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

const input = document.getElementById('myInput');
const logValue = debounce(function(e) {
  console.log('输入值:', e.target.value);
}, 500);

input.addEventListener('input', logValue);

关键点:

  • fn.apply(this, args) 确保 this 指向正确(在事件监听器中 this 指向 DOM 元素)
  • ...args 确保事件对象等参数被正确传递
  • 每次调用先 clearTimeout,保证只有最后一次生效

挑战二:进阶(⭐⭐)

实现一个支持 leadingtrailing 选项的 debounce

  • leading: true — 第一次触发立即执行
  • trailing: true — 最后一次触发 delay 后执行
  • 默认 leading: false, trailing: true
const fn1 = debounce(handler, 300, { leading: true, trailing: false });
// 第一次立即执行,后续快速触发被忽略

const fn2 = debounce(handler, 300, { leading: true, trailing: true });
// 第一次立即执行,如果在 delay 内又触发了,最后一次也会在 delay 后执行
答案与解析
function debounce(fn, delay, options = {}) {
  let timer = null;
  const { leading = false, trailing = true } = options;

  return function(...args) {
    const isFirstCall = timer === null;

    clearTimeout(timer);

    if (leading && isFirstCall) {
      fn.apply(this, args);
    }

    timer = setTimeout(() => {
      if (trailing && !(leading && isFirstCall)) {
        // 避免 leading + trailing 时第一次执行两遍
        fn.apply(this, args);
      }
      timer = null;
    }, delay);
  };
}

注意这里有个细节:当 leading: true, trailing: true 时,第一次触发如果只触发了一次(没有后续触发),不应该在 delay 后再执行一次。但如果后续又触发了,最后一次应该在 delay 后执行。完善版本需要额外记录 lastArgs,有兴趣可以参考 lodash 的实现。

挑战三:综合(⭐⭐⭐)

实现一个 useThrottle Hook:

function useThrottle(value, interval) {
  // 返回节流后的值
  // value 变化时,throttledValue 最多每 interval ms 更新一次
}

// 使用示例
function SearchResults({ keyword }) {
  const throttledKeyword = useThrottle(keyword, 500);

  useEffect(() => {
    if (throttledKeyword) {
      fetchResults(throttledKeyword);
    }
  }, [throttledKeyword]);
}
答案与解析
function useThrottle(value, interval) {
  const [throttledValue, setThrottledValue] = useState(value);
  const lastUpdated = useRef(Date.now());

  useEffect(() => {
    const now = Date.now();
    const elapsed = now - lastUpdated.current;

    if (elapsed >= interval) {
      // 距离上次更新超过 interval,立即更新
      setThrottledValue(value);
      lastUpdated.current = now;
    } else {
      // 设置定时器,在剩余时间后更新
      const timer = setTimeout(() => {
        setThrottledValue(value);
        lastUpdated.current = Date.now();
      }, interval - elapsed);

      return () => clearTimeout(timer);
    }
  }, [value, interval]);

  return throttledValue;
}

这个 Hook 结合了时间戳版和定时器版的优点:如果距离上次更新已经超过 interval,立即更新;否则设置定时器等到 interval 到期再更新。这样既有首次立即响应,最后一次变化也不会丢失。

自我检测

读完本章后,确认你能回答以下问题:

  • 能用一句话解释防抖和节流的区别
  • 能手写基础版 debounce(3 分钟内完成)
  • 能手写基础版 throttle(时间戳版或定时器版)
  • 能说出时间戳版和定时器版节流的区别(首次/末次行为)
  • 能解释 leading 和 trailing 选项的含义
  • 能说出 3 个以上的防抖/节流实际应用场景
  • 知道在 React 中使用 debounce/throttle 需要注意引用稳定性
  • 能实现 cancel 方法

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90