防抖(Debounce):从用户体验到手写实现

准备面试的时候,我发现自己虽然用过 lodash 的 debounce,但如果要手写实现,脑子里却一片空白。

为什么需要延迟?为什么要用闭包?立即执行模式是怎么回事?带着这些疑问,我决定从头梳理一遍防抖的原理。这篇文章是我的学习笔记,希望能帮对 debounce 好奇的你建立清晰的思路。

问题的起源:一次性能优化的思考

防抖(Debounce)这个概念最初来自硬件领域,用来解决按钮的机械抖动问题。在前端开发中,我们遇到的其实是类似的场景:用户的连续操作可能触发大量不必要的事件处理。

假设你在做一个搜索框的实时联想功能。用户输入 "react hooks" 这 11 个字符,如果每次按键都发送一次请求,那就是 11 次网络调用。但实际上,用户真正想搜索的是完整的 "react hooks",前面 10 次请求都是无意义的。

这就是防抖要解决的核心问题:在用户频繁操作时,只响应最后一次有效操作

javascript 复制代码
// 环境:浏览器
// 场景:搜索框输入,没有防抖的情况

const searchInput = document.querySelector('#search');

searchInput.addEventListener('input', (e) => {
  // 假设这是一个网络请求
  console.log('发送请求:', e.target.value);
  // fetch(`/api/search?q=${e.target.value}`)
});

// 用户输入 "react" 时,会触发 5 次 console.log
// "r" -> "re" -> "rea" -> "reac" -> "react"

核心原理:时间轴上的延迟与取消

防抖的实现本质上就两个关键动作:

  1. 延迟执行:不立即执行函数,而是等待一段时间
  2. 取消重复:如果在等待期间又触发了事件,取消之前的等待,重新开始计时

我们可以用一个时间轴来理解这个过程:

graph LR A[第1次输入] -->|开始等待300ms| B[等待中] C[第2次输入
100ms后] -->|取消前一次
重新等待300ms| D[等待中] E[第3次输入
150ms后] -->|取消前一次
重新等待300ms| F[等待中] F -->|300ms后
无新输入| G[执行函数]

用人话说就是:

给我 300 毫秒的缓冲时间,如果这段时间内你又触发了事件,我就重置计时器;

如果 300 毫秒内你没再触发,我就执行函数。

定时器的作用:clearTimeout 实现"取消"

JavaScript 的 setTimeout 返回一个定时器 ID,我们可以用 clearTimeout 来取消这个定时器。

这就是防抖"取消重复"的核心机制。

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:理解 clearTimeout 的作用

let timer = setTimeout(() => {
  console.log('这句话不会被打印');
}, 1000);

// 在定时器触发前取消它
clearTimeout(timer);

// 重新设置一个新的定时器
timer = setTimeout(() => {
  console.log('这句话会在 1 秒后打印');
}, 1000);

闭包的必要性:保存 timer 状态

为了在多次调用之间保持 timer 的引用,我们需要用闭包。这是很多人理解防抖时的第一个难点。

javascript 复制代码
// 环境:浏览器
// 场景:为什么需要闭包

// ❌ 错误的实现:每次调用都是新的 timer
function wrongDebounce(func, delay) {
  return (..arg) => {
      let timer; // 这个 timer 在每次调用 wrongDebounce 时都会重新创建
      if(timer) clearTimeout(timer);
      timer = setTimeout(() => {
          func.apply(this,args);
      }, delay);
  };
}

// ✅ 正确的实现:返回一个函数,形成闭包
function correctDebounce(func, delay) {
  let timer; // 这个 timer 被闭包捕获,在多次调用间保持引用
  
  return (...args) => {
    // 取消上一个定时器
    clearTimeout(timer);
    // 重新设置定时器
    timer = setTimeout(() => {
      func(...args);
    }, delay);
  };
}

// 使用示例
const debouncedSearch = correctDebounce(() => {
  console.log('执行搜索');
}, 300);

debouncedSearch(); // 第 1 次调用,设置定时器
debouncedSearch(); // 第 2 次调用,取消上一个定时器,重新设置
debouncedSearch(); // 第 3 次调用,取消上一个定时器,重新设置
// 只有最后一次会在 300ms 后执行

闭包让 timer 变量成为了一个"私有状态",所有通过 correctDebounce 返回的函数都共享这个状态。

闭包让 debounce 能够"记住"之前设置的定时器,从而在新的调用时取消它,实现"延迟执行,只执行最后一次"的效果。

从最简实现到完整版本

让我们一步步构建防抖函数,每个版本解决一个具体的问题。

