手写JS原生防抖与节流函数实现代码

作者:霁月

时间:2026-11-03

浏览:357

详细讲解JavaScript原生防抖(debounce)与节流(throttle)函数的手写实现原理,包含基础版、立即执行、取消功能及二者的场景选型决策指南。

文章目录
JavaScript 手写核心 性能优化 阅读时长约 8 分钟
手写JS原生防抖与节流函数实现代码
图 0:高频事件原始触发序列与防抖、节流处理后执行时序示意图

假设我们正在开发一个商品搜索页面,需要在搜索框的 input 事件中监听用户输入并实时向后端发送联想词查询请求。如果用户连续键入 10 个字符,浏览器就会在数百毫秒内密集发出 10 次异步请求。类似地,在监听浏览器窗口的 scroll 或 resize 事件时,事件触发频率甚至高达每秒 60 次以上。如果每次触发都无节制地执行 DOM 计算或网络通信,页面很快就会出现肉眼可见的卡顿、重排抖动甚至请求乱序返回。

防抖(Debounce)与节流(Throttle)正是解决这类高频事件触发问题的核心优化手段。本文将直接从核心原理切入,手写出具备生产级可用性(支持 this 绑定、参数传递、立即执行与主动取消)的原生 JavaScript 防抖与节流代码,并给出清晰的选型标准。

一、手写原生防抖函数(Debounce)

防抖的核心思想在于“等待静默期”:当事件被高频触发时,不立即执行目标函数,而是推迟设定的时间间隔 wait;如果在此间隔内该事件再次被触发,则清空上一轮的计时器并重新计算时间。直到用户彻底停止触发且静默时间达到 wait,目标函数才会真正执行一次。

手写JS原生防抖与节流函数实现代码
图 1:手写防抖函数(Debounce)内部执行流程与状态流转示意图

1. 基础版防抖实现

最精简的防抖利用闭包保存 timer 变量。每次事件触发时调用 clearTimeout(timer),然后再创建新的延时计时器:

// 基础版防抖函数
function debounceBasic(fn, wait) {
  let timer = null;

  return function (...args) {
    // 每次触发,清除前一次未执行的计时器
    if (timer) {
      clearTimeout(timer);
    }
    
    // 重新设定定时器,延迟 wait 毫秒后执行真实函数
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, wait);
  };
}

2. 支持立即执行(Immediate / Leading)与主动取消(Cancel)的完整手写版

在实际生产开发中,经常会有“点击按钮立即发起提交,但防止后续 500ms 内因手抖连点而重复触发”的需求。同时,在 SPA 框架单页面应用中,若页面卸载或组件销毁时定时器仍在等待,容易导致内存泄露,因此必须挂载 cancel 取消方法。

/**
 * 原生完整版防抖函数
 * @param {Function} fn 需要防抖的目标函数
 * @param {number} wait 等待的静默时间(毫秒)
 * @param {boolean} immediate 是否在首次触发时立即执行
 * @return {Function} 返回包装后的防抖函数
 */
function debounce(fn, wait = 300, immediate = false) {
  let timer = null;

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

    // 如果已有定时器,清除上一轮等待
    if (timer) {
      clearTimeout(timer);
    }

    if (immediate) {
      // 判断当前是否处于可立即执行的状态(即此前没有定时器在排队)
      const callNow = !timer;

      // 设置定时器,经过 wait 毫秒后重置 timer,恢复可再次立即执行的资格
      timer = setTimeout(() => {
        timer = null;
      }, wait);

      if (callNow) {
        fn.apply(context, args);
      }
    } else {
      // 标准非立即执行逻辑:在等待期满后执行
      timer = setTimeout(() => {
        fn.apply(context, args);
        timer = null;
      }, wait);
    }
  }

  // 挂载取消方法,用于清理内存与未决的定时任务
  debounced.cancel = function () {
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }
  };

  return debounced;
}

二、手写原生节流函数(Throttle)

节流的核心思想在于“固定节拍”:在高频事件持续发生的整个过程中,将函数的执行频率锁定在固定周期 wait 毫秒内,保证单位时间内至多触发一次。它就像现实生活中的水龙头滴水阀门,即使上游水流再汹涌,水滴也只能以每隔一秒一滴的平稳节奏滴落。

手写JS原生防抖与节流函数实现代码
图 2:节流函数中时间戳法与定时器法运行机制及综合策略架构图

1. 时间戳版节流(首发立即执行)

记录上一次执行的时间戳 previous。每次事件被触发时取当前时间 Date.now(),如果差值大于等于 wait,则立刻执行并将 previous 更新为当前时间:

// 时间戳版节流
function throttleTimestamp(fn, wait) {
  let previous = 0;

  return function (...args) {
    const now = Date.now();
    if (now - previous >= wait) {
      fn.apply(this, args);
      previous = now;
    }
  };
}

2. 定时器版节流(末次延后保底)

维护一个 timer 标志。当 timer 为空时开启定时器;在定时器回调内执行真实函数,并在执行后将 timer 置为 null,以释放下一次进入的权限:

