手写 JavaScript 节流和防抖函数代码实现
深入浅出讲解 JavaScript 节流(throttle)与防抖(debounce)的核心原理。提供从基础版闭包实现到支持立即执行、尾部保留、取消能力的生产级完整手写代码。
this 指向正确、支持前置/后置触发以及支持主动取消的完整代码实现。
一、防抖与节流的核心差异与执行时序
初学者常常将防抖与节流混淆,其实只需抓住两者的核心设计目标即可快速区分:
- 防抖(Debounce):核心是“等待静止后执行”。只要你在规定时间内再次触发,上一次计时就会被彻底清零重新计算,直到你在设定的时间段内不再有动作,才真正执行最后一次调用。
- 节流(Throttle):核心是“固定频率稀释”。在连续高频触发的过程中,它会把密集的事件流稀释为每隔固定时间周期(如 200ms)匀速执行一次,中间的多次触发会被直接忽略或推迟到下个周期。
正如时序图所示,假设时间窗口为 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(),当防抖函数绑定在 DOM 的点击事件或者 Vue/React 组件方法上时,fn 内部的 this 会丢失,在非严格模式下退化为 window,在严格模式下变为 undefined。通过 const context = this 配合 fn.apply(context, args),能够确保目标函数内部准确获取到触发事件的 DOM 节点或实例对象。
2. 进阶版防抖:支持立即执行(Immediate / Leading)与手动取消(Cancel)
在许多实际业务场景中,我们希望在用户第一次点击时立刻响应(例如提交按钮点击后立即发起,随后进入防抖冷却期),而不是等静止后才响应。此外,当页面路由跳转或组件销毁时,正在计时的定时器需要能够被主动取消(Cancel),避免内存泄漏或在已卸载组件上触发回调。
/**
* 完整增强版防抖函数
* @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:停止触发后,最后一次操作仍会延迟补齐执行(尾节流)。
/**
* 生产级高精度节流函数
* @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。只需问自己一个问题:“在这一连串动作发生时,我需要感知中间的过程变化,还是只在乎最后的静止结果?”
| 业务场景 | 推荐方案 | 设计原因与选型分析 |
|---|---|---|
| 搜索框实时联想 (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 保持同一个防抖实例引用。
this 绑定、leading / trailing 双端支持以及 cancel 销毁机制,不仅能轻松应对代码考核,更能帮助你在面对复杂的真实业务性能优化时游刃有余。
JS动态构建复杂嵌套JSON对象的方法
2026-11-05 15:41:52
JS负数取余为什么是负数?详解余数与模运算区别
2026-11-05 13:34:41
JS 解压出现乱码或报错怎么解决?
2026-11-05 12:32:38
JS 中 startsWith、indexOf 与正则判断字符串前缀的对比
2026-11-04 22:19:43
JS解析Excel后怎么将数据转换为JSON格式
2026-11-04 20:58:28
相关标签:








