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

作者:听澜

时间:2026-11-03

浏览:160

深入解析原生 JavaScript 节流函数(Throttle)的核心实现原理与手写步骤,包含基于时间戳与基于定时器的基础方案,以及支持 leading、trailing 与 cancel 的工程级完备实现。

文章目录
节流(Throttle)的核心目的,是限制连续高频触发的事件执行频率,确保目标函数在规定的时间窗口内最多只执行一次。在前端开发中,面对窗口缩放(resize)、页面滚动(scroll)以及鼠标轨迹跟踪(mousemove)等每秒能产生数十次甚至上百次调用的事件流,若每次都直接执行 DOM 读写或昂贵的计算,极易造成页面掉帧与卡顿。本文将从基础的时间戳与定时器双模型出发,逐步推导并实现一个兼顾领先沿(leading)、后延沿(trailing)与取消功能的完备原生节流函数。
手写原生JS节流函数代码实现

一、节流的核心工作原理与防抖的区别

在深入编写代码之前,必须先厘清节流与防抖(Debounce)的本质差异。两者虽然都是针对高频事件的前端性能优化手段,但控制节奏的哲学完全不同:

  • 节流(Throttle):控制执行频率。无论高频事件被触发了多少次,只要时间还在设定的周期范围内,就绝不允许多次执行;一旦周期时间到达,就会规律地释放一次执行。就像水龙头被拧紧成每两秒滴下一滴水,水流始终在匀速流出。
  • 防抖(Debounce):控制执行时机。只有当高频事件彻底停止、且维持了一段固定的静默时间后,目标函数才会被执行一次;如果在等待期间再次发生触发,则重新计算等待时间。就像电梯关门,每次有人进入都会重置关门倒计时。
手写原生JS节流函数代码实现
图 1:在持续 800ms 的连续触发过程中,原始事件、防抖与节流的调用时序对比

从图 1 可以看出,在用户持续滚动的整个生命周期内,节流能提供平滑、连续的中间反馈,因此特别适合用于页面滚动进度监听、无限下拉加载曝光以及 Canvas 笔触绘制。

二、实现路径一:基于“时间戳”的立即执行节流

时间戳实现是节流最直观的模式。其核心思路是维护一个记录上一次成功执行时间的变量 lastTime。每当事件触发时,获取当前时间戳 Date.now(),计算两者之差是否大于等于设定的间隔 delay:

/**
 * 基于时间戳的节流函数(领先沿立即触发)
 * @param {Function} fn 需要节流的目标函数
 * @param {number} delay 节流周期(毫秒)
 * @returns {Function} 包装后的节流函数
 */
function throttleTimestamp(fn, delay) {
  let lastTime = 0;

  return function(...args) {
    const now = Date.now();
    // 首次调用时 lastTime 为 0,now - 0 必定大于 delay,因此会立即执行
    if (now - lastTime >= delay) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
}

时间戳模式的执行特性

时间戳方案结构极度简洁,但存在一个鲜明的特点:首次调用无延迟,立即执行;最后一次停止后无收尾。

在事件流第一次到达时,now - 0 >= delay 条件必然成立,函数会在第一时间被调用,这保证了良好的交互即时性。然而,当用户停止滑动时,如果最后一次滑动事件距离上一次执行不足 delay 毫秒,由于没有任何定时器维持等待,这次最终状态将被直接丢弃。

三、实现路径二:基于“定时器”的延迟执行节流

为了解决时间戳版本无法保留最后一次状态的问题,另一种基础思路是依靠 setTimeout。其核心逻辑是检查当前是否存在活跃的定时器:若不存在,则创建一个;定时器到期后执行目标函数,并将定时器变量重置为 null:

/**
 * 基于定时器的节流函数(后延沿延迟触发)
 * @param {Function} fn 需要节流的目标函数
 * @param {number} delay 节流周期(毫秒)
 * @returns {Function} 包装后的节流函数
 */
function throttleTimer(fn, delay) {
  let timer = null;

  return function(...args) {
    const context = this;
    
    // 如果已有定时器在等待,则直接跳过当前触发
    if (!timer) {
      timer = setTimeout(() => {
        timer = null;
        fn.apply(context, args);
      }, delay);
    }
  };
}
手写原生JS节流函数代码实现
图 2:时间戳方案与定时器方案的机制与优缺点对比

定时器模式的执行特性

正如上图所示,定时器方案的优缺点恰好与时间戳方案相反:

  • 首次触发有延迟:即便用户只是孤立地点击了一次屏幕,函数也必须等待 delay 毫秒后才会执行,在需要即时反馈的交互中会感到响应慢半拍。
  • 保证最后一次状态被执行:当用户连续拖拽并松开鼠标后,最后一次触发注册的定时器依然会在倒计时结束时触发,确保了拖拽释放后的最终坐标能够被正确记录。

四、综合演进:实现首尾均可配置的完备节流函数

工业级的工具库(如 Lodash、Underscore)往往将时间戳与定时器两者的优势进行融合,通过一个 options 参数允许开发者自由配置:

  • leading: true/false —— 控制是否在周期刚开始时立即执行(领先沿)。
  • trailing: true/false —— 控制是否在周期结束时补充执行最后一次触发(后延沿)。
手写原生JS节流函数代码实现
图 3:完备节流算法中剩余时间计算与分支执行状态流转步骤

核心数学推导:剩余等待时间 remaining

完备节流的核心算法建立在对剩余时间 remaining 的实时计算上:

const remaining = delay - (now - lastTime);

这个简单的公式将所有可能的执行状态清晰地划分为两大分支:

  1. 当 remaining <= 0 || remaining > delay: 说明距离上次执行的时间已经超过了 delay(或者用户修改了系统时间导致时钟倒退)。此时应立刻执行目标函数,清除残留的定时器,并将 lastTime 更新为当前时间。
  2. 当 remaining > 0 且开启了 trailing: 说明仍在当前周期的冷却期内。此时如果尚未建立定时器,就启动一个延迟为 remaining 的 setTimeout,并在等待期间不断暂存最新的实参 args 与上下文 this。

完整手写代码清单

/**
 * 完整原生 JavaScript 节流函数(支持 leading、trailing 与 cancel)
 * @param {Function} fn 目标函数
 * @param {number} delay 周期时长(毫秒)
 * @param {Object} [options] 配置选项
 * @param {boolean} [options.leading=true] 是否在节流开始前立即执行一次
 * @param {boolean} [options.trailing=true] 是否在节流结束后补充执行最后一次
 * @returns {Function} 带有 cancel 功能的包装函数
 */
function throttle(fn, delay, options = {}) {
  let timer = null;
  let lastTime = 0;
  let trailingArgs = null;
  let trailingContext = null;

  // 默认两端均开启
  const leading = options.leading !== false;
  const trailing = options.trailing !== false;

  // 统一的执行并重置逻辑
  const invoke = (context, args) => {
    lastTime = leading ? Date.now() : 0;
    timer = null;
    trailingArgs = null;
    trailingContext = null;
    fn.apply(context, args);
  };

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

    // 首次触发且 leading 为 false 时,将 lastTime 对齐到当前时间,阻止立即执行
    if (!lastTime && !leading) {
      lastTime = now;
    }

    const remaining = delay - (now - lastTime);

    // 状态 A:时间已到或时钟倒退,立即执行
    if (remaining <= 0 || remaining > delay) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      invoke(this, args);
    } 
    // 状态 B:仍在周期内,若允许尾部执行且无现有定时器,开启剩余时间倒计时
    else if (trailing && !timer) {
      trailingArgs = args;
      trailingContext = this;

      timer = setTimeout(() => {
        lastTime = leading ? Date.now() : 0;
        timer = null;
        if (trailingArgs) {
          fn.apply(trailingContext, trailingArgs);
          trailingArgs = null;
          trailingContext = null;
        }
      }, remaining);
    } 
    // 状态 C:已有定时器在等待,更新参数为最新实参
    else if (trailing && timer) {
      trailingArgs = args;
      trailingContext = this;
    }
  };

  // 取消方法:用于在组件卸载或手动停止时清理
  throttled.cancel = function() {
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }
    lastTime = 0;
    trailingArgs = null;
    trailingContext = null;
  };

  return throttled;
}

