JS的防抖与节流

99 阅读9分钟

本文将从核心思想→基础实现→生产级代码→应用场景→面试考点全链路讲解,所有代码均可直接复制到项目中使用。


前言

你一定遇到过这些场景:

  • 输入框实时搜索,每输入一个字符就发一次请求
  • 窗口 resize 时,页面疯狂重绘导致卡顿
  • 滚动加载更多,滚动一次触发十次接口
  • 按钮快速点击导致表单重复提交

这些问题的本质都是:短时间内高频触发的函数,造成了不必要的性能浪费

防抖(Debounce)节流(Throttle),就是解决这类问题最核心、最常用的两个性能优化技术。

一、防抖(Debounce)

1. 核心思想

将短时间内多次触发的函数,合并为最后一次执行

简单说就是:等你停下来,我再执行image.png

2. 基础版实现(理解原理)

这是最容易理解的核心逻辑,适合新手入门:

let timer = null;
input.addEventListener('keyup', function() {
  // 每次触发都清除之前的定时器
  if (timer) clearTimeout(timer);
  // 重新开始计时
  timer = setTimeout(() => {
    console.log('发送搜索请求');
  }, 500);
});

缺点:全局变量污染、不可复用、this 指向错误、无法传递参数。

3. 生产级实现

解决了基础版的所有问题,支持立即执行 / 非立即执行双模式,自带取消功能:

/**
 * 防抖函数
 * @param {Function} fn - 需要防抖的目标函数
 * @param {number} delay - 延迟时间(毫秒)
 * @param {boolean} immediate - 是否立即执行(默认:false)
 * @returns {Function} 防抖后的函数,自带cancel方法
 */
function debounce(fn, delay, immediate = false) {
  let timer = null;

  const debounced = function(...args) {
    // 保存正确的this上下文
    const context = this;
    // 每次触发都清除之前的定时器
    if (timer) clearTimeout(timer);

    if (immediate) {
      // 立即执行模式:只有当没有定时器时才执行
      const callNow = !timer;
      // 定时器仅负责重置状态,不执行函数
      timer = setTimeout(() => {
        timer = null;
      }, delay);
      // 满足条件则立即执行
      if (callNow) fn.apply(context, args);
    } else {
      // 非立即执行模式:最后一次触发后执行
      timer = setTimeout(() => {
        fn.apply(context, args);
        timer = null;
      }, delay);
    }
  };

  // 手动取消未执行的防抖,cancel 是我们手动给防抖 / 节流函数添加的一个自定义方法,它的唯一作用是:提前终止还没执行的防抖 / 节流操作。
  debounced.cancel = () => {
    clearTimeout(timer); // 取消timer这个 ID 对应的定时器,让它不再执行。
    timer = null; // 将 timer 变量强制恢复到「没有任何定时器在运行」的初始状态
  };

  return debounced;
}

// 处理函数
function handle() {
  console.log(Math.random());
}

// resize事件
window.addEventListener("resize", debounce(handle, 1000));

这个写法的问题在于:它没有保存最后一次的参数和上下文。当最后一次触发事件时,它只是重置了定时器,但没有把最新的参数保存下来,所以定时器结束后也无法执行最后一次调用。

最终完美版、无任何 bug 的标准防抖:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <input />
    </div>
    <script>
      function debounce(
        fn,
        delay,
        options = { leading: false, trailing: true },
      ) {
        let timer = null;
        let lastArgs; // 保存最后一次参数
        let lastThis; // 保存最后一次上下文
        const { leading, trailing } = options;

        // 核心:执行函数后清空参数,杜绝重复执行
        function invoke() {
          fn.apply(lastThis, lastArgs);
          // 关键修复:执行后清空,单次输入不会触发 trailing
          lastArgs = lastThis = null;
        }

        function debounced(...args) {
          // 保存最新的参数和上下文
          lastArgs = args;
          lastThis = this;

          // 清除之前的定时器
          if (timer) clearTimeout(timer);

          // 立即执行模式:首次触发时执行
          const isFirstInvoke = leading && !timer;
          if (isFirstInvoke) {
            invoke();
          }

          // 设置定时器:延迟结束后执行最后一次
          timer = setTimeout(() => {
            // 关键:只有存在未执行的参数,才执行(避免单次输入重复)
            if (trailing && lastArgs) {
              invoke();
            }
            timer = null;
          }, delay);
        }

        // 取消防抖
        debounced.cancel = () => {
          clearTimeout(timer);
          timer = lastArgs = lastThis = null;
        };

        return debounced;
      }

      // 测试函数
      function handleInput(e) {
        console.log("执行:", e.target.value);
      }

      // 配置:leading=true + trailing=true(你要的模式)
      const input = document.querySelector("input");
      input.addEventListener("keyup", debounce(handleInput, 1000));
    </script>
  </body>