版本 1:最基础的防抖(核心逻辑)

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:最简单的防抖实现
// 功能:延迟执行,取消重复

function debounce(func, delay) {
  let timer = null;
  
  return function() {
    // 如果已经有定时器在等待,取消它
    clearTimeout(timer);
    
    // 设置新的定时器
    timer = setTimeout(() => {
      func();
    }, delay);
  };
}

// 测试
const log = debounce(() => console.log('executed'), 300);

log(); // 不会执行
log(); // 不会执行
log(); // 300ms 后执行

这个版本虽然简单,但已经实现了防抖的核心思想。不过它有两个明显的问题:

  1. 没有处理函数的参数
  2. 没有处理 this 绑定

版本 2:处理参数和 this 绑定

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:处理参数传递和 this 上下文

function debounce(func, delay) {
  let timer = null;
  
  return function(...args) { // 使用剩余参数收集所有参数
    const context = this; // 保存调用时的 this
    
    clearTimeout(timer);
    
    timer = setTimeout(() => {
      // 使用 apply 调用,传入正确的 this 和参数
      func.apply(context, args);
    }, delay);
  };
}

// 测试:在对象方法中使用
const searchBox = {
  keyword: '',
  
  search: debounce(function(value) {
    this.keyword = value; // this 应该指向 searchBox
    console.log('搜索:', this.keyword);
  }, 300)
};

searchBox.search('react'); // 300ms 后打印 "搜索: react"

这里有两个容易混淆的点:

  • 为什么要保存 this ?因为 setTimeout 内部的箭头函数会捕获外层的 this,如果不保存,当 setTimeout 执行时,this 可能已经指向别的对象了。
  • 为什么用 apply 而不是直接调用 ?因为我们需要同时传入 this 上下文和参数数组。

版本 3:支持立即执行(leading edge)

有些场景下,我们希望第一次触发时立即执行,而不是延迟。比如提交表单按钮,用户第一次点击应该立即响应,后续的连续点击才需要防抖。

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:支持立即执行模式
// 参数:immediate 为 true 时,第一次触发立即执行

function debounce(func, delay, immediate = false) {
  let timer = null;
  
  return function(...args) {
    const context = this;
    const callNow = immediate && !timer; // 立即执行的条件
    
    clearTimeout(timer);
    
    timer = setTimeout(() => {
      timer = null; // 重要:重置 timer,为下次立即执行做准备
      if (!immediate) {
        func.apply(context, args);
      }
    }, delay);
    
    // 如果是立即执行模式,且当前没有等待中的定时器,立即调用
    if (callNow) {
      func.apply(context, args);
    }
  };
}

// 测试:提交按钮防抖
const submitButton = document.querySelector('#submit');
const handleSubmit = debounce(function() {
  console.log('提交表单');
}, 1000, true); // immediate = true

submitButton.addEventListener('click', handleSubmit);

// 用户连续点击 3 次:
// 第 1 次:立即执行 console.log
// 第 2 次:不执行(在 1 秒内)
// 第 3 次:不执行(在 1 秒内)
// 1 秒后:timer 被重置,下次点击又会立即执行

立即执行模式的关键在于:

  • callNow 的判断:immediate && !timer 表示"开启了立即执行 且 当前没有等待中的定时器"
  • timer = null 的重置:延迟结束后,把 timer 重置为 null,这样下次触发时 !timer 才为 true,才能再次立即执行

版本 4: 添加取消功能

有时候我们需要手动取消防抖,比如用户切换了页面,之前设置的防抖操作应该被取消。

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:可手动取消的防抖
// 功能:返回的函数附带 cancel 方法

function debounce(func, delay, immediate = false) {
  let timer = null;
  
  const debounced = function(...args) {
    const context = this;
    const callNow = immediate && !timer;
    
    clearTimeout(timer);
    
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) {
        func.apply(context, args);
      }
    }, delay);
    
    if (callNow) {
      func.apply(context, args);
    }
  };
  
  // 添加 cancel 方法
  debounced.cancel = function() {
    clearTimeout(timer);
    timer = null;
  };
  
  return debounced;
}

// 测试:页面跳转时取消防抖
const autoSave = debounce(() => {
  console.log('自动保存草稿');
}, 2000);

// 用户编辑内容
document.addEventListener('input', autoSave);

// 用户点击"退出"按钮时,取消自动保存
document.querySelector('#exit').addEventListener('click', () => {
  autoSave.cancel();
  console.log('已取消自动保存');
});

手写实现的关键点

在面试中手写防抖,有几个容易踩的坑:

1. 闭包陷阱:为什么要用 apply

