高频事件的“冷静剂” 闭包的实用场景:防抖与节流

防抖与节流的性能优化 🚀

作为前端开发者,你是否遇到过这样的场景:用户疯狂敲击键盘搜索时,控制台像放烟花一样刷满了请求日志;或者滚动页面时,浏览器卡得像幻灯片?其实这些问题都能通过 "防抖" 和 "节流" 这两个小技巧解决,而它们的实现背后,都藏着闭包的身影。今天咱们就来扒一扒这对性能优化界的 "黄金搭档"~

第一部分:防抖 ------"等你停了我再动" ⏸️

1. 情景引入:被代码提示打断思路的痛 😣

有没有遇到过在编辑器里疯狂敲代码?比如写const user = { name: '时,编辑器如果每敲一个字符就弹一次代码提示框,那画面简直太美不敢看 ------ 光标被遮挡、思路被打断,分分钟想砸键盘!

但聪明的编辑器不会这么干,它会等你停顿半秒后,再慢悠悠地弹出精准提示。这种 "等你停了再行动" 的逻辑,就是防抖的精髓。

2. 无防抖的 "灾难现场":keyup 事件的疯狂输出 💥

先来看个反面教材。假设我们有个搜索框,想在用户输入时实时发送请求获取建议,代码可能长这样:

html

复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>模拟</title>
</head>
<body>
    <div>
        <input type="text" id="undebounce" />
        <script>
            // 模拟AJAX请求的函数
            function ajax(content) {
                console.log('ajax request', content);
            }

            // 获取输入框元素
            const inputa = document.getElementById('undebounce');
            
            // 绑定keyup事件:每次按键抬起就触发请求
            inputa.addEventListener('keyup', function (e) {
                // 把输入框的值传给ajax函数
                ajax(e.target.value);
            });
        </script>
    </div>
</body>
</html>

代码详解:

  • 给输入框绑定了keyup事件,意思是 "用户每松开一个按键,就执行一次回调"
  • 回调里直接调用ajax函数,把当前输入的内容传过去
  • 看起来很简单,但实际运行起来...

代码效果:

当你输入 "hello" 这 5 个字母时,控制台会疯狂输出 5 条请求日志:

复制代码
ajax request h
ajax request he
ajax request hel
ajax request hell
ajax request hello

如果用户打字快,一秒能敲 10 个键,那一秒就会发 10 次请求 ------ 服务器表示:"我承受了太多不该承受的压力 😭"

思考:

这显然不对劲!用户真正需要的是 "输入完一段内容后" 的一次请求,而不是 "输入过程中" 的 N 次无效请求。就像你打电话给外卖小哥,不会每说一个字就挂了重打,而是说完一整句话再等回复,对不?

所以我们的需求很明确:管你keyup触发多少次,我只执行最后一次------ 这就是防抖要干的事!

3. 防抖的实现:给事件加个 "冷静期" 🧠

具体实现逻辑:

防抖的核心是 "延迟执行 + 清空重计",需要两个帮手:

  • 定时器(setTimeout) :让函数延迟执行,给用户留出 "冷静期"
  • 闭包:保存定时器 ID,方便每次触发时清除上一个定时器

步骤拆解:

  1. 第一次触发事件时,设置一个定时器,计划延迟delay时间后执行目标函数
  2. 如果在delay时间内再次触发事件,就清除上一个定时器,重新设置一个新的
  3. 直到用户停止触发事件,且超过delay时间,定时器才会真正执行目标函数

4.代码实现:

javascript

复制代码
// 防抖函数:接收目标函数fn和延迟时间delay
function debounce(fn, delay) {
    var id; // 闭包保存的定时器ID(自由变量,不会被垃圾回收)
    // 返回包装后的函数,接收事件参数
    return function (args) {
        // 如果已有定时器,先清除(上一次的触发作废)
        if (id) {
            clearTimeout(id);
        }
        var that = this; // 保存当前this指向(比如输入框元素)
        // 重新设置定时器,延迟执行fn
        id = setTimeout(function () {
            // 用call保证fn的this指向正确,同时传递参数
            fn.call(that, args);
        }, delay);
    }
}

// 使用防抖包装ajax函数,延迟1000毫秒(1秒)
let debounceAjax = debounce(ajax, 1000);

// 给防抖输入框绑定事件
inputb.addEventListener('keyup', function (e) {
    debounceAjax(e.target.value); 
});

代码详解:

这里的debounce是个高阶函数(参数和返回值都是函数),它利用闭包把id(定时器 ID)藏了起来。就像你给快递柜设了个取件码,只有你知道这个码,每次取件前可以先取消上一次的预约(清除定时器),重新设置新的取件时间。

生活例子:电梯关门 🛗电梯里没人按按钮时,门会倒计时 3 秒后关闭;但如果这 3 秒内有人按了开门键,电梯会重新倒计时 3 秒 ------ 直到最后 3 秒内没人操作,门才会真正关上。这里的 "3 秒" 就是delay,"重新倒计时" 就是清除定时器后重设,"最终关门" 就是执行目标函数。

5. 防抖的优点:

  • 减少无效请求:从 "每键一次发一次" 变成 "最后一次停顿后发一次",服务器压力大减
  • 提升用户体验:避免频繁更新 UI 导致的闪烁(比如搜索建议框疯狂抖动)
  • 资源优化:节省带宽和浏览器性能,尤其适合复杂的计算或请求

第二部分:节流 ------"到点了我再动" ⏳

1. 情景引入:滚动加载的 "刹车难题" 🛑

刷朋友圈时,你有没有想过:为什么快速滚动屏幕,不会每滚 1 像素就加载一次新内容?如果真这样,手机早就卡成 PPT 了。

实际上,滚动加载会设置一个 "冷却时间",比如 500 毫秒 ------ 不管你在这 500 毫秒内滚得多疯狂,它最多只加载一次新内容。这种 "按固定节奏执行" 的逻辑,就是节流。

2. 思考:节流能解决 keyup 的频繁触发吗?

当然能!如果说防抖是 "等你停了再执行",那节流就是 "不管你停没停,到点了就执行一次"。就像上课铃每 45 分钟响一次,不管你中间举手多少次,下课时间到了才会响 ------ 这就是节流的脾气~

3. 节流的实现:给事件加个 "节拍器" 🎵

具体实现逻辑:

节流的核心是 "固定间隔执行",需要两个闭包变量:

  • last:上一次执行目标函数的时间戳
  • deferTimer:延迟执行的定时器 ID(处理最后一次触发)

步骤拆解:

  1. 第一次触发事件时,直接执行目标函数,记录当前时间为last

  2. 后续触发时,计算当前时间与last的差值:

    • 如果差值 < 间隔时间delay:设置定时器,等delay时间后执行一次(防止最后一次触发被漏掉)
    • 如果差值 >= 间隔时间delay:直接执行目标函数,更新last为当前时间

4.代码实现:

javascript

复制代码
// 节流函数:接收目标函数fn和间隔时间delay
function throttle(fn, delay) {
    let last, deferTimer; // 闭包保存:上一次执行时间、延迟定时器ID
    return function (args) {
        let that = this; // 保存当前this指向
        let _args = arguments; // 保存参数(兼容多参数场景)
        let now = +Date.now(); // 当前时间戳(+号快速转数字)
        
        // 如果已有上一次执行时间,且当前在间隔时间内
        if (last && now < last + delay) {
            // 清除之前的延迟定时器
            clearTimeout(deferTimer);
            // 设置新的延迟定时器,确保最后一次触发能执行
            deferTimer = setTimeout(function () {
                last = now; // 更新上一次执行时间
                fn.apply(that, _args); // 执行目标函数
            }, delay);
        } else {
            // 首次触发或已过间隔时间,直接执行
            last = now; // 更新上一次执行时间
            fn.apply(that, _args); // 执行目标函数
        }
    }
}

// 使用节流包装ajax函数,间隔1000毫秒(1秒)
let throttleAjax = throttle(ajax, 1000);

// 给节流输入框绑定事件
inputc.addEventListener('keyup', function (e) {
    throttleAjax(e.target.value); 
});

代码详解:

throttle函数同样利用闭包保存了lastdeferTimer。它就像个严格的监考老师,规定 "每 1 小时只能交一次卷"------ 就算你提前写完,也得等够 1 小时才能交;但如果到了 1 小时还没写完,也必须交卷(用定时器保证最后一次执行)。

生活例子:机枪射速 🔫很多游戏里的机枪有 "射速限制",比如每秒最多射 3 发。不管你把鼠标按得多快,1 秒内只会射出 3 发子弹,不会因为按得快就无限连发 ------ 这里的 "每秒 3 发" 就是节流的间隔逻辑。

5. 节流的优点:

  • 控制执行频率:避免函数在高频触发时 "扎堆执行",保证浏览器和服务器的稳定
  • 平衡响应性:不会像防抖那样完全等待停顿,而是按固定节奏响应,适合需要持续反馈的场景
  • 资源分配合理:把密集的任务 "平均分配" 到时间轴上,避免瞬间资源占用过高

第三部分:完整代码与效果展示 🎬

代码亮个相:

为了直观对比,咱们把 "无处理、防抖、节流" 三种情况放一起:

html

复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <!-- 移动端视口适配,确保页面在移动设备上正常显示 -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>防抖与节流对比演示</title>
</head>

<body>
    <div>
        <!-- 无防抖节流:输入时实时触发请求 -->
        <input type="text" id="undebounce" placeholder="无防抖节流(实时触发)" />
        <br>
        <!-- 防抖:输入停止后延迟触发请求 -->
        <input type="text" id="debounce" placeholder="防抖(停止输入1秒后触发)" />
        <br>
        <!-- 节流:固定时间间隔内仅触发一次请求 -->
        <input type="text" id="throttle" placeholder="节流(每1秒最多触发一次)" />
    </div>
    <script>
        /**
         * 模拟AJAX请求函数
         * @param {string} content - 输入框的内容,作为请求参数
         */
        function ajax(content) {
            // 打印模拟请求,实际场景中这里是真实的接口调用(如fetch、axios)
            console.log('ajax request', content);
        }

        /**
         * 防抖函数(debounce)
         * 核心逻辑:高频触发时,只保留最后一次触发,且需等待延迟时间结束后才执行
         * 特性:触发后不会立即执行,停止触发后延迟执行,适合搜索输入、窗口resize等场景
         * @param {Function} fn - 需要防抖处理的目标函数(如ajax)
         * @param {number} delay - 延迟时间(毫秒)
         * @returns {Function} - 包装后的防抖函数
         */
        // 高阶函数:参数是函数(fn),返回值也是函数,利用闭包保存自由变量(id)
        function debounce(fn, delay) {
            var id; // 自由变量:保存定时器ID,用于后续清除定时器(闭包特性,不会被垃圾回收)
            // 返回包装后的防抖函数,接收目标函数(fn)的参数
            return function (args) {
                // 如果已有定时器(说明当前处于高频触发中),先清除上一个定时器
                if (id) {
                    clearTimeout(id);
                }
                var that = this; // 保存当前this指向(确保目标函数fn的this指向正确,如绑定到输入框)
                // 重新设置定时器,延迟执行目标函数
                id = setTimeout(function () {
                    // 用call改变fn的this指向为that,同时传递参数args
                    // 确保fn执行时的this和参数与直接调用一致
                    fn.call(that, args);
                }, delay);
            }
        }

        /**
         * 节流函数(throttle)
         * 核心逻辑:高频触发时,限制函数在固定时间间隔内只能执行一次
         * 特性:触发后会优先执行一次(首次),后续高频触发时按固定间隔执行,适合滚动加载、按钮防连点等场景
         * @param {Function} fn - 需要节流处理的目标函数(如ajax)
         * @param {number} delay - 时间间隔(毫秒)
         * @returns {Function} - 包装后的节流函数
         */
        function throttle(fn, delay) {
            let last, deferTimer; // 自由变量(闭包保存)
            // last:上一次执行目标函数的时间戳
            // deferTimer:延迟执行的定时器ID,用于处理最后一次触发的遗留任务
            return function (args) {
                let that = this; // 保存当前this指向,确保fn的this指向正确
                let _args = arguments; // 保存参数列表(类数组对象),兼容多参数场景
                let now = +Date.now(); // 类型转换:将当前时间对象转为毫秒级时间戳(+号简化转换)

                // 条件判断:如果存在上一次执行时间,且当前时间未超过「上一次执行时间+间隔时间」
                // 说明当前处于时间间隔内,暂不允许直接执行
                if (last && now < last + delay) {
                    // 清除之前的延迟定时器,避免重复执行
                    clearTimeout(deferTimer);
                    // 设置新的延迟定时器,确保最后一次触发在间隔时间结束后能执行
                    deferTimer = setTimeout(function () {
                        last = now; // 更新上一次执行时间为当前时间
                        // 用apply改变fn的this指向,传递参数列表(apply适合类数组参数)
                        fn.apply(that, _args);
                    }, delay);
                } else {
                    // 首次触发 或 当前时间已超过「上一次执行时间+间隔时间」,直接执行目标函数
                    last = now; // 更新上一次执行时间为当前时间
                    fn.apply(that, _args); // 立即执行目标函数
                }
            }
        }

        // 1. 获取三个输入框DOM元素
        const inputa = document.getElementById('undebounce'); // 无防抖节流的输入框
        const inputb = document.getElementById('debounce'); // 防抖处理的输入框
        const inputc = document.getElementById('throttle'); // 节流处理的输入框

        // 2. 生成防抖和节流处理后的ajax函数(延迟/间隔时间均为1000毫秒=1秒)
        let debounceAjax = debounce(ajax, 1000);
        let throttleAjax = throttle(ajax, 1000);

        // 3. 为输入框绑定keyup事件(输入内容时触发)
        // 无防抖节流:输入时每按一个键就触发一次ajax,高频触发
        inputa.addEventListener('keyup', function (e) {
            ajax(e.target.value); // 直接调用ajax,e.target.value是输入框当前内容
        });

        // 防抖处理:输入时不触发,停止输入1秒后才触发一次ajax
        inputb.addEventListener('keyup', function (e) {
            debounceAjax(e.target.value); // 调用包装后的防抖函数
        });

        // 节流处理:输入时每1秒最多触发一次ajax,即使高频输入也会限制执行频率
        inputc.addEventListener('keyup', function (e) {
            throttleAjax(e.target.value); // 调用包装后的节流函数
        }); 
    </script>
</body>

</html>

效果亮个相:

当我在输入框输入"forntent"时,观察控制台效果:

  • 无处理输入框:控制台刷出 8 条日志(f/r/o/n/t/e/n/t 各一次)
  • 防抖输入框:停顿 1 秒后,只刷出 1 条 "frontent"
  • 节流输入框:每 1 秒刷一次,假设输入用了 3 秒,会刷出 "f"、"fronte" 和 "frontent" 3条

第四部分:防抖和节流的对比 🔍

1. 核心逻辑:"留最后" vs "控频率"(最本质区别)

  • 防抖:核心是「等待最后一刻」,它不关心触发过程,只认在一定时间内的 "最后一次触发",所有中间触发都会被清除丢弃,只有当触发停止且延迟时间耗尽,才会执行最终的逻辑。比如:你给客服发消息,连续发了 5 条,防抖就相当于等你停发 10 秒后,一次性把最后 1 条消息发给客服,前 4 条全部丢弃。
  • 节流:核心是「匀速响应」,它不丢弃过多关键触发,而是按照固定时间间隔 "筛选" 触发,每到间隔时间就执行一次,即使触发未停止,也会平稳输出结果。比如:同样连续发 5 条消息,节流就相当于每 10 秒发 1 条,不管你发得多快,都按 10 秒间隔依次发送,既不会刷屏,也不会遗漏核心信息。

2. 执行时机:"停了才执行" vs "到点就执行"

  • 防抖:是「被动执行」,必须等高频触发停止后,才能执行,触发过程中完全无响应,适合不需要实时反馈的场景。
  • 节流:是「主动执行」,只要触发开始,到了固定时间间隔就会执行,触发过程中持续有反馈,适合需要一定实时性的场景。

3. 执行次数:"一次到位" vs "分批执行"

  • 防抖:高频触发期间,执行次数固定为「1 次」,仅对应最后一次触发的结果,没有中间过程的执行。
  • 节流:高频触发期间,执行次数「大于等于 1 次」,触发时长越长、时间间隔越短,执行次数越多,是分批、匀速的执行模式。
维度 防抖(Debounce) 节流(Throttle)
核心逻辑 一定时间内只执行最后一次 每隔一定时间执行一次
执行时机 停止触发后延迟执行 触发后立即执行(首次)+ 固定间隔执行
生活例子 电梯关门(等人上完再关) 机枪射速(固定时间射一发)
应用场景 搜索建议、窗口 resize、按钮防重复提交 滚动加载、高频点击(如射击游戏)、视频弹幕
闭包作用 保存定时器 ID,用于清除上一次延迟 保存上次执行时间和延迟定时器,控制间隔

第五部分:面试官会问这些哦 🤔

  1. 防抖和节流的区别是什么? 答:防抖是 "等停止触发后执行最后一次",节流是 "按固定间隔执行"。比如搜索输入用防抖(等输完再查),滚动加载用节流(按节奏加载)。

  2. 它们为什么要用闭包实现? 答:因为需要保存 "定时器 ID" 或 "上次执行时间" 这些状态,闭包能让这些变量在函数调用间持续存在,不会被垃圾回收。

  3. 什么场景用防抖,什么场景用节流? 答:需要 "等待用户操作结束" 的场景用防抖(如输入搜索);需要 "持续反馈但控制频率" 的场景用节流(如滚动、拖拽)。

第六部分:结语 🎯

防抖和节流就像前端性能优化的 "双保险"------ 它们用闭包的特性巧妙控制函数执行时机,既避免了资源浪费,又提升了用户体验。

记住:防抖是 "等你停",节流是 "按时来" 。下次遇到高频触发的事件(keyup、scroll、resize...),先想想该请哪位 "大神" 出山吧~🏆

相关推荐
优弧2 小时前
2025 提效别再卷了:当我把 AI 当“团队”,工作真的顺了
前端
.try-2 小时前
cssTab卡片式
java·前端·javascript
怕浪猫3 小时前
2026最新React技术栈梳理,全栈必备
前端·javascript·面试
ulias2123 小时前
多态理论与实践
java·开发语言·前端·c++·算法
Bigger3 小时前
Tauri (24)——窗口在隐藏期间自动收起导致了位置漂移
前端·react.js·app
小肥宅仙女3 小时前
限流方案
前端·后端
雲墨款哥3 小时前
从一行好奇的代码说起:Vue怎么没有React的props.children
前端·vue.js·react.js
用户6802659051193 小时前
2025年十大终端管理软件推荐指南
vue.js·后端·面试
孜孜不倦不忘初心3 小时前
Axios 常用配置及使用
前端·axios