</html>

4. 三种配置效果

配置效果适用场景
{leading:false, trailing:true}停止输入后执行 1 次搜索联想
{leading:true, trailing:false}仅首次输入执行 1 次按钮防重复点击
{leading:true, trailing:true}连续输入 = 首次 + 最后一次;单次输入 = 仅 1 次实时输入 / 自动保存

二、节流(Throttle)

1. 核心思想

保证函数在固定时间间隔内,最多只执行一次

简单说就是:不管你触发多少次,我每隔固定时间只执行一次image.png

2. 生产级实现(推荐直接使用)

这是目前最标准、功能最完整的节流实现,支持leading/trailing双配置:

<!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 placeholder="输入测试节流" />
    </div>
    <script>
      /**
       * 标准节流函数(与Lodash行为一致)
       * @param {Function} fn 要节流的函数
       * @param {number} delay 节流间隔(ms)
       * @param {Object} options 配置项
       * @param {boolean} options.leading 是否在间隔开始时执行
       * @param {boolean} options.trailing 是否在间隔结束时执行
       * @returns {Function} 节流后的函数
       */
      function throttle(
        fn,
        delay,
        options = { leading: true, trailing: true },
      ) {
        const { leading, trailing } = options;
        let timer = null;
        let lastTime = 0;
        let lastArgs = null; // 保存最后一次参数
        let lastContext = null; // 保存最后一次上下文

        // 执行函数的统一入口
        function invokeFunc() {
          if (lastArgs) {
            fn.apply(lastContext, lastArgs);
            lastArgs = lastContext = null; // 执行后清空,避免重复执行
          }
        }

        function throttled(...args) {
          const now = Date.now();
          
          // 更新最新的参数和上下文(关键:解决最后一次输入丢失)
          lastArgs = args;
          lastContext = this;

          // 第一次触发且关闭leading,把上次执行时间设为现在
          if (!leading && !lastTime) {
            lastTime = now;
          }

          const remain = delay - (now - lastTime);

          // 到达冷却时间 → 执行
          if (remain <= 0) {
            if (timer) {
              clearTimeout(timer);
              timer = null;
            }
            invokeFunc();
            lastTime = now;
          } 
          // 冷却中,且需要trailing → 只开一次定时器
          else if (trailing && !timer) {
            timer = setTimeout(() => {
              invokeFunc();
              lastTime = Date.now();
              timer = null;
            }, remain);
          }
        }

        // 取消节流
        throttled.cancel = () => {
          clearTimeout(timer);
          timer = null;
          lastTime = 0;
          lastArgs = lastContext = null;
        };

        return throttled;
      }

      // 测试函数
      function handleInput(e) {
        console.log("执行:", e.target.value, "时间:", Date.now());
      }

      const input = document.querySelector("input");
      // 配置:leading=true + trailing=true(最常用模式)
      input.addEventListener("keyup", throttle(handleInput, 1000));
    </script>
  </body>
</html>

3. 四种组合模式

配置行为适用场景
leading: true, trailing: true(默认)开始执行一次,结束再补一次滚动加载更多、窗口 resize
leading: true, trailing: false只在开始执行一次按钮点击、鼠标移动
leading: false, trailing: true只在结束执行一次拖拽元素位置更新
leading: false, trailing: false无意义,不推荐使用-
备注:
  • leading 控制周期开始时是否执行,保证响应速度

  • trailing 控制周期结束时是否补执行,保证状态最终一致

  • 默认配置 { leading: true, trailing: true } 适合绝大多数场景

