防抖与节流:前端性能优化的双子星

36 阅读9分钟

引言

“用户疯狂打字,系统差点崩溃;滚动如疾风,服务器瑟瑟发抖。”
——这是没有防抖和节流的世界。

在现代 Web 应用中,用户操作越来越频繁:搜索框实时建议、无限滚动加载、窗口大小调整、按钮连点……这些看似简单的交互背后,若不加以控制,可能引发成百上千次无意义的函数调用或网络请求,严重拖慢页面性能,甚至压垮后端服务。

为此,前端工程师祭出了两大法宝:防抖(Debounce)节流(Throttle) 。它们如同交通信号灯,为高频事件设定规则,让系统在狂暴输入中保持优雅与稳定。

本文将结合完整的 HTML + JavaScript 代码,逐字引用、逐行剖析,带你深入理解防抖与节流的本质、实现细节、使用误区与最佳实践。准备好了吗?让我们一起走进这场性能优化的深度之旅!


一、场景引入:为什么需要防抖和节流?

帕金森现象:用户在操作一个功能时,由于操作的频率过快,导致功能被触发多次,从而影响用户体验。
百度搜索框(baidu ajax suggest) :用户在输入时,会实时触发搜索请求。如果没有防抖,每次输入都发送一个请求,会导致服务器压力增大。有了防抖,用户在输入时,只有在停止输入一段时间后,才会发送请求。

这正是问题的核心:事件触发太密集 → 执行太频繁 → 资源浪费 + 用户体验差

而解决方案就是:

  • 防抖(Debounce) :在一定时间内,只执行最后一次。
  • 节流(Throttle) :每隔一定时间,最多执行一次。

防抖适用于“结果导向”场景(如搜索、保存草稿)——你关心的是最终状态。
节流适用于“过程监控”场景(如滚动、resize、mousemove)——你需要定期采样,但不能太频繁。


二、HTML 结构:三个输入框,三种命运

我们先看页面结构:

<div>
    <label for="undeounce">未防抖</label>
    <input type="text" id="undeounce" />
    <br>
    <label for="debounce">防抖</label>
    <input type="text" id="debounce" />
    <br>
    <label for="throttle">节流</label>
    <input type="text" id="throttle" />
</div>

三个输入框分别代表:

  1. 未防抖:原始暴力模式,每按一次键就触发一次请求;
  2. 防抖:聪明模式,等你停手 1 秒再干活;
  3. 节流:节奏大师模式,不管你多快,我每秒最多响应一次。

接下来,我们进入 JavaScript 的核心战场。


三、模拟网络请求:ajax 函数

function ajax(content) {
    console.log('ajax request', content)
}

这是一个简化版的 AJAX 请求函数,实际项目中可能是 fetch(url, { body: content })。这里仅用于演示,输出内容到控制台。

💡 注意:这个函数本身是纯函数,不依赖上下文,所以 this 绑定在此例中并非关键,但在通用工具函数中必须考虑。


四、防抖(Debounce)实现详解

1. 防抖函数定义

// 高阶函数:参数或返回值(闭包)是函数(函数就是对象)
function debounce(fn, delay) {
    var id;
    return function (args) {
        if (id) clearTimeout(id)
        var that = this
        id = setTimeout(() => {
            fn.call(that, args)
        }, delay)
    }
}

