手写 JavaScript 节流和防抖函数代码实现

作者:纸飞机

时间:2026-11-03

浏览:510

深入浅出讲解 JavaScript 节流(throttle)与防抖(debounce)的核心原理。提供从基础版闭包实现到支持立即执行、尾部保留、取消能力的生产级完整手写代码。

文章目录
发布分类:JavaScript 进阶 / 性能优化 • 难度层级:进阶实用 • 运行环境:浏览器 (ECMAScript 5+) / Node.js
手写 JavaScript 节流和防抖函数代码实现
手写防抖与节流概念概览
在 Web 前端开发中,用户的很多交互行为(如输入框实时输入、页面滚动、窗口缩放、鼠标滑动等)会在极短时间内高频触发事件处理函数。若直接在这些事件中执行复杂的 DOM 计算、重排重绘或网络请求,会导致页面卡顿甚至浏览器崩溃。防抖(Debounce)与节流(Throttle)正是解决这类高频事件性能损耗的两套标准控制策略。本文直接面向实际手写需求,从最基础的核心原理开始,逐步手写出具备传参保留、this 指向正确、支持前置/后置触发以及支持主动取消的完整代码实现。

一、防抖与节流的核心差异与执行时序

初学者常常将防抖与节流混淆,其实只需抓住两者的核心设计目标即可快速区分:

  • 防抖(Debounce):核心是“等待静止后执行”。只要你在规定时间内再次触发,上一次计时就会被彻底清零重新计算,直到你在设定的时间段内不再有动作,才真正执行最后一次调用。
  • 节流(Throttle):核心是“固定频率稀释”。在连续高频触发的过程中,它会把密集的事件流稀释为每隔固定时间周期(如 200ms)匀速执行一次,中间的多次触发会被直接忽略或推迟到下个周期。
手写 JavaScript 节流和防抖函数代码实现
图 1:高频事件触发下防抖与节流的时序执行对比

正如时序图所示,假设时间窗口为 200ms:如果用户在 1 秒内连续输入 10 次,防抖函数在这 1 秒内完全不执行任何逻辑,直到停止打字 200ms 后才发送一次请求;而节流函数则会在这 1 秒内大致均匀地执行 5 次,保持规律的心跳输出。

二、手写防抖(Debounce)函数代码实现

防抖的实现基于 JavaScript 的闭包(Closure)机制。利用闭包在内存中常驻一个 timer 变量,每次外部事件触发时先检查是否存在未完成的定时器,有则清除,然后再开启一个新的定时器。

1. 基础版防抖函数

这是最常见的手写起点,我们首先需要保证两个最关键的细节:保留执行上下文(this)与透传外部事件参数(arguments)。

/**
 * 基础版防抖函数
 * @param {Function} fn 需要防抖的目标函数
 * @param {number} delay 延迟等待时间(毫秒)
 * @returns {Function} 包装后的防抖函数
 */
function debounce(fn, delay) {
  // 利用闭包保存定时器引用
  let timer = null;

  return function (...args) {
    // 保存当前的 this 上下文(例如绑定事件的 DOM 元素)
    const context = this;

    // 如果在上一个 delay 周期内再次触发,清除上一个定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 重新开始计时
    timer = setTimeout(function () {
      // 延迟到期后,以正确的 this 和参数执行目标函数
      fn.apply(context, args);
      timer = null;
    }, delay);
  };
}
重点注意事项:为什么不能直接写 fn()?
如果直接写 fn(),当防抖函数绑定在 DOM 的点击事件或者 Vue/React 组件方法上时,fn 内部的 this 会丢失,在非严格模式下退化为 window,在严格模式下变为 undefined。通过 const context = this 配合 fn.apply(context, args),能够确保目标函数内部准确获取到触发事件的 DOM 节点或实例对象。

2. 进阶版防抖:支持立即执行(Immediate / Leading)与手动取消(Cancel)