三、防抖 vs 节流:一张表搞懂区别

特性防抖(Debounce)节流(Throttle)
核心逻辑最后一次执行每隔固定时间执行一次
执行次数高频触发下只执行 1 次高频触发下执行多次(固定频率)
适用场景等待用户操作结束后再执行需要保证一定执行频率的场景
典型应用输入框搜索、自动保存、按钮防重复点击滚动加载、窗口 resize、拖拽、动画

一句话总结

  • 防抖:适合 “等你停下来再做” 的场景
  • 节流:适合 “每隔一段时间做一次” 的场景

四、实际使用示例

1. 输入框实时搜索(防抖)

const searchInput = document.querySelector('.search-input');

function handleSearch(e) {
  console.log('发送搜索请求:', e.target.value);
  // 实际项目中这里调用接口
}

// 停止输入500ms后再发送请求
searchInput.addEventListener('keyup', debounce(handleSearch, 500));

2. 按钮防重复点击(防抖 - 立即执行)

const submitBtn = document.querySelector('.submit-btn');

function handleSubmit() {
  console.log('提交表单');
  // 实际项目中这里调用提交接口
}

// 点击后立即执行,3秒内再次点击无效
submitBtn.addEventListener('click', debounce(handleSubmit, 3000, true));

3. 滚动加载更多(节流)

function handleScroll() {
  // 判断是否滚动到底部
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
    console.log('加载更多数据');
    // 实际项目中这里调用加载更多接口
  }
}

// 每隔200ms执行一次,避免频繁触发
window.addEventListener('scroll', throttle(handleScroll, 200));

4. 框架中使用(React/Vue)

重要:组件卸载时一定要调用 cancel 方法,避免内存泄漏

// React示例
useEffect(() => {
  const handleResize = throttle(() => {
    console.log('窗口大小变化');
  }, 200);

  window.addEventListener('resize', handleResize);

  // 组件卸载时取消节流
  return () => {
    handleResize.cancel();
  };
}, []);

五、常见误区与踩坑点

1. 最经典的坑:事件绑定加括号

// ❌ 错误:加了括号,函数会立即执行,不会绑定事件
window.addEventListener('resize', handle());

// ✅ 正确:不加括号,传递函数本身
window.addEventListener('resize', handle);

// ✅ 正确:防抖/节流写法也是一样
window.addEventListener('resize', debounce(handle, 500));

2. 分不清防抖和节流

  • 输入框搜索:用防抖(等用户输完再搜)
  • 滚动加载:用节流(每隔一段时间加载一次)
  • 按钮防重复点击:用防抖(立即执行模式)

3. 组件卸载时不取消定时器

会导致内存泄漏,尤其是在单页应用中,一定要在组件卸载时调用cancel()方法。

4. 同时开启 leading 和 trailing,一个周期内会执行两次

绝对不会!一个节流周期内,函数最多只会执行一次。如果在周期开始时已经执行了 leading,那么周期结束时的 trailing 会被自动取消。

只有当周期内有新的触发,但没有触发 leading 执行时,trailing 才会在周期结束时执行。


六、高频面试题汇总

1. 什么是防抖和节流?它们有什么区别?

答:见本文第三部分。

2. 手写防抖函数

答:见本文第一部分生产级实现。

3. 手写节流函数

答:见本文第二部分生产级实现。

4. 防抖和节流的应用场景有哪些?

答:见本文第四部分。

5. 为什么防抖和节流要用闭包?

答:为了封装定时器状态(timer、lastTime),避免全局变量污染,同时保证每个防抖 / 节流函数都有自己独立的状态,互不干扰。


七、总结

防抖和节流是前端开发中最基础也最重要的性能优化技术,掌握它们不仅能解决实际开发中的性能问题,也是面试中的必考点。

本文提供的防抖和节流实现,是目前行业内最标准、最健壮的版本,可以直接复制到任何项目中使用。

最后一句话

能用 Lodash 的_.debounce_.throttle就直接用,它们经过了大量生产环境的验证。但你必须理解它们的原理,这样遇到问题时才能快速排查。