逐行深度解析:

  • // 高阶函数:参数或返回值(闭包)是函数(函数就是对象)
    这是一条非常重要的注释。它指出 debounce 是一个高阶函数(Higher-order Function),即接收函数作为参数或返回函数的函数。同时强调了“闭包”的作用:内部函数可以访问外部函数的作用域变量(如 id)。

  • function debounce(fn, delay)
    定义一个名为 debounce 的函数,接收两个参数:

    • fn:要被防抖处理的目标函数(例如 ajax)。
    • delay:延迟时间(单位:毫秒),表示在多少毫秒内不再触发才执行。
  • var id;
    声明一个变量 id,用于存储 setTimeout 返回的定时器 ID。关键点在于:这个变量位于 debounce 的作用域内,而被返回的内部函数通过闭包捕获了它。这意味着每次调用 debounce(ajax, 1000) 会创建一个独立的 id 环境,互不干扰。

  • return function (args) { ... }
    返回一个新函数(即“防抖后的函数”)。这个函数会被用作事件监听器。注意:它接收一个参数 args,但在实际事件处理中,我们会传入 event 对象或其他值。

  • if (id) clearTimeout(id)
    如果之前已经设置过定时器(id 存在且非 null/undefined),就调用 clearTimeout(id) 将其清除。这是防抖的灵魂所在:只要在 delay 时间内再次触发,就取消之前的计划,重新计时。这样确保只有最后一次触发后的 delay 时间内不再触发,才真正执行。

  • var that = this
    保存当前的 this 上下文。虽然在 addEventListenerthis 指向触发事件的元素(如 input),但为了确保 fn 被正确调用(尤其当 fn 是某个对象的方法时),这里做了上下文绑定。如果不保存,箭头函数中的 this 会指向外层作用域(通常是 windowundefined in strict mode)。

  • id = setTimeout(() => { fn.call(that, args) }, delay)
    设置一个新的定时器。delay 毫秒后,执行 fn,并使用 .call(that, args) 确保:

    • this 指向正确的对象(即事件触发时的 this);
    • 参数 args 正确传递给 fn

防抖本质延迟执行 + 取消重置。只有最后一次触发后的 delay 时间内不再触发,才真正执行。


2. 防抖事件绑定

const inputa = document.getElementById('undeounce')
const inputb = document.getElementById('debounce')
// 不防抖
// 频繁触发
inputa.addEventListener('keyup', function (e) {
    ajax(e.target.value) // 复杂
})
// 防抖
let debounceAjax = debounce(ajax, 1000)
inputb.addEventListener('keyup', debounce(function (e) {
    ajax(e.target.value) 
}, 1000))