在许多实际业务场景中,我们希望在用户第一次点击时立刻响应(例如提交按钮点击后立即发起,随后进入防抖冷却期),而不是等静止后才响应。此外,当页面路由跳转或组件销毁时,正在计时的定时器需要能够被主动取消(Cancel),避免内存泄漏或在已卸载组件上触发回调。

手写 JavaScript 节流和防抖函数代码实现
图 2:手写防抖函数核心执行推导步骤
/**
 * 完整增强版防抖函数
 * @param {Function} fn 目标执行函数
 * @param {number} delay 等待时长
 * @param {boolean} immediate 是否在首次触发时立即执行
 */
function debounceEnhanced(fn, delay, immediate = false) {
  let timer = null;

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

    if (timer) {
      clearTimeout(timer);
    }

    // 如果配置了 immediate 并且当前没有在运行的定时器,说明是该周期的首次触发
    if (immediate) {
      const callNow = !timer;

      // 启动定时器,在 delay 毫秒后重置 timer 状态
      timer = setTimeout(function () {
        timer = null;
      }, delay);

      // 首次立即执行
      if (callNow) {
        fn.apply(context, args);
      }
    } else {
      // 标准延迟执行
      timer = setTimeout(function () {
        fn.apply(context, args);
        timer = null;
      }, delay);
    }
  }

  // 挂载取消方法,提供手动清理能力
  debounced.cancel = function () {
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }
  };

  return debounced;
}

三、手写节流(Throttle)函数代码实现

节流函数的实现方式一般分为时间戳版与定时器版。理解这两种写法的差异是手写高质量节流的关键。

1. 时间戳版 vs 定时器版基本实现

时间戳版:记录上一次执行的时间戳 previous,每次触发时比较 Date.now() - previous >= delay。特点是第一次触发会立即执行(首触发),但最后一次停止触发后的动作若没达到周期便不会被执行(无尾触发)。

定时器版:在触发时如果 timer 为空就启动一个延时定时器,定时器到期后执行并清空 timer。特点是第一次触发不会立即执行,必须等满 delay 毫秒才首发,但停止后会执行最后一次保存的动作。

// 【时间戳实现 - 特点:头执行,无尾执行】
function throttleTimestamp(fn, delay) {
  let previous = 0;
  return function (...args) {
    const now = Date.now();
    if (now - previous >= delay) {
      previous = now;
      fn.apply(this, args);
    }
  };
}

// 【定时器实现 - 特点:无头执行,有尾执行】
function throttleTimer(fn, delay) {
  let timer = null;
  return function (...args) {
    const context = this;
    if (!timer) {
      timer = setTimeout(function () {
        timer = null;
        fn.apply(context, args);
      }, delay);
    }
  };
}

2. 生产级融合版节流函数(支持 leading 与 trailing 配置)

单纯的时间戳或定时器在实际项目中往往不够完美:时间戳无法保证停止操作后的最终状态被正确记录(例如滚动到底部未能触发最后一次计算);而定时器在第一次操作时却有明显滞后感。

最佳的实践是将时间戳与定时器结合,并允许开发者自由配置:

  • leading = true:首次触发时立即执行(首节流);
  • trailing = true:停止触发后,最后一次操作仍会延迟补齐执行(尾节流)。
手写 JavaScript 节流和防抖函数代码实现
图 3:生产级节流函数内部状态与决策流程图
/**
 * 生产级高精度节流函数
 * @param {Function} fn 目标函数
 * @param {number} wait 限制等待时间间隔
 * @param {Object} options 配置项:leading(是否立即执行), trailing(是否尾部延迟执行)
 */
function throttle(fn, wait, options = {}) {
  let timer = null;
  let previous = 0;
  
  const leading = options.leading !== false;
  const trailing = options.trailing !== false;

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

    // 如果配置不立即执行,且这是首次触发,将 previous 置为当前时间
    if (!previous && !leading) {
      previous = now;
    }

    // 计算当前周期内还剩余多少毫秒需要等待
    const remaining = wait - (now - previous);

    // 剩余时间小于等于 0,或者系统时间被篡改倒退
    if (remaining <= 0 || remaining > wait) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      previous = now;
      fn.apply(context, args);
    } else if (!timer && trailing) {
      // 尚在时间间隔内,且开启了尾部补偿执行,并且没有排队的定时器
      timer = setTimeout(function () {
        previous = !leading ? 0 : Date.now();
        timer = null;
        fn.apply(context, args);
      }, remaining);
    }
  }

  // 手动取消
  throttled.cancel = function () {
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }
    previous = 0;
  };

  return throttled;
}

