JavaScript 闭包实战:深入掌握防抖(Debounce)和节流(Throttle)

51 阅读11分钟

JavaScript 闭包实战:深入掌握防抖(Debounce)和节流(Throttle)

在前端开发中,我们经常会遇到一些高频触发的事件,比如 keyupscrollresizemousemove 等。如果不加控制地直接绑定回调函数,往往会导致性能问题:浏览器短时间内执行大量复杂逻辑,甚至引发卡顿或频繁发送无意义的 Ajax 请求。

这时,函数防抖(debounce)函数节流(throttle) 就成了性能优化的两大杀器。而实现这两者的核心武器,正是 JavaScript 中强大而又常被误解的 闭包

一、为什么需要防抖和节流?

想象一下下面几个经典场景:

  1. 搜索框自动补全(百度、淘宝搜索建议)
    用户飞快地敲键盘,每输入一个字符就发一次 Ajax 请求。如果不做限制,1 秒内可能发起十几个请求,大部分都是无效的,既浪费服务器资源,又增加网络开销。

  2. 窗口 resize 或 scroll 事件监听
    用户拖动窗口或滚动页面时,事件可能以每秒几十甚至上百次的频率触发。如果每次都去计算布局或加载更多内容,页面很容易卡死。

  3. 按钮防止重复点击提交
    用户手抖连点提交按钮,导致表单重复提交。

这些问题的根源都是:事件触发太频繁,而我们真正关心的往往只是“用户停下来后的最终状态”或“每隔一段时间执行一次”

防抖和节流正是为这两种需求而生。

二、防抖(Debounce):只关心“最后一次”

核心思想

“不管你触发多少次,我只在你停止触发后的 delay 毫秒内执行一次。如果在这段时间内又触发了,那就重新计时。”

形象比喻:就像坐电梯。如果有人在电梯门即将关闭时又按了按钮,电梯会重新等待一段时间。无论多少人按,只要有人在等待时间内再按,就一直推迟关闭。

实现原理(闭包 + 定时器)

function debounce(fn, delay) {
    let timer = null; // 闭包中保存的定时器 ID

    return function (...args) {
        const context = this;

        // 每次触发时,先把之前的定时器清除
        if (timer) {
            clearTimeout(timer);
        }

        // 重新设置一个新的定时器
        timer = setTimeout(() => {
            fn.apply(context, args);
            timer = null; // 可选:执行完后清空,便于 GC
        }, delay);
    };
}
底层逻辑拆解
  1. 闭包的作用
    timer 变量被定义在 debounce 函数作用域中,返回的函数形成了闭包,能够持续访问并修改这个 timer。这保证了每次调用返回的同一个防抖函数都能操作同一个定时器。

  2. 为什么每次都要 clearTimeout?
    因为用户可能连续快速触发事件。我们希望取消上一次尚未执行的任务,只保留最后一次的延迟执行。

  3. this 和参数的处理
    使用 const context = this...args 收集参数,确保在 setTimeout 异步回调中不会丢失上下文和传入的参数。

应用场景

  • 搜索框输入建议(经典)
  • 表单实时验证(避免频繁验证)
  • 按钮防重复提交(delay 设置为 1000ms 左右)

易错点提醒

  • 立即执行版(immediate debounce)
    普通防抖是“等你停下来再执行”。但有些场景希望“第一次触发立即执行,之后频繁触发则等待”。这叫“领先版防抖”,实现时需要在函数开头判断是否已有定时器。

  • delay 时间设置不当
    太短 → 依然频繁请求;太长 → 用户感知延迟,体验差。通常建议 300~800ms,根据业务调整。

三、节流(Throttle):每隔一段时间执行一次

核心思想

“无论你触发多频繁,我每隔 delay 毫秒最多执行一次。”

形象比喻:就像游戏里的枪械射速。你一直扣着扳机,但子弹只会按照固定频率射出(比如每秒 10 发),不会因为你扣得更快就变得更快。

实现原理(时间戳版 + 定时器混合版)

下面给出最常用、最稳定的实现方式(结合时间戳和定时器的混合版):

