搜个东西,输入法还没打完,后台接口已经“砰砰砰”打出去七八个请求——这种场景我敢说每个前端都经历过。你在搜索框里敲了个“手”,请求发出去了;又敲了个“机”,请求又发出去了;等拼音还没选完,第三个请求又跑了。用户那边可能只是手速快了点,服务端那边却已经忙得团团转。这就是这篇文章要聊的东西:前端JS防抖,一个看起来简单、用起来容易翻车、面试还必考的基础技能。
防抖(debounce)不复杂,但它背后牵扯到闭包、定时器、this指向、事件循环这些JS核心机制,而且在实际项目里有好多细节是八股文里不讲的。这篇文我会从“它到底解决什么问题”讲起,一步步拆原理、写代码、对比节流、讲React/Vue里的正确姿势,最后把面试官喜欢追问的点也一并捋干净。不管你是刚入行的新手,还是准备跳槽的老兵,这篇应该都能给你点实在的东西。
1. 一次搜索引发的连环请求:防抖到底在解决什么问题
1.1 从输入框的“手滑”到爆掉的接口
先还原一个日常到不能再日常的场景。
用户在一个电商网站的搜索框里输入“手机”,他真实的输入节奏大概是这样的:先敲“shouji”拼音,然后候选词慢慢弹出来,他可能还会犹豫一下,最后点击“手机”这个词。这个过程中,如果前端在input事件里直接发请求,绑定的可是每一次键盘敲击。中文输入法下,你敲“shouji”六个字母,input事件可能已经触发了六七次甚至更多。
你想一想,一个搜索接口,用户还没决定搜什么,就已经被请求了六七次。如果这是热门搜索词,全站同时几百上千人在输入,后端搜索服务扛不扛得住?
真实我在项目里见过最离谱的一次,运营反馈“搜索接口超时严重”,排查下来发现前端在keyup事件里直接调了搜索接口,而且没有做任何防抖。一个用户一次搜索操作,硬生生发出十几个请求。高峰期QPS直接翻了好几倍,接口不超时才怪。
1.2 防抖的“一锤定音”逻辑:把风暴收敛成一次
防抖做的事情用一句话概括就是:在事件被连续触发时,只在一定时间间隔内没有再次触发后,才执行目标函数。换句话说,它把“频繁触发的风暴”收敛成“最后一次触发后的安静时刻执行一次”。
你可以把它类比成电梯关门的逻辑。电梯门快要关上的时候,如果又有人进来了,门会重新打开,等待时间重新计时,直到一段时间内没人进入,才真正关门上行。防抖就是给函数执行装了一扇这样的电梯门:用户每次触发事件,相当于有人进电梯,计时器重置;只有等用户停下来不再触发,计时器走完,函数才“关门上行”。
这个逻辑听起来非常简单,但它的意义很大:防抖的核心价值在于,它把你的函数从“响应每一次用户动作”变成“响应用户动作的停顿结果”。搜索框场景下,用户只有在停止输入后,才会真正发起搜索请求,这才是符合用户心智的。
1.3 防抖解决的三大类典型场景
防抖在真实项目里主要解决三类问题:
- 高频事件里的昂贵操作:比如
input事件里的搜索请求、resize事件里的重计算、scroll事件里的懒加载判断。这些事件本身触发频率极高,如果每次都执行完整逻辑,性能会非常差。 - 按钮重复提交:用户手抖或者网卡,快速点击“提交”按钮,结果订单提交了好几次。用防抖可以让提交动作只在点击停止后执行一次,或者配合“立即执行版”让第一次点击生效而后续点击失效。
- 状态同步与保存:比如富文本编辑器里的自动保存,用户一直在打字,每次输入都调保存接口不现实,用防抖等用户停顿了再保存,既不会丢数据,也不会把服务器打爆。
铺垫完“是什么”和“为什么”,接下来拆原理。防抖方案到底怎么实现?里面有哪些坑?我们来把它大卸八块。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆开看防抖的引擎:闭包、定时器和this
2.1 闭包为什么是防抖的“记忆仓库”
要写出一个防抖函数,最关键的一个前置知识是闭包。闭包可以理解为“函数能记住它出生时所在作用域里的变量”。防抖函数要正常工作,必须维护一个“定时器ID”的状态,而且这个状态必须跨多次事件触发持续存在。
如果不用闭包,定时器ID放在全局变量里,倒是也能实现,但会有两个问题:一是污染全局作用域,二是多个防抖实例会共用同一个定时器ID,互相干扰。比如页面里有两个搜索框,都用同一个全局定时器,你在左边输入会让右边的防抖重置,这显然不合理。
用闭包就能完美解决。外层函数debounce每次执行时,都会创建一个新的作用域,里面保存独立的timer变量。返回的内部函数每次被调用时,通过闭包访问同一个timer。这样每个防抖实例都有自己独立的“记忆仓库”,互不干扰。
javascript复制function debounce(fn, delay) {
let timer = null; // 这个变量就是那个“记忆仓库”
return function(...args) {
// 每次触发都重置定时器
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
这版代码是防抖的“教科书原型”。但注意,这个版本里藏着一个关键细节:fn.apply(this, args)。为什么要用apply绑this?这就引出第二个核心点。
2.2 定时器里的this和arguments:两个经典细节坑
JavaScript的函数里,this指向什么取决于调用方式。如果你在防抖函数里直接写fn(...args),那么当fn原本是某个对象的方法时,比如obj.search(),直接调用会让search内部的this丢失,指向window或undefined(严格模式下),而不是obj。
举个例子:
javascript复制const obj = {
keyword: '手机',
search() {
console.log(this.keyword);
}
};
const debouncedSearch = debounce(obj.search, 500);
debouncedSearch(); // 如果不处理this,这里会打印undefined
看起来debouncedSearch是obj.search的防抖版本,但实际上调用时this早就丢了。解决办法就是内部函数里保存this,然后在定时器回调里通过apply把它绑回去。
arguments的问题类似。事件回调里经常要用到event对象,比如event.target.value。如果在返回的内部函数里不把arguments传进去,定时器回调里的fn就拿不到event。ES6的剩余参数...args在这里不只是语法糖,它恰好同时解决了arguments的透传问题。
还有一个小坑:timer初始值是null还是undefined其实无所谓,clearTimeout对null和已失效的定时器ID都不会报错,所以不用做额外判断。
2.3 原生写法逐行拆解
把上面所有知识点合起来,一版相对完整的防抖函数长这样:
javascript复制function debounce(fn, delay = 300, immediate = false) {
let timer = null;
let isInvoked = false; // 用于立即执行版,后面讲
return function(...args) {
const context = this; // 保存this
clearTimeout(timer);
if (immediate) {
// 立即执行版:第一次触发立即执行,之后在延迟期内不执行
if (!isInvoked) {
fn.apply(context, args);
isInvoked = true;
}
timer = setTimeout(() => {
isInvoked = false;
}, delay);
} else {
// 常规版:停止触发后延迟执行
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
}
};
}
注意观察isInvoked这个标志位的作用。常规版在延迟期内每次触发都会清掉上一次的定时器,所以永远只有最后一次触发后的delay毫秒会执行。立即执行版则是反过来:第一次触发时立即执行,然后打开一个“禁入窗口”,窗口内所有触发都被忽略,直到窗口关闭。这样既能防抖,又能保证第一次交互的响应速度(比如按钮提交通常需要第一次点击立刻生效,而不是等500毫秒)。
这里有一个容易迷惑的地方:立即执行版里面,定时器的作用不是“延迟执行”,而是“延迟打开允许执行的开关”。这个转换理解清楚了,防抖就算吃透了一大半。
3. 从基础版到工业级:防抖函数的四轮演进
光会写基础版肯定不够,真实项目里会有返回值、取消防抖、维护this、事件对象透传等一堆需求。我把防抖函数从“能跑”到“好用”的演进过程拆成四个阶段,你可以对照自己的代码看看处在哪个阶段。
3.1 第一版:基础尾执行版
第一版就是上面写过的常规版,思路是“最后一次触发后延迟执行”。适合搜索请求、resize重算这种“等用户停下来再做事”的场景。
javascript复制function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
这一版已经能解决大部分问题,但它有个明显不足:如果用户一直在操作,函数可能永远不执行。比如页面有个“拖动滑块改变布局”的功能,用户一直拖,防抖函数可能一直等,布局就一直没有更新,直到停下来才一次性更新。某些场景下这是能接受的,但按钮提交这种场景就不行,用户点了没反应,还得多等一会儿,体验很差。
3.2 第二版:立即执行版,让第一次点击不被吞掉
第二版解决了“首次触发无响应”的问题。核心逻辑是:第一次触发立即执行,后续延迟期内触发全部忽略,延迟期结束后重新“待命”。
javascript复制function debounce(fn, delay, immediate = true) {
let timer = null;
return function(...args) {
const context = this;
if (immediate) {
const callNow = !timer;
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
}, delay);
if (callNow) {
fn.apply(context, args);
}
} else {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
}
};
}
这一版有个精妙的地方:用timer是否为空来判断“现在能不能立即执行”。第一次触发时timer是null,callNow为true,立即执行,然后设置定时器;延迟期内timer一直有值,后续触发都走clearTimeout + 重新设置定时器的逻辑,callNow一直是false,不会执行;直到定时器到点把timer置空,下一次触发才重新进入“立即执行”分支。
按钮提交场景推荐用这一版。用户第一次点击立即发请求,后续点击在延迟期内被吞掉,防止重复提交。但如果你用的是上面的代码,会发现它有一个小bug:如果用户点击后马上又点击了一次,第二次点击虽然被吞掉了,但它会重置定时器,导致timer被延后置空,用户可能要等更久才能进行下一次有效点击。这个问题在下一版解决。
3.3 第三版:支持取消,解决“过期回调”问题
真实项目里经常遇到另一个场景:防抖函数已经发出去了(比如搜索请求),但用户又操作了,或者组件卸载了。如果请求回调后来才返回,可能已经更新了一个“已卸载组件”的状态,React里会报“Can't perform a React state update on an unmounted component”的警告。
解决办法是给防抖函数加一个cancel方法,让外部能手动取消防抖和重置状态。
javascript复制function debounce(fn, delay, immediate = false) {
let timer = null;
let isInvoked = false;
function debounced(...args) {
const context = this;
clearTimeout(timer);
if (immediate && !isInvoked) {
fn.apply(context, args);
isInvoked = true;
}
timer = setTimeout(() => {
if (!immediate) {
fn.apply(context, args);
}
isInvoked = false;
timer = null;
}, delay);
}
debounced.cancel = function() {
clearTimeout(timer);
timer = null;
isInvoked = false;
};
return debounced;
}
注意这里cancel为什么能生效:返回的debounced函数本身是一个对象,函数也是对象,可以挂属性。外部调用cancel时,通过闭包访问到内部的timer和isInvoked,把它们重置。组件卸载时执行cancel,就能避免“卸载后还去更新状态”这种问题。
这一版还有个细节:定时器回调里把timer置null了。为什么?为了和立即执行版的“第一次触发立即执行”逻辑配合——只有timer为空,下一次触发才会走立即执行分支。这也是我在实际项目里比较推荐的写法。
3.4 第四版:返回值怎么办——同步返回和异步回调两条路
基础版、立即执行版、可取消版都解决了,但还有一个终极问题:防抖函数要返回值,怎么办?
比如你有一个getValue函数,返回数字,你希望防抖后调用,拿到的还是这个函数本身返回的值。常规的防抖实现是拿不到返回值的,因为内部函数执行时,fn被包在setTimeout里异步执行,返回值异步了,外层拿不到。
答案是:不要想拿到同步返回值。因为防抖本身是异步的,永远不可能同步返回被防抖函数的返回值。如果你的业务真的需要返回值,有两种策略:
- 策略一:让被防抖的函数通过回调函数或Promise返回结果,调用方在异步回调里使用结果。
javascript复制function debounce(fn, delay) {
let timer = null;
let lastResolve;
let lastReject;
return function(...args) {
const context = this;
if (timer) clearTimeout(timer);
return new Promise((resolve, reject) => {
lastResolve = resolve;
lastReject = reject;
timer = setTimeout(() => {
try {
const result = fn.apply(context, args);
lastResolve(result);
} catch (e) {
lastReject(e);
}
}, delay);
});
};
}
这个版本有个特性:如果在延迟期内多次调用,之前的Promise会被新的Promise“顶掉”,最终只会有一个Promise resolve,符合防抖的语义。
- 策略二:如果一定要拿同步返回值,比如
const hasError = debouncedValidate(input),那说明你的设计本身有问题。防抖的语义决定了它不能同步返回。要么改成节流(节流可以返回同步值),要么把“同步校验”改成“异步校验”。
关于第四版的坑,实际项目中踩过的人应该不少。很多人在面试手写防抖时,写不出“返回值版本”,其实不是能力问题,而是没有想清楚“异步函数没法同步返回”这一层。基于实际经验,我更推荐在业务里统一用Promise版本,因为回调地狱太痛苦了,而且Promise可以和async/await无缝配合。
4. 防抖和节流的“同与不同”:选错等于白写
前面讲防抖说了很多,但有一个话题怎么也绕不开:节流。面试必问,项目里也经常需要。防抖和节流经常被一起提起,很多人混着用,但它们的语义和行为差别很大,选错了,代码跑起来就很怪。
4.1 各自应对的典型场景
节流(throttle)的核心语义是:在一段时间内,最多执行一次。它的效果是“均匀地、定期地执行”,不管用户触发多少次,每隔delay毫秒执行一次。
防抖的核心语义是:停止触发后延迟执行(或立即执行一次后等待),它追求的是“只执行一次”和“在安静时刻执行”。
打个比方:防抖是“电梯关门等最后一个人进来”,节流是“地铁每5分钟一班,不管站台有多少人”。
典型场景对比:
| 场景 | 选防抖 | 选节流 | 原因 |
|---|---|---|---|
| 搜索框实时搜索 | 是 | 否 | 用户停顿后搜一次就够了 |
| 滚动加载更多 | 否 | 是 | 滚动过程中需要持续判断,但要限制频率 |
| 按钮防止重复提交 | 是 | 否 | 只需第一次或最后一次生效 |
| 拖拽时的位置计算 | 否 | 是 | 拖拽过程中要持续更新,但不想每像素触发一次 |
resize后重新布局 |
是 | 是 | 取决于需求:停稳后算一次用防抖;拖动时持续算用节流 |
| 游戏里的射击 | 否 | 是 | 必须限频,不能等玩家停手 |
4.2 一张表记住五组区别
很多人记不住防抖和节流的区别,我总结过一个速记表:
| 对比维度 | 防抖 | 节流 |
|---|---|---|
| 执行时机 | 停止触发后执行(或立即执行后等待) | 固定间隔内最多执行一次 |
| 触发多次的最终结果 | 只执行一次(最后一次或第一次) | 可能执行多次(但被限制频率) |
| 类比 | 电梯等人 | 地铁发车 |
| 代码核心 | clearTimeout + setTimeout |
时间戳判断或setTimeout标志位 |
| 适合场景 | 输入、按钮、自动保存 | 滚动、拖拽、动画 |
这个表是我自己整理的,记忆口诀是“防抖看停稳,节流看间隔”。
4.3 一个实战例子:按钮点击用防抖还是节流?
曾有个朋友问:“提交按钮,我用防抖还是节流?”答案是:如果目标是防止重复提交,防抖更合适,尤其是立即执行版——用户第一次点击立刻生效,后续点击在延迟窗口内被吞掉;如果用节流,用户可以每隔几百毫秒提交一次,仍然可能重复提交。
但还有一种情况:按钮要“多阶段提交”,比如上传文件时进度条要持续更新,点击一次启动上传,上传过程中防抖就不合适了,因为防抖会吞掉后续点击,进度条不更新。这时候应该把“发起上传”这个动作做防抖,把“进度更新”这个动作做节流,或者直接用节流实现“每200毫秒更新一次进度”。
所以选型不是“非此即彼”,而是“在这个事件上,语义是停稳执行还是限频执行”。
5. 真实项目中的正确打开方式:React/Vue/纯JS三种姿势
理论说了一大堆,现在讲讲项目里怎么落地。前端框架现在基本是React和Vue的天下,简单场景直接用工具函数,复杂场景会有一些框架特有的坑,得分别处理。
5.1 React Hook版本:useDebounce从0到1
React里的防抖主要有两个场景:一个是对回调函数防抖(比如onChange里调API),另一个是对state本身防抖(比如input框的值,用户停止输入后延迟更新)。
场景一:对回调函数防抖,直接用自定义Hook。
javascript复制import { useCallback, useRef } from 'react';
function useDebouncedCallback(callback, delay) {
const callbackRef = useRef(callback);
const timerRef = useRef(null);
// 每次渲染时同步最新的callback,避免闭包捕获旧值
callbackRef.current = callback;
const debouncedFn = useCallback((...args) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
return debouncedFn;
}
这里有一个React特有的坑:闭包陷阱。如果你直接把传入的callback闭包到setTimeout里,而callback又在每次渲染时都变化(组件里大多数人都会内联箭头函数),那么定时器触发时拿到的可能是旧版本的callback。解决办法是把callback存在ref里,每次渲染时更新ref.current,定时器回调只读取ref.current,永远拿到最新版本。这个技巧在React里几乎所有Hook类工具函数都适用。
场景二:对state防抖,思路也简单。
javascript复制function useDebouncedValue(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
这里useEffect的清理函数天然充当了clearTimeout的角色。value变化时,先清理上一次的定时器,再开新的,等delay毫秒后更新。这个Hook特别适合搜索框:input的值瞬间更新,但debouncedValue停稳后才变,然后用useEffect监听debouncedValue变化去请求接口。
5.2 Vue里的一行指令和底层原理
Vue的写法比React简单很多,因为Vue的事件绑定天然支持修饰符,虽然官方没有直接提供debounce修饰符,但自定义指令做防抖非常方便。
javascript复制// v-debounce 指令
const debounceDirective = {
mounted(el, binding) {
const { value, arg } = binding;
const delay = Number(arg) || 500;
let timer = null;
el._debounceHandler = function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
value.apply(this, args);
}, delay);
};
el.addEventListener('input', el._debounceHandler);
},
unmounted(el) {
el.removeEventListener('input', el._debounceHandler);
if (el._debounceTimer) clearTimeout(el._debounceTimer);
}
};
用法:
html复制<input v-debounce:500="handleSearch" />
这个指令把防抖逻辑封在mounted里,在unmounted里清理监听器和定时器,避免内存泄漏。Vue2.0时代的lodash.debounce也常用,但用指令可以把“防抖逻辑”从业务组件里彻底抽出去,组件里只写业务函数,代码更干净。
Vue里还有一个容易踩的坑:v-model和防抖同时用时,v-model会直接改数据,input的显示值会立即更新,但搜索值要防抖,两个值要分开。用computed的set做中间层,或者直接用@input而不依赖v-model。
5.3 纯JS业务中的常见坑位盘点
不用框架,纯JS项目里也有三个高频坑:
-
坑一:监听器没有保存防抖后的引用,导致
removeEventListener失效。 很多人写el.addEventListener('click', debounce(fn, 500)),然后el.removeEventListener('click', debounce(fn, 500)),以为能解绑,实际上每次调用debounce都生成了新函数,removeEventListener根本找不到它。正确做法是const debouncedFn = debounce(fn, 500); el.addEventListener('click', debouncedFn);需要解绑时也用它。 -
坑二:把防抖函数传给第三方组件,但第三方组件内部会解绑。 这个场景多见于封装组件时,把
debounced函数作为props传下去,但子组件在beforeDestroy时尝试移除监听器,结果绑定的函数和移除的函数不是同一个引用,导致事件永远解绑不掉。解决办法是传一个稳定的引用(就像上面debouncedFn那样),或者由父组件统一管理生命周期。 -
坑三:防抖的时间设置不合理。 搜索接口防抖设1000ms,用户会觉得“怎么我停下来了还不出结果”,太肉了。一般的经验值是:搜索接口300~500ms,自动保存800~1200ms,resize重算150~300ms,按钮防重复提交500ms左右。但这个不是绝对的,要结合产品交互和接口耗时来调。
6. 面试官眼里的防抖:从手写代码到八股追问
防抖在前端面试里出现频率极高,基本是必考。面试官考防抖不只是考代码实现,而是通过它同时考察闭包、this、事件循环、工具函数的抽象能力。我梳理一下面试的高频考点和追问方向。
6.1 手写题的五分钟拿分标准
手写一个debounce函数,五分钟内你能写出来,且能说出下面这些点,基本就是高分:
- 用
let timer保存定时器ID,用闭包实现状态保持。 - 内层函数里用
...args透传参数。 - 用
fn.apply(this, args)或fn.call(this, ...args)处理this指向。 - 能解释如果不用
apply绑this会出什么问题。 - 能加一个
immediate参数控制是否立即执行。 - 能加一个
cancel方法取消防抖。
面试官如果让你扩展“支持取消防抖”或者“支持立即执行”,你都能写出来,这题基本就稳了。如果还能说出“防抖的返回值怎么处理”,那就是加分项。
6.2 高频追问的七个问题
面试官写完代码之后,几乎都会追问下面这些问题,提前准备,别在最后一步掉链子。
-
问:防抖和节流的区别是什么?
答:防抖是停止触发后延迟执行,节流是固定间隔内最多执行一次。防抖适合“停稳后做一次”,节流适合“持续过程中限频做”。 -
问:防抖内部为什么用闭包?
答:闭包可以保存独立于每次调用的状态(定时器ID),并且不污染全局作用域,多个实例之间互不干扰。 -
问:如果不处理this会怎样?
答:被防抖的函数如果原本是对象方法,内部this会丢失,导致拿不到方法里依赖的this数据。 -
问:如果事件一直在触发,防抖函数会不会永远不执行?
答:会。所以有了立即执行版和节流作为补充。这也解释了为什么防抖和节流常常一起用。 -
问:React里怎么用防抖?有哪些坑?
答:用useCallback/useRef保证函数引用稳定,清理函数里cleanup定时器,注意闭包陷阱,用ref保存最新回调。 -
问:防抖函数能返回被防抖函数的返回值吗?
答:常规实现不能同步返回。异步场景可以用Promise包装,让调用方通过await拿结果。 -
问:一个防抖函数可以同时满足“第一次立即执行”和“最后一次也执行”吗?
答:可以,但要额外维护一个“是否已执行过”的标志位,遍历两次定时器。这也是一个常见面试变种,思路和前面第三版类似,再加一个leading和trailing选项即可,和Lodash的_.debounce配置很像。
6.3 加分项:讲清防抖在事件循环里的位置
面试官如果水平比较高,可能会追问:“setTimeout的延迟时间一定准确吗?”这个问题其实是在考察事件循环的理解。setTimeout的delay表示“最早延迟时间”,不是“精确延迟时间”。如果主线程有长任务(比如大量DOM操作同步执行),定时器回调会被推迟到任务队列空闲之后。在防抖里,这种“不准确”反而无所谓,因为防抖本身不要求精确计时,它要求的是“安静一段时间后执行”。但对节流来说,如果用setTimeout实现,时间就不够精确,所以生产级的节流函数通常用时间戳实现。能讲到这一层,说明你不是在背代码,是真的理解原理。
7. 实际项目里我踩过的那些坑(经验篇)
最后分享几个我实际项目中踩过的防抖相关的坑,这些内容八股文里不会写,但碰到了就是实实在在的线上问题。
7.1 “防抖后的函数”在React组件卸载后还在执行
这是我最开始用React时踩的坑。组件里:
javascript复制const handleSearch = debounce((keyword) => {
fetch(`/api/search?q=${keyword}`).then(res => setList(res.data));
}, 500);
用户输入,正常没问题。但如果快速切换路由,组件卸载了,此时防抖定时器还未触发,等它触发后,setList被调用,在React 18里会报警告(甚至在某些库下会报错)。解决办法是在useEffect的清理函数里调handleSearch.cancel(),前提是你的防抖函数实现了cancel方法。这就是前面讲第三版时有cancel的原因。
7.2 搜索接口的竞态问题
防抖能减少请求次数,但防不了“请求竞态”。比如用户输入“苹果”,防抖后发出请求A,又改成“苹果手机”,防抖后发出请求B。如果A比B晚返回(网络抖动了),界面上最终显示的可能是“苹果”的结果,而不是用户最后输入的“苹果手机”。
这个坑很多年了,防抖并不能解决。解决办法是在请求回调里加一个序号校验,或者用AbortController取消前一次请求。最简单的做法是:
javascript复制let requestId = 0;
const debouncedSearch = debounce(async (keyword) => {
const currentId = ++requestId;
const res = await fetch(`/api/search?q=${keyword}`);
if (currentId === requestId) {
setList(res.data);
}
}, 300);
每次请求发出前递增requestId,回调回来时检查自己是不是最新的,如果不是就丢弃。这个方案简单可靠,比引入第三方库省事太多。
7.3 别把所有输入框都加上同样的防抖延迟
产品经理可能告诉你“搜索框加个防抖”,但如果你无脑加300ms,会遇到一个问题:输入框里输入几个字后,光标还没离开,请求已经发出去了,用户想改正输入都没机会。这个场景下,300ms和500ms的差异看似不大,但实际用起来完全不同。我的经验是:搜索类输入框的防抖时间要和用户的输入速度匹配。中文输入法下,拼音还没选完就触发防抖,选完反而停更久了,体验会很怪。所以输入法场景下要注意compositionstart和compositionend事件,中文选词结束之前不要触发防抖执行。
javascript复制let isComposing = false;
input.addEventListener('compositionstart', () => { isComposing = true; });
input.addEventListener('compositionend', () => {
isComposing = false;
debouncedSearch();
});
input.addEventListener('input', function() {
if (!isComposing) {
debouncedSearch();
}
});
这个细节在真实项目里非常常见,尤其是中文产品。不处理组合输入,搜索词会被截断,请求里的关键词可能是拼音而不是汉字。
我自己的体会是:防抖说起来简单,真正用到一个项目里,涉及到的边界情况非常多。它不是写完一个工具函数就完事,而是要从“用户如何触发、触发后要什么结果、组件何时销毁、接口返回顺序”这一整条链路去考虑。如果你能把上面这些坑都规避掉,防抖才算真正用好了。
