JavaScript 防抖函数 debounce 详解与源码解析

作者:纸飞机时间:2026-10-23 08:09:52浏览:207

本文深入解析JavaScript中防抖函数debounce的核心原理与多种实现方案,涵盖基础定时器实现、立即执行模式及结合节流的增强版。同时结合underscore 1.9.1源码,详细剖析其处理返回值、取消功能及边界条件的逻辑,并对比节流应用场景,帮助开发者掌握高频事件优化技巧。

防抖函数定义与直观理解

定义及解读

防抖函数(debounce)的核心机制在于:在设定的时间窗口内,无论触发多少次回调,最终仅执行最后一次调用。例如,若设定等待时间为3秒,在此期间若再次触发函数,计时器将重置并重新计算3秒,直到新的3秒内无新请求,函数才会执行;否则不断重置计时。

以公交车关门为例:司机需等待最后一位乘客上车后方可关门。每当有新乘客进入,计时器清零并重新等待1分钟。若1分钟内无新乘客,司机判定人员已齐,执行关门发车操作。

在此类比中,“上车的乘客”代表频繁触发的事件回调;“1分钟”是计时器,作为判断关门的依据;若有新乘客(新请求),计时器清零重计;“关门”则是最终执行的函数逻辑。

若对上述概念仍感抽象,可参考相关可视化图表进行理解,或点击指定页面查看节流与防抖的直观对比。图中Regular表示无处理状态,throttle为节流结果,debounce则为防抖结果。

防抖函数定义与直观理解

防抖原理与基础实现

原理及实现

其实现原理依赖于定时器(setTimeout)。首次执行时设置定时器;后续调用时,若检测到已有定时器存在,则先清除旧定时器,再创建新定时器。当定时器计时结束且未被清除时,触发原函数执行。

实现 1


// 实现 1
// fn 是需要防抖处理的函数
// wait 是时间间隔
function debounce(fn, wait = 50) {
// 通过闭包缓存一个定时器 id
let timer = null
// 将 debounce 处理结果当作函数返回
// 触发事件回调时执行这个返回函数
return function(...args) {
// 如果已经设定过定时器就清空上一次的定时器
if (timer) clearTimeout(timer)
// 开始设定一个新的定时器,定时器结束后执行传入的函数 fn
timer = setTimeout(() => {
fn.apply(this, args)
}, wait)
}
}
// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000)
// 停止滑动 1 秒后执行函数 () => console.log('fn 防抖执行了')
document.addEventListener('scroll', betterFn)

实现 2

上述基础方案已覆盖多数场景,但若需实现“首次触发立即执行”的功能,则需对debounce函数进行改写,增加立即执行逻辑。


// 实现 2
// immediate 表示第一次是否立即执行
function debounce(fn, wait = 50, immediate) {
let timer = null
return function(...args) {
if (timer) clearTimeout(timer)
// ------ 新增部分 start ------ 
// immediate 为 true 表示第一次触发后执行
// timer 为空表示首次触发
if (immediate && !timer) {
fn.apply(this, args)
}
// ------ 新增部分 end ------ 
timer = setTimeout(() => {
fn.apply(this, args)
}, wait)
}
}
// DEMO
// 执行 debounce 函数返回新函数
const betterFn = debounce(() => console.log('fn 防抖执行了'), 1000, true)
// 第一次触发 scroll 执行一次 fn,后续只有在停止滑动 1 秒后才执行函数 fn
document.addEventListener('scroll', betterFn)

该逻辑较为直观:判断传入参数immediate是否为true,并额外检查是否为首次执行(通过timer是否为空判断)。当immediate为true且timer为空时,直接执行原函数fn(即fn.apply(this, args))。

加强版节流与混合逻辑

加强版 throttle

考虑一种极端情况:若用户操作极其频繁,在设定的延迟时间结束前不断触发新操作,会导致计时器被频繁清除并重建,使得原函数fn始终无法执行,用户操作得不到响应。

一种解决思路是将“节流”与“防抖”结合,形成加强版节流函数。其核心在于:“在wait时间内允许重新生成定时器,但一旦wait时间到达,必须给用户响应”。这种混合逻辑可有效解决上述问题。

在给出合并代码前,先回顾节流(throttle)函数的定义,此前章节已有详细介绍。


// fn 是需要执行的函数
// wait 是时间间隔
const throttle = (fn, wait = 50) => {
// 上一次执行 fn 的时间
let previous = 0
// 将 throttle 处理结果当作函数返回
return function(...args) {
// 获取当前时间,转换成时间戳,单位毫秒
let now = +new Date()
// 将当前时间和上一次执行函数的时间进行对比
// 大于等待时间就把 previous 设置为当前时间并执行函数 fn
if (now - previous > wait) {
previous = now
fn.apply(this, args)
}
}
}

结合throttle与debounce的代码,加强版节流函数如下。新增逻辑在于:当当前触发时间与上次触发时间差小于时间间隔时,设立新定时器,这相当于将debounce的代码逻辑嵌入到小于时间间隔的判断分支中。


