防抖与节流:写事件处理函数前先想清楚
每次面试别人我都会问这两个概念,每次自己写滚动监听之前也都会重新想一遍。与其说是两个知识点,不如说是一道选择题:这个事件,你到底想响应多少次?
问题是什么
浏览器里有些事件触发得非常勤快:输入框每敲一个键触发一次 input,滚动条每移动一像素都可能触发一次 scroll,鼠标在元素上每经过一个子节点触发一次 mousemove。
如果事件处理函数很重,比如每次都要发请求或者做大量计算,浏览器就会卡给你看。
解法有两个方向,取决于业务语义。
防抖:等你说完我再动
防抖(debounce)的语义是:停止触发 N 毫秒后,才真正执行。如果在这期间又触发了,就重新计时。
典型场景是搜索联想。用户想输入「防抖与节流」五个字,不应该每敲一个键就发一次请求:
function debounce<T extends (...args: never[]) => void>(
fn: T,
wait = 300
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | undefined;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
input.addEventListener("input", debounce((event) => {
fetchSuggestions(event.target.value);
}, 400));
用户连续输入时,请求永远不会发出;停顿超过 400 毫秒,才发最后那一次。五次请求合并成了一次。
节流:按固定节奏响应
节流(throttle)的语义是:不管触发多频繁,每 N 毫秒最多执行一次。
典型场景是滚动监听。页面滚动时想更新一个回到顶部的按钮显隐,用户的手不会停,但 UI 每 200 毫秒更新一次完全够用:
function throttle<T extends (...args: never[]) => void>(
fn: T,
interval = 200
): (...args: Parameters<T>) => void {
let last = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - last < interval) return;
last = now;
fn(...args);
};
}
window.addEventListener("scroll", throttle(updateBackToTop, 200));
注意这是最简单的「 Leading edge」实现:先执行再计时。如果想要停止触发后再补一次,就得加上 trailing 逻辑,复杂度翻倍,多数场景不值得。
怎么选
一句话判断:关心最终状态用防抖,关心过程反馈用节流。
- 搜索联想、表单校验、窗口 resize 后重算布局:防抖
- 滚动加载、滚动吸顶、拖拽时的位置同步:节流
另外两个提醒。第一,addEventListener 的第三个参数传 passive: true,滚动场景能明显更顺滑。第二,组件卸载时记得移除监听并清掉定时器,不然防抖函数会在组件销毁后再执行一次。
就这些。下次写事件处理函数之前,先问一句:这个事件,我想响应多少次?
收录于 前端 系列