分析:

  • 未防抖输入框(inputa

    inputa.addEventListener('keyup', function (e) {
        ajax(e.target.value) // 复杂
    })
    

    每次 keyup 事件发生(即用户按下并释放一个键),都会立即调用 ajax(e.target.value)。如果你输入 “hello”,会触发 5 次请求。这就是我们要避免的“帕金森现象”。

  • 防抖输入框(inputb

    const debouncedHandler = debounce(function (e) {
        ajax(e.target.value);
    }, 1000);
    inputb.addEventListener('keyup', debouncedHandler);
    

或者复用已创建的 debounceAjax(但注意 debounceAjaxdebounce(ajax, 1000),而我们需要的是包装 event 的版本,所以不能直接用)。

🚨 教训:防抖/节流函数必须提前创建并复用,不能在事件监听器内动态生成!


五、节流(Throttle)实现详解

1. 节流函数定义

// 高阶函数 节流:在一定时间内只执行一次
function throttle(fn, delay) {
    let 
      last,
      deferTime;
    return function (args) {
        let that = this // this丢失
        let _args = arguments // 类数组对象
        let now = + new Date() // 类型转换,将时间转为毫秒数,从1970年1月1日00:00:00开始计算
        // 上次执行过 还没到执行时间
        if (last && now < last + delay) {
            clearTimeout(deferTime);
            deferTime = setTimeout(function() {
                last = now
                fn.apply(that, _args)
            }, delay)
        }else{
            last = now
            fn.apply(that, _args)
        }
    }
}

逐行深度解析:

  • // 高阶函数 节流:在一定时间内只执行一次
    注释明确指出节流的目标:限制函数在单位时间内的执行次数。

  • function throttle(fn, delay)
    定义节流函数,同样接收目标函数 fn 和延迟时间 delay

  • let last, deferTime;
    声明两个变量:

    • last:记录上一次函数实际执行的时间戳(毫秒)。
    • deferTime:用于存储“延迟执行”的定时器 ID(用于 trailing edge 执行)。
  • return function (args) { ... }
    返回节流后的函数。注意:虽然形参是 args,但内部使用了 arguments,更通用。

  • let that = this // this丢失
    保存 this 上下文。注释“this丢失”提醒我们:如果不保存,在 setTimeout 回调中 this 会丢失。

  • let _args = arguments // 类数组对象
    arguments 是一个类数组对象,包含所有传入的实际参数。比单个 args 更灵活,能处理任意数量的参数。

  • let now = + new Date()
    +new Date() 是将 Date 对象转为时间戳的简写,等价于 Date.now()。例如 +new Date() 返回 1712345678901

  • 核心逻辑分支

    if (last && now < last + delay) {
        // 在冷却期内
        clearTimeout(deferTime);
        deferTime = setTimeout(function() {
            last = now
            fn.apply(that, _args)
        }, delay)
    } else {
        // 可以立即执行
        last = now
        fn.apply(that, _args)
    }
    
    • 情况1:不在冷却期(now >= last + delay 或首次调用)

      • 条件:!last(首次)或 now >= last + delay(超过冷却时间)。
      • 动作:立即执行 fn.apply(that, _args),并更新 last = now
    • 情况2:在冷却期内(now < last + delay

      • 动作:

        1. 清除之前安排的延迟任务(clearTimeout(deferTime));
        2. 重新安排一个 setTimeout,在 delay 毫秒后执行 fn,并更新 last

✅ 这种实现称为 “节流 + trailing edge(尾随执行)” :即使你在冷却期疯狂触发,也会在冷却结束时补一次最新操作

举个例子(delay=1000ms):

  • t=0ms:输入 → 立即执行
  • t=200ms:输入 → 安排 t=1000ms 执行
  • t=400ms:输入 → 清除 t=1000ms 的任务,重新安排 t=1000ms 执行
  • t=1000ms:执行最后一次输入的内容

这样既限制了频率,又不会丢失用户的最终意图。


2. 节流事件绑定

const inputc = document.getElementById('throttle')
let throttleAjax = throttle(ajax, 1000)
inputc.addEventListener('keyup', function (e) {
    throttleAjax(e.target.value) // 复杂
})
  • 先创建 throttleAjax = throttle(ajax, 1000),得到一个节流后的函数。
  • 在事件监听器中复用这个函数。
  • 因此,lastdeferTime 在多次 keyup 间保持状态,节流生效。

这是正确用法!


六、防抖 vs 节流:对比总结

特性防抖(Debounce)节流(Throttle)
触发时机停止触发后 delay 执行每 delay 最多执行一次
执行次数一定时间内只执行 1 次(最后一次)一定时间内执行 N 次(N = 总时间 / delay)
适用场景搜索建议、表单校验、窗口 resize(有时)滚动加载、鼠标移动、FPS 射击
实现核心clearTimeout + setTimeout时间戳比较 + setTimeout(可选)
是否保留最新状态是(本实现有 trailing)
是否立即执行否(除非改造)是(首次立即执行)

“函数的防抖和节流都是防止某一时间频繁触发,但是原理不同。防抖是某段时间内只执行一次,而函数节流是间隔时间执行。”

🎮 形象比喻

  • 防抖:电梯门——有人不断进出,门就一直开着;直到没人了,才关门走人。
  • 节流:机关枪——扣住扳机不放,子弹也是按固定射速发射。

七、常见误区与改进建议

1. 节流实现的另一种方式(简单版)

有些节流实现只用时间戳,不带 trailing:

function throttle(fn, delay) {
    let last = 0;
    return function (...args) {
        const now = Date.now();
        if (now - last >= delay) {
            last = now;
            fn.apply(this, args);
        }
    };
}

这种实现更简单,但会丢失冷却期内的所有操作(包括最后一次)。


八、结语:让每一次触发都有意义

防抖与节流,看似只是几行代码,却体现了前端工程中对资源的敬畏对用户体验的极致追求

通过闭包保存状态,通过定时器控制节奏,我们让程序在喧嚣中保持冷静,在高频中守住底线。

记住

  • 搜索用 防抖,滚动用 节流
  • 工具函数要提前创建、复用引用
  • 理解原理,才能写出健壮代码。

现在,打开你的开发者工具,试试这三个输入框——看看控制台输出的变化,感受性能优化的魅力吧!


延伸思考
在 React/Vue 等框架中,如何在组件生命周期内正确使用防抖/节流?如何避免内存泄漏?欢迎在评论区讨论!