手写防抖(Debounce)与节流(Throttle):实现支持立即执行(Leading)的版本

手写防抖(Debounce)与节流(Throttle):实现支持立即执行(Leading)的版本

大家好,欢迎来到今天的编程技术讲座。我是你们的讲师,今天我们要深入探讨两个在前端开发中极其重要的性能优化技巧:防抖(Debounce)节流(Throttle)。它们常用于处理高频触发事件(如输入框搜索、窗口滚动、按钮点击等),避免不必要的重复计算或网络请求。

但今天我们不只是讲基础用法,而是要手写一个支持立即执行(leading) 的完整版本——这正是很多开发者在实际项目中容易忽略的关键点。


一、什么是防抖和节流?

✅ 防抖(Debounce)

  • 定义:在一段时间内,如果某个函数被多次调用,只会在最后一次调用之后延迟执行一次。
  • 适用场景:用户输入搜索关键词时,防止每打一个字就发一次请求;或者表单验证频繁触发。

✅ 节流(Throttle)

  • 定义:规定一个时间段内最多执行一次函数,不管在这段时间里调用了多少次。
  • 适用场景:页面滚动监听、鼠标移动事件、resize事件处理。

📝 小贴士:两者本质都是通过控制函数执行频率来提升性能,但策略不同:

特性 防抖(Debounce) 节流(Throttle)
执行时机 最后一次调用后延迟执行 每隔固定时间执行一次
是否保证执行 ❌ 不一定(可能被取消) ✅ 保证周期性执行
常见用途 输入搜索、自动保存 滚动监听、拖拽

二、经典实现 vs 支持 leading 的增强版

我们先看最常见的两种实现方式:

1. 基础防抖(无 leading)

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

这个版本的问题是:永远不会立即执行,即使第一次调用也得等 delay 时间后才执行。这对于某些业务逻辑来说不够灵活。

2. 基础节流(无 leading)

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

同样,它也不支持“首次立即执行”的需求。


三、为什么要引入 Leading(立即执行)?

举个例子:

✅ 用户在搜索框输入时,希望第一次输入就立刻发起请求(比如显示热门推荐),而不是等到用户停止输入再查。

✅ 在滚动事件中,我们想让页面刚加载完就立刻执行一次 scroll 处理逻辑(比如高亮当前 section),而不是等待用户滚动后再执行。

这就是为什么我们需要一个支持 leading 参数的版本!


四、手写支持 leading 的 Debounce 实现

我们来一步步构建一个完整的、可配置的防抖函数:

✅ 核心思路:

  • 如果设置了 leading: true,则在第一次调用时立即执行;
  • 后续调用会重置定时器;
  • 若定时器到期前再次调用,则不会再次执行(除非超时);
  • 提供 trailing 控制是否允许最后执行(默认为 true);
  • 使用闭包维护状态:timeoutId, lastCallTime 等。

✅ 完整代码如下:

function debounce(fn, delay, options = {}) {
  const { leading = false, trailing = true } = options;

  let timeoutId;
  let lastCallTime = 0;

  const later = () => {
    const now = Date.now();
    // 如果 trailing 为 false,则不执行最后一次调用
    if (trailing && now - lastCallTime >= delay) {
      fn.apply(this, arguments);
    }
    timeoutId = null;
  };

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

    // 第一次调用时,若 leading 为 true,则立即执行
    if (leading && !timeoutId) {
      fn.apply(this, args);
      lastCallTime = now;
    }

    // 清除之前的定时器
    clearTimeout(timeoutId);

    // 设置新的定时器
    timeoutId = setTimeout(later, delay);

    // 如果不是 leading 或者已经执行过一次,则记录时间戳用于 trailing 判断
    if (!leading || timeoutId) {
      lastCallTime = now;
    }
  };

  // 取消功能(可选)
  debounced.cancel = () => {
    clearTimeout(timeoutId);
    timeoutId = null;
  };

  return debounced;
}

🔍 关键逻辑说明:

条件 行为
leading === true 且首次调用 立即执行函数,并记录时间
后续调用 清除旧定时器,设置新定时器
定时器到期 执行函数(仅当 trailing 为 true)
cancel() 主动清除定时器,不再执行

✅ 测试用例(建议复制到浏览器 console 中测试):

const log = console.log.bind(console);

const debouncedFn = debounce(
  (msg) => log(`执行了:${msg}`),
  1000,
  { leading: true, trailing: true }
);

// 测试1:连续调用三次,应该只执行两次(第一次立即 + 最后一次延迟)
debouncedFn("A");
setTimeout(() => debouncedFn("B"), 500); // 仍在延迟期内
setTimeout(() => debouncedFn("C"), 1200); // 超过延迟期,触发最后一次

// 输出:
// 执行了:A     ← leading 触发
// 执行了:C     ← trailing 触发

