文章

手写防抖与节流函数深度解析

手写防抖与节流函数深度解析

一句话概括

防抖(debounce)在高频事件停止后才执行一次,节流(throttle)让高频事件每隔固定时间最多执行一次——两者都是用”时间换性能”的思路,核心靠闭包保存定时器/时间戳来实现。面试手写这两道题,基础版 5 行搞定,但面试官真正想看的是你对 this 绑定、立即执行、取消机制这些边角的处理。

1
2
3
4
5
6
7
// 防抖:用户停止输入 500ms 后才发请求
const search = debounce(val => fetch(`/api?q=${val}`), 500);
input.oninput = e => search(e.target.value);

// 节流:滚动时每 200ms 最多执行一次
const scroll = throttle(() => updateUI(window.scrollY), 200);
window.onscroll = scroll;

核心知识点

1. 防抖基础版 —— 定时器的清除与重设

每次触发都”重置倒计时”,只有最后一次触发后安静了 delay 毫秒才真正执行。闭包里只存一个 timer,没有别的花样。

1
2
3
4
5
6
7
8
9
10
11
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);                          // 清掉上次的倒计时
    timer = setTimeout(() => fn.apply(this, args), delay); // 重新计时
  };
}

// 验证:快速连续点击按钮,只有最后一次点击 300ms 后触发
const btn = document.querySelector('#btn');
btn.onclick = debounce(() => console.log('clicked!'), 300);

2. 节流-时间戳版 —— 首次立即执行,末次可能丢失

记下”上次执行时间”,每次触发时算差值。够间隔就执行,不够就跳过。特点是第一次必定立即执行

1
2
3
4
5
6
7
8
9
10
11
12
13
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      fn.apply(this, args);
      last = now;
    }
  };
}

// 验证:连续滚动,每 200ms 打印一次位置
window.onscroll = throttle(() => console.log(window.scrollY), 200);

3. 节流-定时器版 —— 末次一定执行,首次延迟

换一个思路:用定时器是否存在来卡节奏。有定时器说明”还在冷却中”,直接跳过。搞定之后 timer 置空,开门迎接下一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
function throttle(fn, interval) {
  let timer = null;
  return function (...args) {
    if (timer) return;                 // 冷却中,跳过
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;                   // 冷却结束
    }, interval);
  };
}

// 验证:输出"尾部"的滚动位置(与时间戳版的差异请自己多滚几次感受一下)
window.onscroll = throttle(() => console.log('tail:', window.scrollY), 200);

4. 完整版防抖 —— immediate + cancel

面试官让你写完全版,其实就是在基础版上多两个零件:① immediate 让首次触发立即执行;② .cancel() 方法在组件卸载时兜底。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function debounce(fn, delay, immediate = false) {
  let timer = null;

  const debounced = 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);          // immediate 模式:立即执行
  };

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

  return debounced;
}

// 用法
const handler = debounce(submitForm, 1000, true); // 立即执行 + 1s 内防重复
// 组件卸载:handler.cancel()

5. React 中使用防抖/节流 —— useRef 锁定引用

React 组件每次渲染都会重新执行函数体。如果你在组件里直接 const fn = debounce(callback, 300),每次渲染都拿到一个全新的防抖函数,各自带独立的 timer——防抖直接废掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useRef, useCallback } from 'react';

function SearchBox() {
  // ❌ 错误:const search = debounce(fetchResults, 300); —— 每次 render 新建,防抖失效

  const searchRef = useRef(debounce(fetchResults, 300));

  // 组件卸载时取消残留定时器
  useEffect(() => () => searchRef.current.cancel(), []);

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

// 或者用 useCallback + 空依赖(前提是 fn 不依赖 state,否则需要用 ref 存最新 state)
function SubmitButton({ onSubmit }) {
  const handle = useCallback(throttle(onSubmit, 1000), []);
  return <button onClick={handle}>提交</button>;
}

关键点useRef 返回同一个对象引用,生命周期内不变;useCallback(fn, []) 同理。只要防抖/节流函数实例稳定,闭包里的 timer 就能正常工作。

其实你每天都在用

  • 搜索框输入联想:百度、淘宝的搜索框,你每敲一个字不会立刻发请求,而是等你停下来 300-500ms 再查——标准防抖
  • 窗口 resize 重新布局:拖拽浏览器窗口时,resize 事件一秒能触发几十次,节流到 200ms 一次足以
  • 滚到底自动加载更多:监听 scroll 判断是否接近底部,节流后每 200ms 检测一次,避免无意义计算
  • 按钮防重复提交:登录、支付按钮用 immediate 防抖,首次点击立刻执行,1s 内连点无效
  • lodash 的 _.debounce / _.throttle:你 npm install 的这两个,底层就是上面这些逻辑,只是多了一些边界处理

常见误解

❌ 误区一:”节流就是间隔执行,防抖就是延时执行——就这点区别”

这句话只对了一半。面试时更关键的区别是防抖关注”最后一次”(等用户操作完成),节流关注”均匀频率”(持续操作中保证最低响应密度)。理解了这层,你才能回答”搜索框用防抖,滚动监听用节流”的 why,而不是死记硬背。

❌ 误区二:”手写防抖不用管 this,直接用 fn(…args) 就行”

btn.onclick = debounce(handler, 300) 场景下,如果不写 fn.apply(this, args)handler 里的 this 会变成 window(或严格模式的 undefined),拿不到按钮元素。面试官盯着这行代码看的时候,就是在 etc 你有没有处理 this 的意识。

❌ 误区三:”React 里用防抖,把 debounce 写在组件外面就行”

写在外面确实只创建一次实例,但如果你的回调依赖组件的 state 或 props,闭包捕获的是创建时的”过期值”。换个组件实例呢?大家共用一个 debounce 实例、一个 timer,A 组件连续输入把 B 组件的定时器清了——完全乱了。正确做法是用 useRef 存实例,再用一个 useRef 存最新的回调来解决闭包陈旧问题。

❌ 误区四:”时间戳版节流和定时器版节流差不多,写哪个都行”

时间戳版首次立即执行、末次可能丢失,定时器版首次延迟执行、末次一定触发。如果你做滚动吸顶效果需要”第一时间响应”,时间戳版合适;如果你做”收集用户最后一次停留位置”,定时器版合适。面试时说出这个区别比背出两个版本更值钱。

一句话总结

防抖 = 等安静再执行(清除定时器 + 重设定时器),节流 = 按频率执行(比较时间戳 或 用定时器做冷却标记)。手写关键三要素:闭包存状态、applythiscancel 兜底卸载。React 用 useRef 锁实例。面试先出基础版再展示完整版,比一口气写全更显思路。

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量