开篇:为什么我们需要防抖?
今天聊一个既基础又重要的主题------防抖。不过别担心,就算你是新手,我也能让你彻底明白!
想象一下这个场景:你在百度搜索框输入"前端学习路线",每敲一个字母,页面就会向你推荐搜索建议。如果你快速输入"前端"两个字,难道真的要请求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);
};
}
逐行解析:
let timeout
:声明一个变量来存储定时器return function()
:返回一个新函数(这就是闭包!)clearTimeout(timeout)
:每次调用都清除之前的定时器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);
};
}
逻辑解析:
const later
:定义延迟执行的函数const callNow = immediate && !timeout
:判断是否需要立即执行- 如果
immediate
为 true 且没有定时器,就立即执行 - 无论是否立即执行,都要重新设置定时器
使用方式:
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;
}
这个版本增加了:
- 返回值处理:保存并返回函数的执行结果
- 取消方法 :可以通过
.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)也经常被问到。它们有什么区别呢?
防抖 :多次触发,只执行最后一次(等你说完我再响应)
节流:多次触发,每隔一段时间执行一次(你说你的,我按我的节奏响应)
使用场景:
- 防抖:搜索建议、窗口调整结束后的操作
- 节流:滚动加载、鼠标移动事件、游戏中的按键处理
如何选择?问自己:是需要在连续操作结束后执行一次,还是需要定期执行?
总结:从闭包到完整工具的思考
通过这篇文章,我们不仅学会了如何实现一个完整的防抖函数,更重要的是理解了背后的设计思想:
- 闭包是基础:它提供了私有变量和状态保持的能力
- 渐进式完善:从简单到复杂,逐步满足各种需求
- API设计:好的工具函数应该考虑各种使用场景
- 测试保障:确保代码的可靠性和稳定性
现在你已经掌握了防抖的精髓,可以 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;
}
希望这篇文章对你有帮助!如果有任何问题,欢迎在评论区留言讨论。记得点赞收藏哦~