防抖函数:从闭包入门到实战进阶,一篇文章全搞定

开篇:为什么我们需要防抖?

今天聊一个既基础又重要的主题------防抖。不过别担心,就算你是新手,我也能让你彻底明白!

想象一下这个场景:你在百度搜索框输入"前端学习路线",每敲一个字母,页面就会向你推荐搜索建议。如果你快速输入"前端"两个字,难道真的要请求8次服务器吗?

当然不是!这样会给服务器造成巨大压力,用户体验也会很卡顿。这时候,防抖就派上用场了。

防抖的核心思想:无论你触发多少次,我只在最后执行一次。

类似的场景还有:

  • 窗口大小调整事件
  • 表单输入的验证
  • 按钮的频繁点击防止

明白了为什么需要防抖,接下来我们就来看看如何实现它。不过在此之前,我们需要先复习一个小知识点------闭包。

闭包回顾:防抖的实现基础

闭包听起来很高大上,但其实很简单。来看这段代码:

javascript 复制代码
function createCounter() {
  let count = 0; // 这个变量在外部无法访问
  
  return function() {
    count++; // 内部函数可以访问外部函数的变量
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

看到了吗?count 变量本来在 createCounter 执行完后就应该消失的,但因为内部函数还在使用它,所以 JavaScript 把它"留"下来了。

这就是闭包:函数能够记住并访问其词法作用域中的变量,即使函数是在其词法作用域之外执行。

为什么防抖需要闭包?

因为我们需要一个"私人空间"来存储定时器,这个空间不能被外界随意修改,但又需要持久存在。闭包正好提供了这样的能力。

初版:最基础的防抖实现

让我们从最简单的防抖开始:

bash 复制代码
function debounceBasic(func, wait) {
  let timeout;
  return function() {
    clearTimeout(timeout);
    timeout = setTimeout(func, wait);
  };
}

逐行解析:

  1. let timeout:声明一个变量来存储定时器
  2. return function():返回一个新函数(这就是闭包!)
  3. clearTimeout(timeout):每次调用都清除之前的定时器
  4. timeout = setTimeout(func, wait):重新设置定时器

使用方法:

javascript 复制代码
const searchInput = document.getElementById('search');
function doSearch() {
  console.log('实际搜索操作');
}

searchInput.addEventListener('input', debounceBasic(doSearch, 500));

现在,无论用户输入多快,只有在停止输入500毫秒后才会执行搜索。

但是...这个版本有问题!

如果我们搜索的方法需要参数,或者需要正确的 this 指向,这个简单版本就失效了。比如:

javascript 复制代码
function doSearch(keyword) {
  console.log(`搜索: ${keyword}`);
}

// 这样调用会出错,因为func没有接收到参数!
searchInput.addEventListener('input', function(e) {
  debounceBasic(() => doSearch(e.target.value), 500);
});

所以我们需要改进!

进阶:处理this指向和参数

来看增强版:

javascript 复制代码
function debounceWithArgs(func, wait) {
  let timeout;
  return function(...args) {
    const context = this; // 保存this指向
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(context, args); // 使用apply保持this和参数
    }, wait);
  };
}

这里的关键点:

  • ...args:收集所有参数
  • const context = this:保存函数执行时的this指向
  • func.apply(context, args):使用apply方法调用原函数

apply方法小课堂
func.apply(context, args) 意思是:调用 func 函数,让函数中的 this 值为 context,参数为 args 数组。

现在我们可以正确使用了:

javascript 复制代码
searchInput.addEventListener('input', debounceWithArgs(function(e) {
  console.log(`搜索: ${e.target.value}`);
}, 500));

完美!参数和this指向都正确了。

但产品经理又提新需求了:"我希望用户第一次输入时立即搜索,然后才开启防抖..."

增强:立即执行选项

来看支持立即执行的版本:

ini 复制代码
function debounceImmediate(func, wait, immediate) {
  let timeout;
  return function(...args) {
    const context = this;
    const later = () => {
      timeout = null;
      if (!immediate) func.apply(context, args);
    };
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) func.apply(context, args);
  };
}

逻辑解析:

  1. const later:定义延迟执行的函数
  2. const callNow = immediate && !timeout:判断是否需要立即执行
  3. 如果 immediate 为 true 且没有定时器,就立即执行
  4. 无论是否立即执行,都要重新设置定时器

使用方式:

javascript 复制代码
// 首次输入立即执行,后续输入防抖
searchInput.addEventListener('input', debounceImmediate(function(e) {
  console.log(`搜索: ${e.target.value}`);
}, 500, true));

现在我们的防抖函数已经很强大了,但还有可以完善的地方...

完善:取消功能与返回值处理

有时候,我们可能需要在防抖执行前取消它。比如用户快速输入后又点击了取消按钮。

来看最终完善版:

ini 复制代码
function debounceComplete(func, wait, immediate) {
  let timeout, result;
  
  const debounced = function(...args) {
    const context = this;
    const later = () => {
      timeout = null;
      if (!immediate) result = func.apply(context, args);
    };
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) result = func.apply(context, args);
    return result;
  };
  
  // 添加取消方法
  debounced.cancel = function() {
    clearTimeout(timeout);
    timeout = null;
  };
  
  return debounced;
}

