手写JS原生防抖与节流函数实现代码
详细讲解JavaScript原生防抖(debounce)与节流(throttle)函数的手写实现原理,包含基础版、立即执行、取消功能及二者的场景选型决策指南。
假设我们正在开发一个商品搜索页面,需要在搜索框的 input 事件中监听用户输入并实时向后端发送联想词查询请求。如果用户连续键入 10 个字符,浏览器就会在数百毫秒内密集发出 10 次异步请求。类似地,在监听浏览器窗口的 scroll 或 resize 事件时,事件触发频率甚至高达每秒 60 次以上。如果每次触发都无节制地执行 DOM 计算或网络通信,页面很快就会出现肉眼可见的卡顿、重排抖动甚至请求乱序返回。
防抖(Debounce)与节流(Throttle)正是解决这类高频事件触发问题的核心优化手段。本文将直接从核心原理切入,手写出具备生产级可用性(支持 this 绑定、参数传递、立即执行与主动取消)的原生 JavaScript 防抖与节流代码,并给出清晰的选型标准。
一、手写原生防抖函数(Debounce)
防抖的核心思想在于“等待静默期”:当事件被高频触发时,不立即执行目标函数,而是推迟设定的时间间隔 wait;如果在此间隔内该事件再次被触发,则清空上一轮的计时器并重新计算时间。直到用户彻底停止触发且静默时间达到 wait,目标函数才会真正执行一次。
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 毫秒内,保证单位时间内至多触发一次。它就像现实生活中的水龙头滴水阀门,即使上游水流再汹涌,水滴也只能以每隔一秒一滴的平稳节奏滴落。
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 毫秒的防抖及节流函数的响应情况:
| 对比维度 | 防抖函数(Debounce) | 节流函数(Throttle) |
|---|---|---|
| 核心机制 | 等待并只执行最后一次(或立即执行后进入冷却静默) | 固定间隔时间内有节奏地至多执行一次 |
| 连续触发时的表现 | 只要触发不停止,目标函数就一直被往后推迟,不执行 | 即使持续触发,也会每隔一个周期间隔恒定执行一次 |
| 执行频次 | 连续操作期间仅 1 次(或首尾 1~2 次) | 与持续触发总时长成正比:约为 持续时间 / wait 次 |
| 主要心智模型 | “等你彻底做完我再动”(如打字完毕再搜索) | “不管你怎么催,我每 200ms 处理一批”(如滚动刷新) |
四、实际业务场景与选型决策指南
面对具体需求时,究竟应该使用防抖还是节流?我们可以通过下方结构化的逻辑决策图快速判定:
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) 生成包装后的函数实例,再将该实例挂载到事件监听器上。
原生 DOM 事件监听器中,回调函数的 this 默认指向触发该事件的 DOM 节点(如 input 或 window),并且第一个参数是 Event 事件对象。如果在手写包装函数时随意使用了外层箭头函数或直接丢弃了 arguments,会导致原业务函数中无法使用 this.value 或 e.target。因此,外层包装函数必须使用普通的 function 关键字,并通过 fn.apply(context, args) 进行精准上下文转发。
在 Vue 或 React 等现代前端框架中,如果用户在输入框输入内容后立刻切换路由页面,原组件虽然卸载,但防抖函数闭包中的 setTimeout 仍在后台排队等待触发。一旦定时器触发,可能会尝试更新一个已销毁组件的状态从而报错。因此,在组件的 beforeUnmount (Vue) 或 useEffect return (React) 中,务必调用 debouncedFn.cancel() 清理未决任务。
tween.js 内存泄漏预防:怎么正确停止动画与清理对象
2026-11-05 15:52:56
Vue或React项目中为何不建议直接使用JS刷新页面
2026-11-05 13:57:30
哪里可以找到高质量的免费JS脚本资源?
2026-11-05 13:15:03
JS数组对象怎么根据指定ID字段去重
2026-11-04 21:41:10
JS数组去重方法性能对比:Set、Map与Filter谁更快
2026-11-04 16:14:15
相关标签:








