JavaScript 防抖与节流详解
JavaScript 防抖与节流详解
引言
在前端开发中,DOM 事件监听是再常见不过的操作。用户的每一次点击、输入、滚动、窗口调整大小等行为,都会触发大量事件。如果事件处理函数涉及复杂的计算、昂贵的 API 调用或频繁的 DOM 操作,这些频繁触发的事件将导致严重的性能问题,甚至造成页面卡顿。防抖(Debounce)和节流(Throttle)作为两种经典的性能优化技术,能够有效控制事件处理函数的执行频率,是每个前端开发者必须掌握的技能。本文将系统讲解这两种技术的原理、实现方式以及在实际开发中的应用场景。
防抖(Debounce)
防抖的原理
防抖的核心思想是:当事件持续触发时,事件处理函数不会被立即执行,而是等待一个静默期。只有当用户停止触发事件超过指定时间后,事件处理函数才会被执行。如果在等待期间事件再次被触发,计时器会重新开始计时。这个过程就像我们等公交车:只要不断有人上车,司机就会一直等待,直到没有人再上车,才关门发车。
防抖的代码实现
#### 基础版本
function debounce(func, wait) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
}
使用示例:
function handleSearch(keyword) {
console.log(`搜索关键词: ${keyword}`);
}
const debouncedSearch = debounce(handleSearch, 300);
// 模拟用户快速输入
debouncedSearch('a');
debouncedSearch('ab');
debouncedSearch('abc');
debouncedSearch('abcd');
// 只会在停止输入 300ms 后执行一次
// 输出: 搜索关键词: abcd
#### 立即执行版本
有时我们需要在事件触发时立即执行一次,然后进入防抖等待:
function debounce(func, wait, immediate = false) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
if (immediate) {
// 如果已经执行过,timeout 存在则表示在等待期内
const callNow = !timeout;
timeout = setTimeout(() => {
timeout = null;
}, wait);
if (callNow) {
func.apply(context, args);
}
} else {
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
}
};
}
运行效果:
const handleInput = debounce((value) => {
console.log(`最终输入值: ${value}`);
}, 300, true);
handleInput('a'); // 立即执行: 最终输入值: a
handleInput('ab'); // 在 300ms 内,不执行
handleInput('abc'); // 在 300ms 内,不执行
// 300ms 后 timeout 清除,下次调用会立即执行
handleInput('abcd'); // 立即执行: 最终输入值: abcd
#### 带取消功能的版本
function debounce(func, wait, immediate = false) {
let timeout;
const debounced = function(...args) {
const context = this;
clearTimeout(timeout);
if (immediate) {
const callNow = !timeout;
timeout = setTimeout(() => timeout = null, wait);
if (callNow) func.apply(context, args);
} else {
timeout = setTimeout(() => func.apply(context, args), wait);
}
};
debounced.cancel = function() {
clearTimeout(timeout);
timeout = null;
};
return debounced;
}
节流(Throttle)
节流的原理
节流的核心思想是:限制事件处理函数的执行频率。无论事件触发多么频繁,事件处理函数在单位时间内只会执行一次。这就像水龙头滴水:不管你怎么拧动,每次只能滴出一滴水。节流确保了函数调用的稳定性,不会因为事件过于频繁而导致性能问题。
节流的代码实现
#### 时间戳版本
function throttle(func, wait) {
let previous = 0;
return function(...args) {
const now = Date.now();
const context = this;
// remaining 是距离下一次执行还需要等待的时间
const remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) {
func.apply(context, args);
previous = now;
}
};
}
运行效果:
const handleScroll = throttle((scrollY) => {
console.log(`滚动位置: ${scrollY}`);
}, 200);
handleScroll(100); // 立即执行
handleScroll(200); // 立即执行
handleScroll(300); // 立即执行
// 在 200ms 内的调用都会立即执行(因为 previous = 0)
#### 定时器版本
function throttle(func, wait) {
let timeout;
return function(...args) {
const context = this;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(context, args);
}, wait);
}
};
}
运行效果:
const handleScroll = throttle((scrollY) => {
console.log(`滚动位置: ${scrollY}`);
}, 200);
handleScroll(100); // 设置 timeout,200ms 后执行
handleScroll(200); // timeout 存在,不执行
handleScroll(300); // timeout 存在,不执行
// 200ms 后执行: 滚动位置: 100
#### 组合版本(性能最优)
function throttle(func, wait, options = { leading: true, trailing: true }) {
let timeout = null;
let previous = 0;
const { leading, trailing } = options;
return function(...args) {
const context = this;
const now = Date.now();
if (!previous && !leading) previous = now;
const remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(context, args);
previous = now;
} else if (!timeout && trailing !== false) {
timeout = setTimeout(() => {
previous = !leading ? 0 : Date.now();
timeout = null;
func.apply(context, args);
}, remaining);
}
};
}
防抖与节流的区别
核心区别
| 特性 | 防抖(Debounce) | 节流(Throttle) |
|------|-----------------|-----------------|
| 执行时机 | 事件停止触发后延迟执行 | 持续触发时固定频率执行 |
| 时间控制 | 最后一次触发有效 | 每段时间内至少执行一次 |
| 适用场景 | 用户输入确认、搜索建议 | 滚动监听、窗口调整、按钮防双击 |
| 典型延迟 | 较长(500ms-2000ms) | 较短(100ms-300ms) |
视觉对比
防抖示例(wait=300ms):
用户操作: --a--ab--abc--abcd--
执行时机: -----------------abcd
节流示例(wait=300ms):
用户操作: --a--ab--abc--abcd--
执行时机: --a-----------abcd--
实际应用场景
1. 搜索输入框(防抖)
const searchInput = document.getElementById('search');
const suggestions = document.getElementById('suggestions');
const fetchSuggestions = debounce(async (keyword) => {
if (!keyword.trim()) {
suggestions.innerHTML = '';
return;
}
const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const results = await response.json();
suggestions.innerHTML = results.map(r => `<li>${r}</li>`).join('');
}, 300);
searchInput.addEventListener('input', (e) => {
fetchSuggestions(e.target.value);
});
效果: 用户输入时不会立即发起请求,只在用户停止输入 300ms 后才发起请求,减少服务器压力。
2. 窗口调整监听(节流)
const updateLayout = throttle(() => {
console.log('重新计算布局...');
// 执行复杂的布局计算
const width = window.innerWidth;
const height = window.innerHeight;
document.body.style.fontSize = width < 768 ? '14px' : '16px';
}, 200);
window.addEventListener('resize', updateLayout);
效果: 窗口调整大小事件触发非常频繁,节流确保布局计算每隔 200ms 最多执行一次,避免频繁重排重绘。
3. 滚动到底部加载更多(节流)
const loadMore = throttle(async () => {
if (isLoading || hasMore) return;
isLoading = true;
const response = await fetch(`/api/items?page=${currentPage}`);
const data = await response.json();
appendItems(data.items);
currentPage++;
isLoading = false;
if (data.hasMore === false) {
hasMore = true;
window.removeEventListener('scroll', onScroll);
}
}, 500);
window.addEventListener('scroll', onScroll);
4. 表单提交防抖(防止重复提交)
const submitForm = debounce(async (formData) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData)
});
const result = await response.json();
showResult(result);
}, 1000, true);
submitBtn.addEventListener('click', () => {
submitBtn.disabled = true;
submitForm(getFormData());
setTimeout(() => submitBtn.disabled = false, 1000);
});
5. 按钮防双击(立即执行防抖)
const submitOrder = debounce(() => {
console.log('订单提交中...');
// 执行订单提交逻辑
}, 2000, true);
confirmBtn.addEventListener('click', () => {
submitOrder();
});
常见问题与解决方案
Q1: 防抖后函数返回的 Promise 如何处理?
默认防抖实现无法获取异步函数的返回值:
// 改进版:返回 Promise
function debounce(func, wait, immediate = false) {
let timeout;
let resultPromise;
return function(...args) {
const context = this;
return new Promise((resolve, reject) => {
clearTimeout(timeout);
const execute = () => {
try {
const result = func.apply(context, args);
resolve(result);
} catch (e) {
reject(e);
}
};
if (immediate) {
const callNow = !timeout;
timeout = setTimeout(() => timeout = null, wait);
if (callNow) execute();
} else {
timeout = setTimeout(execute, wait);
}
});
};
}
// 使用
const search = debounce(async (q) => {
return await fetch(`/api/search?q=${q}`).then(r => r.json());
}, 300);
search('javascript').then(results => console.log(results));
Q2: 事件监听中使用 <code>addEventListener</code> 的 this 问题
const handleClick = throttle(function() {
console.log('点击了,当前元素:', this.id);
}, 500);
// 正确:保持 this 绑定
button.addEventListener('click', handleClick);
// 错误:this 会丢失
button.addEventListener('click', () => handleClick());
Q3: 取消已经安排的函数执行
const handleSearch = debounce((q) => {
console.log(`搜索: ${q}`);
}, 300);
// 在某些场景下需要取消
cancelBtn.addEventListener('click', () => {
handleSearch.cancel(); // 取消待执行的搜索
console.log('搜索已取消');
});
Q4: 第一次触发时立即执行还是最后一次?
选择取决于业务场景:
- <strong>搜索输入</strong>:通常用普通防抖(最后一次)
- <strong>按钮防抖</strong>:通常用立即执行防抖(第一次)
- <strong>滚动加载</strong>:通常用节流
Q5: 第三方库 vs 手写实现
| 方案 | 优点 | 缺点 |
|------|------|------|
| 手写实现 | 无依赖、定制灵活、轻量 | 需要自己处理边界情况 |
| lodash.debounce | 功能完善、经过大量验证 | 包体积增加(约 3KB) |
| underscore.debounce | 经典库、兼容性好 | 同样增加包体积 |
// lodash 使用
import { debounce, throttle } from 'lodash-es';
// tree-shaking 支持,按需引入
import debounce from 'lodash.debounce';
延伸阅读
1. Lodash 官方文档 - Debounce & Throttle
https://lodash.com/docs/4.17.15#debounce
https://lodash.com/docs/4.17.15#throttle
2. MDN - setTimeout
https://developer.mozilla.org/zh-CN/docs/Web/API/Window/setTimeout
3. CSS-Tricks - Debouncing and Throttling
https://css-tricks.com/debouncing-throttling-explained-examples/
4. JavaScript 事件节流防抖的区别与实现
https://github.com/mqyqingfeng/Blog
5. You Might Not Need Underscore
https://youmightnotneed.com/lodash/
6. Web Performance Working Draft - Event Timing
https://www.w3.org/TR/event-timing/
7. Google Web Fundamentals - Rendering Performance
https://developers.google.com/web/fundamentals/performance/rendering
总结
防抖与节流是前端性能优化的两大利器。防抖适合「等待用户安静下来」的场景,如搜索输入、窗口调整结束确认;节流适合「限制执行频率」的场景,如滚动监听、频繁点击防护。选择哪种技术取决于具体的业务需求和用户体验目标。理解其底层原理后,手写实现并不复杂,但在生产环境中,使用经过验证的 lodash 等库通常是更稳妥的选择。无论采用哪种方案,都应当配合 Chrome DevTools 等性能分析工具进行测试,确保优化真正带来了性能提升而非新的问题。