这个版本增加了:

  1. 返回值处理:保存并返回函数的执行结果
  2. 取消方法 :可以通过 .cancel() 取消延迟执行

使用示例:

javascript 复制代码
const debouncedSearch = debounceComplete(function(e) {
  return fetchResults(e.target.value);
}, 500);

searchInput.addEventListener('input', debouncedSearch);

// 如果需要取消
cancelButton.addEventListener('click', function() {
  debouncedSearch.cancel();
});

测试:编写完整的测试用例

好的代码需要有测试保障。我们来写几个简单测试:

scss 复制代码
// 模拟定时器
jest.useFakeTimers();

test('防抖函数基本测试', () => {
  const mockFunc = jest.fn();
  const debounced = debounceComplete(mockFunc, 500);
  
  // 快速调用多次
  debounced();
  debounced();
  debounced();
  
  // 时间快进
  jest.advanceTimersByTime(500);
  
  // 应该只执行一次
  expect(mockFunc).toHaveBeenCalledTimes(1);
});

test('立即执行测试', () => {
  const mockFunc = jest.fn();
  const debounced = debounceComplete(mockFunc, 500, true);
  
  // 第一次调用应该立即执行
  debounced();
  expect(mockFunc).toHaveBeenCalledTimes(1);
  
  // 快速再次调用
  debounced();
  debounced();
  
  // 时间快进
  jest.advanceTimersByTime(500);
  
  // 应该仍然只执行一次(因为后续调用被防抖了)
  expect(mockFunc).toHaveBeenCalledTimes(1);
});

实战:在React/Vue中的实际应用

在React Hooks中使用

ini 复制代码
import { useCallback, useRef } from 'react';

function SearchComponent() {
  const [results, setResults] = useState([]);
  
  const debouncedSearch = useCallback(
    debounceComplete(async (keyword) => {
      const data = await fetchSearchResults(keyword);
      setResults(data);
    }, 500),
    [] // 依赖项为空,确保debouncedSearch不会重新创建
  );
  
  const handleInputChange = (e) => {
    debouncedSearch(e.target.value);
  };
  
  return (
    <div>
      <input type="text" onChange={handleInputChange} />
      <SearchResults results={results} />
    </div>
  );
}

在Vue中使用

javascript 复制代码
import { debounceComplete } from '@/utils/debounce';

export default {
  data() {
    return {
      results: []
    };
  },
  methods: {
    handleSearch: debounceComplete(async function(keyword) {
      const data = await this.$api.search(keyword);
      this.results = data;
    }, 500)
  }
};

延伸:防抖与节流的选择

防抖的好兄弟------节流(throttle)也经常被问到。它们有什么区别呢?

防抖 :多次触发,只执行最后一次(等你说完我再响应)
节流:多次触发,每隔一段时间执行一次(你说你的,我按我的节奏响应)

使用场景

  • 防抖:搜索建议、窗口调整结束后的操作
  • 节流:滚动加载、鼠标移动事件、游戏中的按键处理

如何选择?问自己:是需要在连续操作结束后执行一次,还是需要定期执行?

总结:从闭包到完整工具的思考

通过这篇文章,我们不仅学会了如何实现一个完整的防抖函数,更重要的是理解了背后的设计思想:

  1. 闭包是基础:它提供了私有变量和状态保持的能力
  2. 渐进式完善:从简单到复杂,逐步满足各种需求
  3. API设计:好的工具函数应该考虑各种使用场景
  4. 测试保障:确保代码的可靠性和稳定性

现在你已经掌握了防抖的精髓,可以 confidently 应对面试和实际开发中的相关问题了!

附录:完整代码实现

ini 复制代码
function debounceComplete(func, wait, immediate) {
  let timeout, result;
  
  const debounced = function(...args) {
    const context = this;
    const later = () => {
      timeout = null;
      if (!immediate) result = func.apply(context, args);
    };
    const callNow = immediate && !timeout;
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
    if (callNow) result = func.apply(context, args);
    return result;
  };
  
  debounced.cancel = function() {
    clearTimeout(timeout);
    timeout = null;
  };
  
  return debounced;
}

希望这篇文章对你有帮助!如果有任何问题,欢迎在评论区留言讨论。记得点赞收藏哦~

相关推荐
今禾3 小时前
深入浅出:ES6 Modules 与 CommonJS 的爱恨情仇
前端·javascript·面试
前端小白19953 小时前
面试取经:Vue篇-Vue2响应式原理
前端·vue.js·面试
子兮曰3 小时前
⭐告别any类型!TypeScript从零到精通的20个实战技巧,让你的代码质量提升300%
前端·javascript·typescript
前端AK君3 小时前
如何开发一个SDK插件
前端
小满xmlc3 小时前
WeaveFox AI 重新定义前端开发
前端
日月晨曦3 小时前
大文件上传实战指南:让「巨无霸」文件也能「坐高铁」
前端
拜无忧3 小时前
css带有“反向圆角”的 Tab 凸起效果。clip-path
前端·css
月亮慢慢圆3 小时前
Intersection Observer API
前端