四、节流与防抖的场景选型与总结对比

在实际项目做技术选型时,不用死记硬背每个 API。只需问自己一个问题:“在这一连串动作发生时,我需要感知中间的过程变化,还是只在乎最后的静止结果?”

手写 JavaScript 节流和防抖函数代码实现
图 4:节流与防抖业务场景选型与特征对比矩阵
业务场景 推荐方案 设计原因与选型分析
搜索框实时联想 (input / keyup) 防抖 (Debounce) 用户在持续打字时,中间拼音或未完成字符发送请求不仅浪费服务器带宽,还会造成请求竞态乱序,必须等打字停止后再查。
页面滚动触底加载更多 (scroll) 节流 (Throttle) 用户在滑动长列表时,需要持续监听距离底部的距离。如果用防抖,只有等用户手指彻底停下才加载,会感到明显的滑动空档与卡顿。
表单提交 / 支付按钮防重复点击 防抖立即执行 (Immediate) 第一次点击必须立即响应提交,后续短时间内的手抖连续重复点击必须全部屏蔽。
拖拽 DOM 节点位置计算 (drag / touchmove) 节流 (Throttle) 需要平滑跟随光标坐标刷新,但又不能每帧(16ms 内多次)过度计算,以 50ms~100ms 匀速节流最为丝滑。
窗口大小缩放自适应 (window.resize) 防抖 (Debounce) 拖动窗口边界会产生数百次 resize 事件,等用户调整到满意的尺寸松手后再做重绘和图表自适应计算,性能最节省。

五、手写实现中的三大高频避坑点

在编写或面试考察手写代码时,许多开发者容易忽略边缘情况,以下是三个核心避坑指南:

1. 箭头函数导致 this 丢失

若在手写包装函数时写成 const debounce = (fn) => (...) => { ... },由于箭头函数没有自身的 this,它会直接沿用外层词法作用域的 this。当把包装后的方法赋给 DOM 事件(如 btn.onclick = debounce(...))时,内部将无法获取到 btn 节点。手写外层包装器时请务必使用传统的 function 关键字。

2. 单页面应用(SPA)中的内存泄漏与悬挂定时器

在 React 的 useEffect 或 Vue 的 onUnmounted 生命周期中,如果页面已经销毁,而防抖/节流函数的定时器仍在等待倒计时,计时器到期后仍然会尝试执行回调,甚至触发对已销毁组件的 setState 警告。因此,生产级代码一定要实现 cancel() 方法,在组件卸载钩子中显式调用:

// React 组件示例
useEffect(() => {
  const handleResize = debounce(refreshChart, 300);
  window.addEventListener('resize', handleResize);

  return () => {
    // 卸载时注销监听器并取消未完成的计时
    window.removeEventListener('resize', handleResize);
    handleResize.cancel();
  };
}, []);

3. 每次渲染重复创建防抖函数

在现代前端框架中(特别是 React 函数组件),每次组件重新渲染都会重新执行函数体。若直接在组件内写 const onClick = debounce(fn, 500),那么每次渲染都会生成一个全新的闭包实例,前一个闭包里的 timer 无法阻挡新闭包里的执行,导致防抖完全失效。必须配合 useCallback 或 useMemo、自定义 Hook 保持同一个防抖实例引用。

总结归纳
手写 JavaScript 防抖和节流函数的本质,是对异步定时器生命周期与闭包作用域的精确调度。掌握参数透传、this 绑定、leading / trailing 双端支持以及 cancel 销毁机制,不仅能轻松应对代码考核,更能帮助你在面对复杂的真实业务性能优化时游刃有余。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章