文章

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

从原理到实现,手写完整的防抖(debounce)与节流(throttle)函数,涵盖立即执行版本与取消功能

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

一句话概括

防抖(debounce)让高频事件在最后一次触发后延迟执行,节流(throttle)让高频事件按固定频率执行——两者都是控制函数调用频率的核心手段,也是前端面试必考手写题。

背景

在浏览器中,scrollresizeinputmousemove 等事件会以极高频率触发(每秒数十次甚至上百次)。如果每次触发都执行复杂逻辑(如 API 请求、DOM 操作),会严重影响性能。

防抖和节流是解决这一问题的两种经典策略:

策略核心思想典型场景
防抖最后一次触发后等待 N ms 再执行搜索框输入、表单验证
节流每隔 N ms 最多执行一次滚动监听、按钮防重复点击

概念与定义

防抖(Debounce)

在事件被触发 N 毫秒后再执行回调,如果在这 N 毫秒内又被触发,则重新计时。

生活类比:电梯关门——每次有人进来,关门计时器重置,直到没人进来才关门。

节流(Throttle)

在 N 毫秒内,无论触发多少次,只执行一次回调。

生活类比:地铁进站——无论多少人等待,每隔固定时间才开一次门。

最小示例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 防抖:输入停止 500ms 后才搜索
const handleSearch = debounce((value) => {
  fetchSearchResults(value);
}, 500);

input.addEventListener('input', (e) => handleSearch(e.target.value));

// 节流:滚动时每 200ms 最多执行一次
const handleScroll = throttle(() => {
  updateScrollPosition();
}, 200);

window.addEventListener('scroll', handleScroll);

核心知识点拆解

1. 防抖的核心机制

防抖的关键在于定时器的清除与重置

1
2
3
4
触发 → 清除旧定时器 → 设置新定时器(N ms 后执行)
触发 → 清除旧定时器 → 设置新定时器(N ms 后执行)
触发 → 清除旧定时器 → 设置新定时器(N ms 后执行)
... N ms 内无触发 → 执行!

2. 节流的两种实现思路

时间戳版:记录上次执行时间,每次触发时判断是否超过间隔

1
2
触发 → 当前时间 - 上次执行时间 >= N → 执行,更新时间戳
触发 → 当前时间 - 上次执行时间 < N  → 跳过

定时器版:用定时器标记是否在冷却中

1
2
触发 → 无定时器 → 设置定时器(N ms 后执行并清除定时器)
触发 → 有定时器 → 跳过

两者的区别:

  • 时间戳版:第一次立即执行,最后一次可能不执行
  • 定时器版:第一次延迟执行,最后一次一定执行

实战案例

手写防抖函数(基础版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * 防抖函数
 * @param {Function} fn - 需要防抖的函数
 * @param {number} delay - 延迟时间(ms)
 * @returns {Function} 防抖后的函数
 */
function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    // 每次触发先清除旧定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 重新设置定时器
    timer = setTimeout(() => {
      fn.apply(this, args);  // 保持 this 指向和参数
      timer = null;
    }, delay);
  };
}

手写防抖函数(完整版:支持立即执行 + 取消)

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
 * 完整防抖函数
 * @param {Function} fn - 需要防抖的函数
 * @param {number} delay - 延迟时间(ms)
 * @param {boolean} immediate - 是否立即执行(首次触发立即执行,之后防抖)
 * @returns {Function} 防抖后的函数,附带 cancel 方法
 */
function debounce(fn, delay, immediate = false) {
  let timer = null;
  let isInvoked = false; // 记录立即执行版是否已执行

  const debounced = function (...args) {
    const context = this;

    // 立即执行版:第一次触发立即执行
    if (immediate && !isInvoked) {
      fn.apply(context, args);
      isInvoked = true;
    }

    if (timer) {
      clearTimeout(timer);
    }

    timer = setTimeout(() => {
      if (!immediate) {
        fn.apply(context, args);
      }
      timer = null;
      isInvoked = false; // 重置,允许下次立即执行
    }, delay);
  };

  // 取消防抖
  debounced.cancel = function () {
    if (timer) {
      clearTimeout(timer);
      timer = null;
      isInvoked = false;
    }
  };

  return debounced;
}

使用示例:

1
2
3
4
5
6
7
8
// 普通防抖:停止输入 500ms 后搜索
const search = debounce(fetchData, 500);

// 立即执行防抖:首次点击立即执行,之后 1s 内不重复
const handleClick = debounce(submitForm, 1000, true);

// 取消防抖(如组件卸载时)
handleClick.cancel();