// 定时器版节流
function throttleTimer(fn, wait) {
  let timer = null;

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

3. 结合时间戳与定时器的完整节流实现(支持 leading 与 trailing 配置)

单纯时间戳版无法执行用户停止操作后的最后一次事件(例如拖拽结束瞬间的停靠坐标),而单纯定时器版首发又有迟钝感。结合二者即可实现功能完备的节流函数:

/**
 * 原生完整版节流函数
 * @param {Function} fn 目标执行函数
 * @param {number} wait 节流周期(毫秒)
 * @param {Object} options 配置项 leading (是否首发立即触发), trailing (是否在最后一次停止后补刀触发)
 */
function throttle(fn, wait = 300, options = {}) {
  let timer = null;
  let previous = 0;
  
  // 默认 leading 和 trailing 均为 true
  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,或由于系统时钟回拨导致 remaining > wait
    if (remaining <= 0 || remaining > wait) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      previous = now;
      fn.apply(context, args);
    } else if (!timer && trailing) {
      // 在剩余时间内,如果没有排队任务且允许 trailing,则启动定时器兜底最后一次调用
      timer = setTimeout(() => {
        previous = leading ? Date.now() : 0;
        timer = null;
        fn.apply(context, args);
      }, remaining);
    }
  }

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

  return throttled;
}

三、防抖与节流执行时序对比

为了直观对比两者的动作差异,下方的时序对比图展示了在持续 800 毫秒的高频连续事件流中,原始事件与设定 200 毫秒的防抖及节流函数的响应情况:

手写JS原生防抖与节流函数实现代码
图 3:防抖与节流在 0~1000ms 持续高频触发序列下的实际响应时序对比
对比维度 防抖函数(Debounce) 节流函数(Throttle)
核心机制 等待并只执行最后一次(或立即执行后进入冷却静默) 固定间隔时间内有节奏地至多执行一次
连续触发时的表现 只要触发不停止,目标函数就一直被往后推迟,不执行 即使持续触发,也会每隔一个周期间隔恒定执行一次
执行频次 连续操作期间仅 1 次(或首尾 1~2 次) 与持续触发总时长成正比:约为 持续时间 / wait 次
主要心智模型 “等你彻底做完我再动”(如打字完毕再搜索) “不管你怎么催,我每 200ms 处理一批”(如滚动刷新)

四、实际业务场景与选型决策指南

面对具体需求时,究竟应该使用防抖还是节流?我们可以通过下方结构化的逻辑决策图快速判定:

手写JS原生防抖与节流函数实现代码
图 4:高频前端交互场景下防抖与节流的技术选型决策树

1. 搜索框实战案例(防抖)

// 获取搜索框元素
const searchInput = document.querySelector('#search-input');

// 具体的网络搜索逻辑
function fetchSuggest(event) {
  const keyword = event.target.value.trim();
  if (!keyword) return;
  console.log('向后端发送请求,关键词:', keyword);
}

// 包装成 350ms 的防抖监听函数
const handleInputDebounced = debounce(fetchSuggest, 350);

// 绑定事件监听
searchInput.addEventListener('input', handleInputDebounced);

2. 页面滚动位置计算案例(节流)

// 页面滚动位置监听
function onPageScroll() {
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
  console.log('当前页面滚动距离:', scrollTop);
  
  // 当滚动距离超过 400px 时显示“返回顶部”按钮
  const backToTopBtn = document.querySelector('#back-to-top');
  if (backToTopBtn) {
    backToTopBtn.style.display = scrollTop > 400 ? 'block' : 'none';
  }
}

// 采用 200ms 周期节流处理,保证平滑度的同时大幅降低事件循环负载
const handleScrollThrottled = throttle(onPageScroll, 200);

window.addEventListener('scroll', handleScrollThrottled);

五、手写代码常见陷阱与注意事项

⚠️ 陷阱一:千万不要在内联事件中直接调用高阶函数

初学者容易犯下此类错误:input.addEventListener('input', () => debounce(fn, 300)())。这样每次事件触发时都会重新生成一个全新的闭包,导致之前的 timer 被丢弃,防抖完全失效!正确的做法是先执行 debounce(fn, wait) 生成包装后的函数实例,再将该实例挂载到事件监听器上。

⚠️ 陷阱二:保证 this 上下文与 Event 对象完整透传

原生 DOM 事件监听器中,回调函数的 this 默认指向触发该事件的 DOM 节点(如 input 或 window),并且第一个参数是 Event 事件对象。如果在手写包装函数时随意使用了外层箭头函数或直接丢弃了 arguments,会导致原业务函数中无法使用 this.value 或 e.target。因此,外层包装函数必须使用普通的 function 关键字,并通过 fn.apply(context, args) 进行精准上下文转发。

⚠️ 陷阱三:单页面应用组件销毁时务必执行 cancel

在 Vue 或 React 等现代前端框架中,如果用户在输入框输入内容后立刻切换路由页面,原组件虽然卸载,但防抖函数闭包中的 setTimeout 仍在后台排队等待触发。一旦定时器触发,可能会尝试更新一个已销毁组件的状态从而报错。因此,在组件的 beforeUnmount (Vue) 或 useEffect return (React) 中,务必调用 debouncedFn.cancel() 清理未决任务。

实用工具推荐

电子包浆

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

使用 ›
图片压缩

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

使用 ›
相关文章