防抖节流实现与分析

71 阅读4分钟

前言

在前端开发中,我们经常会遇到一些高频触发的事件,如窗口 resize、滚动 scroll、输入框 keyup 等。如果对这些事件的回调函数不加以控制,可能会导致性能问题,加重服务器的负载或非预期的结果。防抖(debounce)节流(throttle) 是两种常用的优化高频触发事件的方法。下面分别用防抖和节流的方法来处理一个简单的连续点击按钮的高频触发事件场景并分析代码实现要点。

防抖

防抖的本质是将多次触发的事件合并为一次执行,其原理基于以下两个关键机制:

  1. 定时器延迟执行
    当事件触发时,不立即执行目标函数,而是启动一个定时器,设定一个等待时间(如 2000ms)。若在等待时间内事件再次触发,则清除之前的定时器,重新开始计时。只有当等待时间内没有新的事件触发时,才会执行目标函数。
  2. 清除定时器避免累积
    通过clearTimeout()方法清除未执行的定时器,确保每次事件触发都会重置等待周期,避免多个定时器累积导致的执行混乱。

实现代码

<!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>
    <button id="btn">点击按钮</button>
    <p id="status">等待点击...</p>
    
    <script>
        function fn() {
            console.log("按钮回调执行");
            document.getElementById('status').textContent = "回调函数执行于: " + new Date().toLocaleTimeString();
        }
        
        let btn = document.getElementById('btn');
        btn.addEventListener('click', debounce(fn, 2000));
        
        // 防抖函数 (延迟执行版)
        function debounce(fn, wait) {
            let timer = null;
            return function(...arg) {
                let that = this;
                console.log("触发防抖: 清除前一个定时器,设置新定时器 (" + wait + "ms)");
                document.getElementById('status').textContent = "等待中... " + wait + "ms";
                
                clearTimeout(timer);
                timer = setTimeout(function() {
                    console.log("定时器触发,执行回调");
                    fn.call(that, ...arg);
                }, wait);
            }
        }
    </script>
</body>
</html>

防抖函数代码分析

1. let timer = null;

作用:声明一个闭包变量 timer,用于存储定时器 ID

关键点

  • 使用闭包确保每次调用防抖函数时,timer 变量独立保存
  • 初始化为 null,表示尚未设置定时器

2. return function(...arg) { ... }

作用:返回一个新函数作为事件处理函数

关键点

  • 使用箭头函数会导致 this 指向全局对象(非期望行为)
  • ...arg 收集所有参数,确保传递给原始函数
  • 每次事件触发时实际执行的是这个返回的函数

3. let that = this;

作用:保存当前上下文(调用事件处理函数的对象)

关键点

  • 在定时器的回调函数中,this 会指向全局对象(浏览器中为 window
  • 通过 that 变量保存正确的上下文,在定时器中使用 fn.call(that) 恢复

4. clearTimeout(timer);

作用:清除前一个定时器

关键点

  • 每次触发事件时都会清除之前的定时器
  • 如果前一个定时器未到期,将被取消执行
  • 这是防抖的核心机制:合并多次触发为最后一次

5. timer = setTimeout(...)

作用:设置新的定时器

关键点

  • 每次触发事件都会创建新的定时器
  • 定时器时间为 wait 毫秒
  • 如果在 wait 时间内再次触发事件,当前定时器会被清除(步骤 4)

6. fn.call(that, ...arg);

作用:执行原始函数并传递参数

关键点

  • fn.call(that):使用保存的上下文 that 调用原始函数
  • ...arg:传递事件触发时的所有参数
  • 仅在定时器到期且未被清除时执行

节流

节流与防抖的主要区别在于,节流会在固定时间间隔内执行一次回调,而不是合并所有触发。

形象示例

如果连续点击会出现冷却时间确保在2秒后才能点击第二次。


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>节流按钮示例</title>
    <style>
        button {
            padding: 10px 20px;
            font-size: 16px;
            background-color: #4CAF50;
            color: white;
            border: none;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        button.loading {
            background-color: #888;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
    <button id="btn">点击按钮</button>
    <p id="status">等待点击...</p>
    <p id="lastThrottle">上次执行: 未执行</p>
    
    <script>
        function fn() {
            console.log("按钮回调执行");
            document.getElementById('status').textContent = "回调函数执行于: " + new Date().toLocaleTimeString();
            
            // 模拟加载过程
            document.getElementById('btn').classList.add('loading');
            setTimeout(() => {
                document.getElementById('btn').classList.remove('loading');
            }, 500);
        }
        
        let btn = document.getElementById('btn');
        btn.addEventListener('click', throttle(fn, 2000));
        
        // 节流函数 (时间戳+定时器混合版)
        function throttle(fn, limit) {
            let lastExecTime = 0;  // 上次执行时间
            let timer = null;      // 定时器
            
            return function(...args) {
                const context = this;
                const now = Date.now();
                const remaining = limit - (now - lastExecTime);
                
                document.getElementById('status').textContent = 
                    remaining > 0 
                        ? `冷却中... 剩余 ${Math.ceil(remaining/1000)}s` 
                        : "可点击";
                
                // 如果距离上次执行超过了限制时间,立即执行
                if (remaining <= 0) {
                    clearTimeout(timer);
                    timer = null;
                    lastExecTime = now;
                    console.log("节流执行: 直接执行");
                    document.getElementById('lastThrottle').textContent = 
                        "上次执行: " + new Date().toLocaleTimeString();
                    fn.apply(context, args);
                } 
                // 否则,设置一个定时器在剩余时间后执行
                else if (!timer) {
                    timer = setTimeout(() => {
                        lastExecTime = Date.now();
                        timer = null;
                        console.log("节流执行: 延迟执行");
                        document.getElementById('lastThrottle').textContent = 
                            "上次执行: " + new Date().toLocaleTimeString();
                        fn.apply(context, args);
                    }, remaining);
                }
            };
        }
    </script>
</body>
</html>

简易版

function fn() {
    console.log("点击按钮");
  }
  let btn = document.querySelector('button');
  btn.addEventListener('click', throttle(fn, 2000));

  function throttle(fn, wait) {
    let preTime = 0;  // 闭包变量:记录上次执行时间
    return function (...arg) {  // 返回一个新函数作为事件处理函数
      let nowTime = Date.now();  // 获取当前时间戳
      if (nowTime - preTime >= wait) {  // 判断是否超过时间间隔
        fn.call(this, ...arg);  // 执行原函数,并传递参数和上下文
        preTime = nowTime;  // 更新上次执行时间为当前时间
      }
    }