javascript 复制代码
// ❌ 错误:直接调用会丢失 this 和参数
timer = setTimeout(() => {
  func(); // this 是 undefined,参数也丢失了
}, delay);

// ✅ 正确:使用 apply 传入 context 和 args
timer = setTimeout(() => {
  func.apply(context, args);
}, delay);

有人会问:为什么不用 func.call(context, ...args) 或者 func.bind(context)(...args)

  • call 也可以,但 apply 接受数组参数,写起来更简洁
  • bind 会创建新函数,性能略差,虽然在这个场景下影响不大

2. this 指向问题的常见错误

javascript 复制代码
// ❌ 错误:使用箭头函数定义返回的函数
function debounce(func, delay) {
  let timer = null;
  
  // 箭头函数的 this 是词法作用域,不能被 apply/call 改变
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args); // 这里的 this 不是调用时的 this!
    }, delay);
  };
}

// ✅ 正确:使用普通函数
return function(...args) {
  const context = this; // 这里的 this 才是调用时的 this
  // ...
};

3. 立即执行的边界条件

立即执行模式最容易写错的是 callNow 的判断逻辑:

arduino 复制代码
// ❌ 错误:只判断 immediate
const callNow = immediate; // 这样每次都会立即执行!

// ✅ 正确:immediate && !timer
const callNow = immediate && !timer; // 只在第一次或延迟结束后立即执行

而且别忘了在 setTimeout 回调中重置 timer = null,否则第二轮的立即执行永远不会触发。

实战应用场景

理解了原理后,我们来看看防抖在实际开发中的几个典型场景。

场景 1:搜索框实时联想

javascript 复制代码
// 环境:React 组件
// 场景:搜索框输入防抖
// 依赖:React, fetch API

import { useState, useCallback } from 'react';