五、真实场景实战与效果验证

编写完节流函数后,我们通过一个监听网页垂直滚动高度并更新阅读进度条的实际用例,来验证它的执行效果与性能表现:

// 1. 待节流的目标业务函数
function updateReadingProgress(e) {
  const scrollTop = window.scrollY || document.documentElement.scrollTop;
  const docHeight = document.documentElement.scrollHeight - window.innerHeight;
  const progress = Math.min(100, Math.round((scrollTop / docHeight) * 100));
  
  console.log(`[${new Date().toISOString().slice(11, 23)}] 页面滚动计算触发,当前阅读进度: ${progress}%`);
}

// 2. 包装为 200 毫秒执行一次的节流监听器
const onScrollThrottled = throttle(updateReadingProgress, 200, {
  leading: true,
  trailing: true
});

// 3. 注册到全局滚动事件
window.addEventListener('scroll', onScrollThrottled);

// 4. 当离开当前页面或 SPA 组件卸载时释放监听
function cleanup() {
  window.removeEventListener('scroll', onScrollThrottled);
  onScrollThrottled.cancel(); // 确保正在排队的 trailing 定时器被清除
}
执行对比验证输出

在未节流的情况下,快速滚动鼠标 1 秒钟会在控制台打印 80 ~ 120 次日志;而在应用 delay = 200ms 的节流后,输出严格保持在每秒 5 次左右,同时保留了首次滚动立刻触发与滚动停止后的最后一次精准修正。

六、高频面试与工程避坑指南

在实际编写与使用节流函数时,有几个非常隐蔽但至关重要的细节必须注意:

考点 / 陷阱 错误写法后果 正确防范措施
上下文 this 丢失 在回调中直接使用普通函数或未绑定 this,导致 DOM 元素方法中的 this 变成 window 或 undefined 使用 fn.apply(this, args) 或在外层捕获 context = this。
事件对象截断 未处理函数参数,导致绑定的 event 事件对象无法在业务函数中获取 包装函数使用剩余参数 ...args 捕获,并在调用时全量透传。
首尾互斥配置 开发者传入 { leading: false, trailing: false } 首次由于 !leading 将 lastTime 拨快,后续又无定时器,函数将永远不执行。生产环境应做参数校验。
闭包内存泄漏 页面频繁跳转但未调用 cancel,驻留的定时器持有外部引用 在组件卸载钩子中执行 cancel() 清理内部计时器。
手写原生JS节流函数代码实现
图 4:原生节流函数工程落地全景总结与决策卡片

七、总结

手写原生节流函数不仅仅是为了应对前端面试,更是一项非常实用的底层工程技能。掌握节流的核心在于理解时间轴上的采样控制:

基础版本中,时间戳通过时间差计算直接控制领先沿(Leading),响应敏捷;定时器通过异步宏任务控制后延沿(Trailing),保证末尾状态。而完整版本则巧妙地利用 remaining = delay - (now - lastTime) 作为统一标尺,将两种机制有机整合,形成了兼具即时响应与状态完整性的标准节流方案。

实用工具推荐

电子包浆

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

使用 ›
图片压缩

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

使用 ›
相关文章