💡 注意:这里 C 是在 1200ms 后触发的,因为 B 和 C 之间间隔小于 1000ms,所以 B 被丢弃,最终只保留 C。


五、手写支持 leading 的 Throttle 实现

节流比防抖更简单一些,因为我们只需要控制执行频率即可。

✅ 支持 leading 的 throttle 实现:

function throttle(fn, delay, options = {}) {
  const { leading = true, trailing = true } = options;

  let lastCallTime = 0;
  let timeoutId = null;

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

    // 如果没有上次调用时间,或者已超过 delay,直接执行
    if (leading && !lastCallTime) {
      fn.apply(this, args);
      lastCallTime = now;
      return;
    }

    // 如果距离上次调用已经超过 delay,直接执行
    if (now - lastCallTime >= delay) {
      fn.apply(this, args);
      lastCallTime = now;
      return;
    }

    // 如果设置了 trailing,且还没设置定时器,则注册一个尾部执行
    if (trailing && !timeoutId) {
      timeoutId = setTimeout(() => {
        fn.apply(this, args);
        lastCallTime = Date.now();
        timeoutId = null;
      }, delay - (now - lastCallTime));
    }
  };

  // 取消功能(可选)
  throttled.cancel = () => {
    clearTimeout(timeoutId);
    timeoutId = null;
  };

  return throttled;
}

✅ 测试示例:

const throttledFn = throttle(
  (msg) => log(`执行了:${msg}`),
  1000,
  { leading: true, trailing: true }
);

throttledFn("A"); // 立即执行
setTimeout(() => throttledFn("B"), 300); // 不执行
setTimeout(() => throttledFn("C"), 1200); // 执行(因为距离 A 已经超过 1s)

输出:

执行了:A
执行了:C

⚠️ 注意:这里的 trailing 机制是在中间调用未达到 delay 时,通过定时器模拟“延迟执行”,这是节流的核心技巧之一。


六、对比总结:Debounce vs Throttle with Leading

功能 Debounce + Leading Throttle + Leading
第一次调用 ✅ 立即执行 ✅ 立即执行
中间调用 ❌ 不执行(除非超时) ❌ 不执行(除非超时)
最后一次调用 ✅ 延迟执行(如果 trailing=true) ✅ 延迟执行(如果 trailing=true)
执行频率 最多一次/延迟结束 每 delay 时间最多一次
是否保证执行 ❌ 不一定(可能被取消) ✅ 保证周期性执行
使用场景 输入搜索、自动保存 滚动监听、鼠标移动

七、常见陷阱 & 最佳实践

❗ 陷阱1:忘记清理定时器导致内存泄漏

  • 解决方案:提供 .cancel() 方法,手动释放资源。

❗ 陷阱2:this 指向错误(尤其是在类方法中)

  • 解决方案:使用 fn.apply(this, args) 确保上下文正确。

❗ 陷阱3:误以为 debounce 总能减少请求次数

  • 实际上:如果用户快速输入并保持高速率,可能导致所有请求都被丢弃(除非你加了一个 fallback)。

✅ 最佳实践建议:

  1. 优先选择合适的模式
    • 搜索建议 → debounce({ leading: true })
    • 滚动监听 → throttle({ leading: true, trailing: true })
  2. 始终提供 cancel 接口,方便组件卸载时清理。
  3. 不要滥用:过度封装反而增加复杂度,考虑是否真的需要这些优化。
  4. 结合防抖+节流:有些场景可以组合使用(如先防抖再节流),但需谨慎设计。

八、进阶:如何将这两个工具封装成通用模块?

我们可以创建一个统一的工具库文件,比如 utils/debounce-throttle.js

// utils/debounce-throttle.js
export function debounce(fn, delay, options = {}) {
  // 上面实现...
}

export function throttle(fn, delay, options = {}) {
  // 上面实现...
}

// 导出常用组合
export const debounceSearch = (fn, delay) =>
  debounce(fn, delay, { leading: true });

export const throttleScroll = (fn, delay) =>
  throttle(fn, delay, { leading: true, trailing: true });

这样你在项目中就可以这样用:

import { debounceSearch } from './utils/debounce-throttle';

const searchHandler = debounceSearch(async (query) => {
  const res = await fetch(`/api/search?q=${query}`);
  renderResults(res.data);
}, 300);

九、结语

今天我们不仅手写了支持 leading 的 debounce 和 throttle,还深入理解了它们的区别、适用场景以及潜在陷阱。这些技术虽然看似简单,但在大型项目中却至关重要。

记住一句话:

好的性能优化不是堆砌代码,而是精准控制执行节奏。

希望今天的分享对你有帮助!如果你正在做前端性能优化,不妨试试把这些工具融入你的项目中,你会发现用户体验显著提升。

下节课我们将讲解如何用 React Hooks 实现类似的防抖节流逻辑 —— 敬请期待!

🔚

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注