function SearchBox() {
  const [suggestions, setSuggestions] = useState([]);
  
  // 使用 useCallback 配合防抖
  const fetchSuggestions = useCallback(
    debounce(async (keyword) => {
      if (!keyword) {
        setSuggestions([]);
        return;
      }
      
      const response = await fetch(`/api/search?q=${keyword}`);
      const data = await response.json();
      setSuggestions(data.suggestions);
    }, 300),
    [] // 空依赖,确保 debounce 只创建一次
  );
  
  const handleInput = (e) => {
    fetchSuggestions(e.target.value);
  };
  
  return (
    <div>
      <input type="text" onChange={handleInput} />
      <ul>
        {suggestions.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    </div>
  );
}

这个例子中有个细节:为什么要用 useCallback 包裹?因为每次组件重新渲染,debounce 都会创建一个新的防抖函数,导致之前的 timer 丢失。用 useCallback 可以确保防抖函数在组件生命周期内只创建一次。

场景 2:窗口 resize 事件优化

javascript 复制代码
// 环境:浏览器
// 场景:响应式布局调整
// 功能:窗口大小改变时重新计算布局

function initResponsiveLayout() {
  const updateLayout = () => {
    const width = window.innerWidth;
    
    // 假设这里有复杂的 DOM 操作
    if (width < 768) {
      document.body.classList.add('mobile');
    } else {
      document.body.classList.remove('mobile');
    }
  };
  
  // 初始化时执行一次
  updateLayout();
  
  // resize 时防抖执行
  const debouncedUpdate = debounce(updateLayout, 200);
  window.addEventListener('resize', debouncedUpdate);
  
  // 清理函数(如果在框架中使用)
  return () => {
    debouncedUpdate.cancel();
    window.removeEventListener('resize', debouncedUpdate);
  };
}

场景 3:表单自动保存

javascript 复制代码
// 环境:Vue 组件
// 场景:富文本编辑器自动保存草稿
// 依赖:Vue 3, axios

import { ref, watch } from 'vue';
import axios from 'axios';

export default {
  setup() {
    const content = ref('');
    const lastSaved = ref(null);
    
    const saveDraft = debounce(async (value) => {
      try {
        await axios.post('/api/draft', { content: value });
        lastSaved.value = new Date();
        console.log('草稿已保存');
      } catch (error) {
        console.error('保存失败:', error);
      }
    }, 2000);
    
    // 监听内容变化,触发自动保存
    watch(content, (newValue) => {
      saveDraft(newValue);
    });
    
    return { content, lastSaved };
  }
};

防抖与节流的对比

"防抖和节流有什么区别?" 我的理解是:

防抖(Debounce) :等你冷静下来再说话

  • 触发机制: 连续触发只执行最后一次
  • 时间特征: 从最后一次触发开始计时
  • 典型场景: 搜索框输入、窗口 resize、表单验证

节流(Throttle) : 限制你说话的频率

  • 触发机制: 固定时间间隔内只执行一次
  • 时间特征: 从第一次触发开始计时
  • 典型场景: 滚动加载、鼠标移动、游戏射击

用一个例子类比:

javascript 复制代码
// 防抖:电梯等人
// 有人进来就重新等 10 秒,10 秒内没人进来才关门
debounce(closeDoor, 10000);

// 节流:定时发车
// 不管来多少人,每 10 分钟发一班车
throttle(startBus, 600000);

在 React Hooks 中的正确使用

在函数组件中使用防抖,有一些特殊的注意点。

javascript 复制代码
// 环境:React 18+
// 场景:在函数组件中正确使用防抖

import { useState, useMemo, useRef, useEffect } from 'react';

function SearchComponent() {
  const [keyword, setKeyword] = useState('');
  
  // ✅ 方案 1:使用 useMemo 创建防抖函数
  const debouncedSearch = useMemo(
    () => debounce((value) => {
      console.log('搜索:', value);
      // 实际的搜索逻辑
    }, 300),
    [] // 空依赖,确保只创建一次
  );
  
  // 组件卸载时清理
  useEffect(() => {
    return () => {
      debouncedSearch.cancel();
    };
  }, [debouncedSearch]);
  
  const handleChange = (e) => {
    const value = e.target.value;
    setKeyword(value);
    debouncedSearch(value);
  };
  
  return <input value={keyword} onChange={handleChange} />;
}

// ✅ 方案 2:使用自定义 Hook
function useDebounce(callback, delay) {
  const callbackRef = useRef(callback);
  
  // 保持 callback 引用最新
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  
  const debouncedCallback = useMemo(() => {
    return debounce((...args) => {
      callbackRef.current(...args);
    }, delay);
  }, [delay]);
  
  // 清理
  useEffect(() => {
    return () => debouncedCallback.cancel();
  }, [debouncedCallback]);
  
  return debouncedCallback;
}

延伸思考

在研究防抖的过程中,我产生了一些新的疑问:

  1. 返回值怎么处理 ?如果被防抖的函数有返回值,我们能拿到吗?
    • 答案是不能直接拿到,因为 setTimeout 是异步的。一种可能的方案是返回 Promise,但这又引入了新的复杂度。
  2. 防抖函数能取消吗 ?我们在版本 4 中实现了 cancel 方法,但如果需要获取"是否有待执行的函数"这个状态呢?
    • 可以考虑添加 isPending 方法。
  3. 最大等待时间 ?有些场景下,我们希望防抖有一个上限,比如不管用户输入多快,至少每 5 秒要执行一次。
    • 这就需要结合节流的思想,实现一个"带最大等待时间的防抖"。
  4. 在微前端架构中 ,如果子应用被卸载,防抖函数还在等待中怎么办?
    • 这涉及到生命周期管理和内存泄漏的问题。

小结

防抖看似简单,实际上涉及闭包、this 绑定、定时器管理等多个知识点。手写防抖的关键是理解"延迟与取消"的核心思想,然后一步步处理参数、this、立即执行等细节。

这篇文章是我准备面试时的思考过程,可能有理解不到位的地方。如果你有更好的实现方案或者发现了文中的问题,欢迎交流讨论。

面试时,除了能写出代码,更重要的是能解释清楚"为什么这样写"。比如:

  • 为什么要用闭包?(保存 timer 状态)
  • 为什么要用 apply?(传递 this 和参数)
  • 立即执行模式的边界条件是什么?(immediate && !timer)

下一步我想探索的是:如何实现一个带最大等待时间的防抖?这在实际项目中可能更实用。

参考资料

相关推荐
HelloReader1 小时前
Flutter 进阶 UI搭建 iOS 风格通讯录应用(十一)
前端
张元清1 小时前
每个 React 开发者都需要的 10 个浏览器 API Hooks
前端·javascript·面试
进击的尘埃1 小时前
给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑
javascript
HelloReader1 小时前
Flutter ListenableBuilder让界面自动响应数据变化(十)
前端
yuki_uix1 小时前
深拷贝:JavaScript 引用类型的完全复制之道
前端·javascript
默默学前端2 小时前
JavaScript 中 call、apply、bind 的区别
开发语言·前端·javascript
宁雨桥2 小时前
前端设计模式面试题大全
前端·设计模式
Cg136269159742 小时前
JS函数表示
前端·html
℘团子এ2 小时前
vue3中,el-table表格固定列后出现表格线段折断的问题
javascript·vue.js·elementui