手写节流函数(时间戳版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * 节流函数(时间戳版)
 * 特点:第一次立即执行,最后一次可能不执行
 */
function throttle(fn, interval) {
  let lastTime = 0;

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

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

手写节流函数(定时器版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * 节流函数(定时器版)
 * 特点:第一次延迟执行,最后一次一定执行
 */
function throttle(fn, interval) {
  let timer = null;

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

手写节流函数(完整版:结合时间戳 + 定时器)

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
 * 完整节流函数
 * 特点:第一次立即执行,最后一次也执行,支持取消
 * @param {Function} fn - 需要节流的函数
 * @param {number} interval - 节流间隔(ms)
 * @returns {Function} 节流后的函数,附带 cancel 方法
 */
function throttle(fn, interval) {
  let lastTime = 0;
  let timer = null;

  const throttled = function (...args) {
    const context = this;
    const now = Date.now();
    const remaining = interval - (now - lastTime); // 距下次可执行的剩余时间

    if (remaining <= 0) {
      // 已超过间隔,立即执行
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      fn.apply(context, args);
      lastTime = now;
    } else {
      // 未超过间隔,设置定时器确保最后一次执行
      if (timer) {
        clearTimeout(timer);
      }
      timer = setTimeout(() => {
        fn.apply(context, args);
        lastTime = Date.now();
        timer = null;
      }, remaining);
    }
  };

  // 取消节流
  throttled.cancel = function () {
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }
    lastTime = 0;
  };

  return throttled;
}

使用示例:

1
2
3
4
5
6
7
8
9
10
// 滚动节流:每 200ms 最多执行一次
const handleScroll = throttle(() => {
  console.log('scroll position:', window.scrollY);
}, 200);

window.addEventListener('scroll', handleScroll);

// 组件卸载时取消
window.removeEventListener('scroll', handleScroll);
handleScroll.cancel();

底层原理

为什么要用 fn.apply(this, args)

在返回的闭包函数中,this 指向调用者(如 DOM 元素)。如果直接调用 fn(...args)this 会丢失(严格模式下为 undefined,非严格模式下为 window)。

1
2
3
4
5
6
7
8
// 错误示例:this 丢失
const btn = document.querySelector('button');
btn.addEventListener('click', debounce(function() {
  console.log(this); // ❌ 如果不用 apply,this 不是 btn
}, 300));

// 正确:使用 apply 保持 this 指向
fn.apply(this, args); // ✅ this 正确指向 btn

闭包在防抖/节流中的作用

防抖和节流的核心依赖闭包来保存状态:

1
2
3
4
5
6
7
8
9
function debounce(fn, delay) {
  let timer = null; // ← 这个变量通过闭包被保存

  return function (...args) {
    // 每次调用都能访问到同一个 timer
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

timer 变量存在于 debounce 函数的作用域中,通过闭包被返回的函数持续引用,不会被垃圾回收。

内存泄漏风险

如果防抖/节流函数绑定在 DOM 元素上,且 DOM 元素被移除但事件监听未清除,闭包中的 timerfn 引用会阻止垃圾回收。

最佳实践:组件卸载时调用 .cancel() 并移除事件监听。

高频面试题解析

Q1:防抖和节流的区别是什么?

  • 防抖:事件停止触发后 N ms 才执行,适合”等用户操作完成再响应”的场景(搜索框、表单验证)
  • 节流:每 N ms 最多执行一次,适合”持续触发但需要限频”的场景(滚动、鼠标移动)

核心区别:防抖关注最后一次,节流关注执行频率

Q2:防抖的立即执行版和普通版有什么区别?

  • 普通版:触发后等待 N ms 执行,适合”等待用户停止操作”
  • 立即执行版:第一次触发立即执行,之后 N ms 内不再执行,适合”立即响应但防止重复”(如按钮防重复点击)

Q3:节流的时间戳版和定时器版有什么区别?

对比项时间戳版定时器版
第一次触发立即执行延迟 N ms 执行
最后一次触发可能不执行一定执行
精确度较高受 setTimeout 影响

完整版结合两者:第一次立即执行,最后一次也执行。

Q4:如何在 React 中正确使用防抖/节流?

:需要用 useRefuseCallback 保持函数引用稳定,避免每次渲染创建新的防抖函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ 错误:每次渲染都创建新的防抖函数,防抖失效
function SearchInput() {
  const handleSearch = debounce(fetchData, 500); // 每次渲染重新创建!
  return <input onChange={handleSearch} />;
}

// ✅ 正确:用 useRef 保持引用稳定
function SearchInput() {
  const handleSearch = useRef(debounce(fetchData, 500)).current;
  return <input onChange={handleSearch} />;
}

// ✅ 或者用 useCallback(注意依赖项)
function SearchInput() {
  const handleSearch = useCallback(
    debounce(fetchData, 500),
    [] // 空依赖,只创建一次
  );
  return <input onChange={handleSearch} />;
}

Q5:防抖和节流能用 requestAnimationFrame 实现吗?

:可以,requestAnimationFrame 节流适合动画场景,每帧(约 16.7ms)执行一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function rafThrottle(fn) {
  let rafId = null;

  return function (...args) {
    if (rafId) return; // 已有待执行帧,跳过

    rafId = requestAnimationFrame(() => {
      fn.apply(this, args);
      rafId = null;
    });
  };
}

// 适合滚动动画、Canvas 绘制等场景
const handleScroll = rafThrottle(updateAnimation);

总结与扩展

核心要点回顾

要点防抖节流
执行时机停止触发后 N ms每 N ms 最多一次
实现核心clearTimeout + setTimeout时间戳比较 或 定时器标记
适用场景搜索框、表单验证、窗口 resize滚动、鼠标移动、按钮点击
关键细节apply 保持 this、立即执行版时间戳版 vs 定时器版的差异

扩展阅读

  1. Lodash 源码_.debounce_.throttle 是工业级实现,支持 leading/trailing 选项,值得阅读
  2. React 中的防抖:结合 useRefuseCallback 使用,避免闭包陷阱
  3. requestAnimationFrame 节流:动画场景的最佳节流方案
  4. 下一步:第2周将深入 this 指向与原型链,理解 apply/call/bind 的底层实现

💡 面试技巧:手写防抖/节流时,先写基础版(5行代码),再说”完整版还需要考虑 this 绑定、立即执行、取消功能”,展示你的工程思维。

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