function throttle(fn, delay) {
    let last = 0;        // 上次执行时间
    let timer = null;    // 备用定时器

    return function (...args) {
        const context = this;
        const now = Date.now();

        // 剩余时间
        const remaining = delay - (now - last);

        // 如果已经超过 delay,直接执行
        if (remaining <= 0) {
            // 如果有定时器,说明上次是延迟执行的,需要清理
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }
            fn.apply(context, args);
            last = now;
        } else if (!timer) {
            // 还没到时间,且没有等待中的定时器,则设置一个延迟执行
            // 保证在停止触发后还能执行最后一次
            timer = setTimeout(() => {
                fn.apply(context, args);
                last = Date.now();
                timer = null;
            }, remaining);
        }
    };
}
底层逻辑拆解
  1. 时间戳版(简单但有缺陷)
    仅用 last 记录上次执行时间,每次触发时判断是否超过 delay。这种方式会导致“停止触发后不会再执行最后一次”。

  2. 定时器版
    使用 setTimeout 模拟固定间隔,但首次会有 delay 延迟。

  3. 混合版优势

    • 保证时间间隔严格不超过 delay
    • 首次立即执行
    • 停止触发后仍能执行最后一次(尾部执行)

应用场景

  • 滚动事件加载更多(scroll loading)
  • 高频 mousemove 事件(如拖拽、画板)
  • 游戏中技能冷却、射击频率控制

易错点提醒

  • 不要用简单的时间戳版忽略“尾部执行”
    很多初学者实现的节流在用户停止操作后不会再执行最后一次逻辑,这在滚动加载场景中会导致内容漏载。

  • this 绑定问题
    在 class 或箭头函数环境中容易丢失 this,必须手动保存。

四、防抖 vs 节流:一图胜千言

特性防抖 (debounce)节流 (throttle)
执行时机停止触发后 delay ms 内执行最后一次每隔 delay ms 执行一次
是否保证执行最后一次是(推荐混合版)
首次是否立即执行否(普通版)
典型场景输入搜索、按钮防重提交滚动加载、鼠标移动追踪
比喻电梯门等人枪械固定射速

五、完整可运行 Demo 解析

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>防抖与节流对比</title>
</head>
<body>
    <h3>无控制(频繁触发)</h3>
    <input type="text" id="raw" placeholder="直接触发">

    <h3>防抖(5秒后执行最后一次)</h3>
    <input type="text" id="debounce" placeholder="debounce">

    <h3>节流(每500ms执行一次)</h3>
    <input type="text" id="throttle" placeholder="throttle">

    <script>
        function ajax(content) {
            console.log('ajax request:', content);
        }

        // 上文提供的 debounce 和 throttle 实现
        // ...(此处粘贴上面两个函数)

        const rawInput = document.getElementById('raw');
        const debounceInput = document.getElementById('debounce');
        const throttleInput = document.getElementById('throttle');

        const debouncedAjax = debounce(ajax, 5000);
        const throttledAjax = throttle(ajax, 500);

        rawInput.addEventListener('keyup', e => ajax(e.target.value));
        debounceInput.addEventListener('keyup', e => debouncedAjax(e.target.value));
        throttleInput.addEventListener('keyup', e => throttledAjax(e.target.value));
    </script>
</body>
</html>

打开控制台快速输入,你会清晰看到三种行为的巨大差异。

六、几个细节知识点

1.节流在京东购物平台滑动加载商品中的实际体现

在京东App(或H5移动端)浏览商品列表时,你有没有注意到:手指快速向上滑动浏览海量商品,页面不会卡顿,当接近底部时,会自动加载更多商品,底部出现“正在加载”提示,新商品无缝衔接进来。这就是典型的无限滚动(Infinite Scroll)加载更多机制,而支撑它顺滑体验的核心技术之一,正是函数节流(throttle)

为什么无限滚动加载需要节流,而不是防抖?
  • 防抖(debounce):适合“用户停下来后才执行”的场景。比如搜索框输入,只有用户停止敲键盘一段时间后才发请求。如果用防抖实现加载更多,用户必须完全停下手指,页面才会加载下一页商品——这会让体验变得很差:用户快速滑动到底,却要等几秒才看到新内容,感觉像“卡住了”。

  • 节流(throttle):适合“持续操作过程中定期执行”的场景。无论用户滑动多快,每隔固定时间(比如200~500ms)检查一次“是否接近底部”,如果是的,就触发加载请求。这样即使你一口气滑到底,系统也会在滑动过程中多次检查并提前加载,确保你到达底部时新商品已经准备好或正在加载,体验极其流畅。

京东、淘宝、天猫等电商平台的商品列表页,几乎都采用节流来实现这个功能。搜索结果中多次提到“滚动加载、加载更多”就是节流的应用经典场景。

京东滑动加载商品的底层实现逻辑(结合节流)