// fn 是需要节流处理的函数
// wait 是时间间隔
function throttle(fn, wait) {
// previous 是上一次执行 fn 的时间
// timer 是定时器
let previous = 0, timer = null
// 将 throttle 处理结果当作函数返回
return function (...args) {
// 获取当前时间,转换成时间戳,单位毫秒
let now = +new Date()
// ------ 新增部分 start ------ 
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔
if (now - previous < wait) {
// 如果小于,则为本次触发操作设立一个新的定时器
// 定时器时间结束后执行函数 fn 
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
previous = now
fn.apply(this, args)
}, wait)
// ------ 新增部分 end ------ 
} else {
// 第一次执行
// 或者时间间隔超出了设定的时间间隔,执行函数 fn
previous = now
fn.apply(this, args)
}
}
}
// DEMO
// 执行 throttle 函数返回新函数
const betterFn = throttle(() => console.log('fn 节流执行了'), 1000)
// 第一次触发 scroll 执行一次 fn,每隔 1 秒后执行一次函数 fn,停止滑动 1 秒后再执行函数 fn
document.addEventListener('scroll', betterFn)

观察完整代码可知,该思想与underscore库中throttle的实现思路高度相似。

underscore源码深度解析

underscore 源码解析

基本版代码逻辑相对简单,接下来学习underscore如何实现debounce函数,借鉴其优秀设计。以下源码解析基于underscore 1.9.1版本。


// 此处的三个参数上文都有解释
_.debounce = function(func, wait, immediate) {
// timeout 表示定时器
// result 表示 func 执行返回值
var timeout, result;
// 定时器计时结束后
// 1、清空计时器,使之不影响下次连续事件的触发
// 2、触发执行 func
var later = function(context, args) {
timeout = null;
// if (args) 判断是为了过滤立即触发的
// 关联在于 _.delay 和 restArguments
if (args) result = func.apply(context, args);
};
// 将 debounce 处理结果当作函数返回
var debounced = restArguments(function(args) {
if (timeout) clearTimeout(timeout);
if (immediate) {
// 第一次触发后会设置 timeout,
// 根据 timeout 是否为空可以判断是否是首次触发
var callNow = !timeout;
timeout = setTimeout(later, wait);
if (callNow) result = func.apply(this, args);
} else {
// 设置定时器
timeout = _.delay(later, wait, this, args);
}
return result;
});
// 新增 手动取消
debounced.cancel = function() {
clearTimeout(timeout);
timeout = null;
};
return debounced;
};
// 根据给定的毫秒 wait 延迟执行函数 func
_.delay = restArguments(function(func, wait, args) {
return setTimeout(function() {
return func.apply(null, args);
}, wait);
});

相比前文的基本实现,underscore版本增加了以下功能点。

1、函数func执行结束后返回结果值result。

2、定时器计时结束后清除timeout,确保不影响后续连续事件的触发。

3、新增手动取消功能cancel。

4、当immediate为true时,仅在第一次触发时执行,后续频繁触发结束后不再执行。

核心概念总结与应用场景

小结

  • 函数节流和防抖都是「闭包」、「高阶函数」的应用
  • 函数节流 throttle 表示在特定时间间隔(如 3 秒)内仅执行一次,期间忽略后续调用请求
    • 节流类比拧紧水龙头放水,3 秒滴一滴水
      • 「管道中的水」就是我们频繁操作事件而不断涌入的回调任务,它需要接受「水龙头」安排
      • 「水龙头」就是节流阀,控制水的流速,过滤无效的回调任务
      • 「滴水」就是每隔一段时间执行一次函数
      • 「3 秒」就是间隔时间,它是「水龙头」决定「滴水」的依据
    • 应用:监听滚动事件添加节流函数后,每隔固定的一段时间执行一次
    • 实现方案 1:用时间戳来判断是否已到执行时间,记录上次执行的时间戳,然后每次触发后执行回调,判断当前时间距离上次执行时间的间隔是否已经达到时间差(Xms) ,如果是则执行,并更新上次执行的时间戳,如此循环
    • 实现方案 2:使用定时器,比如当 scroll 事件刚触发时,打印一个 hello world,然后设置个 1000ms 的定时器,此后每次触发 scroll 事件触发回调,如果已经存在定时器,则回调不执行方法,直到定时器触发,handler 被清除,然后重新设置定时器
  • 函数防抖 debounce 表示在指定时间内无论触发多少次回调,仅执行最后一次
    • 防抖类比司机等待最后一人上车后关门,新人进入则重置计时器
      • 「上车的乘客」就是我们频繁操作事件而不断涌入的回调任务
      • 「1 分钟」就是计时器,它是司机决定「关门」的依据,如果有新的「乘客」上车,将清零并重新计时
      • 「关门」就是最后需要执行的函数
    • 应用:input 输入回调事件添加防抖函数后,只会在停止输入后触发一次
    • 实现方案:使用定时器,函数第一次执行时设定一个定时器,之后调用时发现已经设定过定时器就清空之前的定时器,并重新设定一个新的定时器,如果存在没有被清空的定时器,当定时器计时结束后触发函数执行

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章