手写原生JS节流函数代码实现
深入解析原生 JavaScript 节流函数(Throttle)的核心实现原理与手写步骤,包含基于时间戳与基于定时器的基础方案,以及支持 leading、trailing 与 cancel 的工程级完备实现。
一、节流的核心工作原理与防抖的区别
在深入编写代码之前,必须先厘清节流与防抖(Debounce)的本质差异。两者虽然都是针对高频事件的前端性能优化手段,但控制节奏的哲学完全不同:
- 节流(Throttle):控制执行频率。无论高频事件被触发了多少次,只要时间还在设定的周期范围内,就绝不允许多次执行;一旦周期时间到达,就会规律地释放一次执行。就像水龙头被拧紧成每两秒滴下一滴水,水流始终在匀速流出。
- 防抖(Debounce):控制执行时机。只有当高频事件彻底停止、且维持了一段固定的静默时间后,目标函数才会被执行一次;如果在等待期间再次发生触发,则重新计算等待时间。就像电梯关门,每次有人进入都会重置关门倒计时。
从图 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);
}
};
}
定时器模式的执行特性
正如上图所示,定时器方案的优缺点恰好与时间戳方案相反:
- 首次触发有延迟:即便用户只是孤立地点击了一次屏幕,函数也必须等待
delay毫秒后才会执行,在需要即时反馈的交互中会感到响应慢半拍。 - 保证最后一次状态被执行:当用户连续拖拽并松开鼠标后,最后一次触发注册的定时器依然会在倒计时结束时触发,确保了拖拽释放后的最终坐标能够被正确记录。
四、综合演进:实现首尾均可配置的完备节流函数
工业级的工具库(如 Lodash、Underscore)往往将时间戳与定时器两者的优势进行融合,通过一个 options 参数允许开发者自由配置:
leading: true/false—— 控制是否在周期刚开始时立即执行(领先沿)。trailing: true/false—— 控制是否在周期结束时补充执行最后一次触发(后延沿)。
核心数学推导:剩余等待时间 remaining
完备节流的核心算法建立在对剩余时间 remaining 的实时计算上:
const remaining = delay - (now - lastTime);
这个简单的公式将所有可能的执行状态清晰地划分为两大分支:
-
当
remaining <= 0 || remaining > delay: 说明距离上次执行的时间已经超过了delay(或者用户修改了系统时间导致时钟倒退)。此时应立刻执行目标函数,清除残留的定时器,并将lastTime更新为当前时间。 -
当
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() 清理内部计时器。 |
七、总结
手写原生节流函数不仅仅是为了应对前端面试,更是一项非常实用的底层工程技能。掌握节流的核心在于理解时间轴上的采样控制:
基础版本中,时间戳通过时间差计算直接控制领先沿(Leading),响应敏捷;定时器通过异步宏任务控制后延沿(Trailing),保证末尾状态。而完整版本则巧妙地利用 remaining = delay - (now - lastTime) 作为统一标尺,将两种机制有机整合,形成了兼具即时响应与状态完整性的标准节流方案。
crypto-js 处理 Base64 和 Hex 编码转换的正确姿势
2026-11-05 16:39:11
ammo.js 内存管理:避免 Wasm 环境下的内存泄漏技巧
2026-11-05 10:21:09
JS几种补零方法的性能对比与选择建议
2026-11-04 21:17:50
JS数组去重最佳实践:Set、filter与reduce实现方案
2026-11-04 10:57:15
基准元素选择策略对 JavaScript 快速排序性能的影响
2026-11-03 22:37:29