fc962ce0cd306c49bc54248e80437e81.jpg

  1. 监听滚动事件
    App或H5页面会监听 scroll 事件(移动端可能是 touchmove + scroll)。滚动事件是高频事件,用户手指滑动时可能每秒触发几十上百次。

  2. 应用节流包装检查函数
    直接在 scroll 里判断底部会造成性能浪费,所以用节流包装一个检查函数:

    function throttle(fn, delay) {
        let last = 0;
        let timer = null;
        return function(...args) {
            const now = Date.now();
            const remaining = delay - (now - last);
            if (remaining <= 0) {
                if (timer) {
                    clearTimeout(timer);
                    timer = null;
                }
                fn.apply(this, args);
                last = now;
            } else if (!timer) {
                timer = setTimeout(() => {
                    fn.apply(this, args);
                    last = Date.now();
                    timer = null;
                }, remaining);
            }
        };
    }
    
    // 检查是否接近底部的函数
    function checkLoadMore() {
        const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
        const clientHeight = document.documentElement.clientHeight;
        const scrollHeight = document.documentElement.scrollHeight;
    
        // 当滚动距离 + 视口高度 >= 总高度 - 预加载阈值(比如200px)
        if (scrollTop + clientHeight >= scrollHeight - 200) {
            // 触发加载更多:发Ajax请求下一页商品,追加到列表
            loadMoreGoods();
        }
    }
    
    // 节流后的事件监听(每300ms最多检查一次)
    window.addEventListener('scroll', throttle(checkLoadMore, 300));
    
  3. 加载过程体现

    • 用户快速滑动 → 节流确保每300ms检查一次是否接近底部。
    • 一旦满足条件 → 发送请求获取下一页商品数据。
    • 数据返回后 → 动态渲染追加到列表(可能用虚拟滚动优化长列表)。
    • 底部显示“加载中” → 加载完隐藏。
  4. 为什么节流让体验更好?

    • 节省资源:不节流的话,快速滑动可能触发数百次无意义的检查和计算,导致卡顿。
    • 提前预加载:即使你一口气滑到底,中间的多次检查已经触发了加载,你到达底部时新商品基本就位了。
    • 无缝衔接:结合京东的瀑布流布局(商品高度不一,左右交错排列),加载的新商品自然融入,不会跳动。
实际效果对比
场景不使用任何优化使用防抖使用节流(京东实际方式)
快速滑动到底部频繁检查,页面卡顿停下后才加载,中间空白等待滑动中定期加载,到达底部已准备好
用户体验差,容易掉帧一般,有明显延迟极佳,顺滑无缝
资源消耗中等(可控)

京东作为亿级用户平台,对性能极度敏感,这种节流+无限滚动的组合,能在保证流畅的同时控制服务器请求频率,避免瞬间爆发大量加载请求。

在京东购物平台滑动浏览商品时,那种“怎么滑都滑不到底,商品源源不断”的顺滑感,正是节流在无限滚动加载中的完美体现。它让高频滚动事件变得可控,确保在用户持续操作的过程中,定期、及时地加载新内容。如果你正在开发类似电商列表,强烈推荐使用节流——它就是让页面“飞起来”的关键之一!

2.一元加号运算符 +

在 JavaScript 中,一元加号运算符 + 放在一个值的前面时,会尝试将这个值强制转换为 Number 类型

  • 当你把 + 放在 Date 对象前面时:

    JavaScript

    +new Date()
    

    相当于调用了 Date 对象的 .valueOf() 方法,或者 Number(new Date()),返回的是从 1970年1月1日 00:00:00 UTC 开始到当前时间的毫秒数(即时间戳,timestamp)。

c11c3d347de98f3b5839ab429b8383b7.png 这是 JavaScript 中获取当前时间戳最常见、最简洁的方式之一。

等价写法对比

以下几种写法效果完全一样:

JavaScript

+new Date()                    // 最简洁,常用于节流/防抖实现
Date.now()                     // 推荐!最清晰、最快(不创建Date对象)
new Date().getTime()           // 传统写法
new Date().valueOf()           // 底层本质
Number(new Date())             // 显式转换

类似的强制转换还有:

  • !!value → 转 Boolean
  • ~~value → 转 32位整数(不常用)
  • value | 0 → 转整数

但 + 转数字是最安全、最常用的。

七、总结:闭包如何成就防抖节流

防抖和节流的底层都离不开 闭包

  • 闭包让内部函数能够持续持有外部作用域的变量(如 timer、last)
  • 每次事件触发时,返回的闭包函数都能访问并修改这些共享状态
  • 从而实现“记忆上次执行情况”的能力

没有闭包,我们就无法在多次调用间维持状态,也就无法实现这两个高级模式。

掌握了防抖和节流,你就真正掌握了闭包的实用价值之一:状态持久化 + 高阶函数封装

希望这篇文章能帮助你彻底理解并灵活运用这两个性能优化神器。下次遇到高频事件时,别再直接绑回调了,记得先问自己一句:

“这里适合防抖,还是节流?”