用户双击提交按钮、网络卡时狂点保存、移动端触摸的快速重复触发——前端开发里,重复提交是高频事故源:订单重复创建、审批重复发起、数据重复写入。防抖和节流是应对的两件经典武器。这篇讲两者的区别、实现和实战用法。结论先说:防抖与节流都是控制函数执行频率的技术——防抖是等用户停了再执行(停止触发后计时),节流是固定节奏执行(间隔内只跑一次)——表单提交这类写操作,防抖加禁用按钮双保险,是重复提交的末了一道闸。
一、重复提交的事故现场
1.1 一单变三单的客诉
先讲事故:电商项目的老页面,下单按钮没有防重——用户网络慢,点了一次没反应,三连击——三个创建订单请求先后到达——后端没做幂等——三张订单、三次扣库存。客诉来了:我怎么下了三单?赔优惠券息事宁人。复盘时统计:那个月类似重复提交事故十几起——双击的、狂点的、前端重复触发的——每起都是钱——重复提交这个坑,坑的从来不是技术,是直接的成本。修复分两层:前端加防抖加按钮禁用,后端加幂等键——两层都上,事故归零——这篇讲前端这层,后端幂等是另一门功课,但记住:前端的防重是体验优化,后端的幂等才是安全底线,两层都要有。
1.2 重复触发的高危场景
重复触发不止按钮点击:输入框的搜索联想——每敲一个字发一次请求——请求风暴打后端;滚动加载——滚动事件每秒触发几十次——不做节流,加载逻辑疯狂执行;窗口缩放的重算——缩放过程连续触发——重算抖成动画片;移动端的触摸事件——快速滑动产生密集触发。这些场景的共性:用户操作或浏览器事件的频率,远高于逻辑需要执行的频率——中间需要一道频率控制的闸——人手的频率是情绪化的,代码的频率应该是不动声色的。
二、防抖:等停了再动
2.1 原理与实现
防抖的比喻:电梯关门——有人进来就重新计时,没人再进才关门。触发停止后等一段静默期,静默期满才执行:
function debounce(fn, wait = 500) {
let timer = null;
return function (...args) {
clearTimeout(timer); // 每次触发都取消上次计时
timer = setTimeout(() => {
fn.apply(this, args); // 静默期满才真正执行
}, wait);
};
}
// 用法: 搜索联想
input.addEventListener('input', debounce(doSearch, 400));
实现的关键:闭包持计时器——每次触发先清旧计时再立新的——连续触发期间,执行永远被推迟;只有停下来超过等待期,函数才真正跑一次。防抖的适用特征:只关心较后的状态——搜索联想要的是用户输完的内容,中间过程可丢;窗口缩放重算要的是较终尺寸——过程值无意义——只关心终点的场景,防抖把过程全部折叠。
2.2 立即执行的变体
标准防抖的问题是首次触发也延迟——按钮场景不合适(点一下就该立刻响应)。变体:前缘防抖——首次触发立即执行,之后的连续触发被忽略,静默期满恢复可执行——按钮防连击用这个:点一下立即响应,狂点只算头一下:
function debounceLeading(fn, wait = 800) {
let ready = true, timer = null;
return function (...args) {
if (ready) { // 冷却期外的触发: 立即执行
ready = false;
fn.apply(this, args);
timer = setTimeout(() => ready = true, wait);
} else { // 冷却期内: 忽略, 只续期计时
clearTimeout(timer);
timer = setTimeout(() => ready = true, wait);
}
};
}
前缘防抖的语义:头一下有效,后续狂点只延长冷却——按钮提交、保存操作的标准选择。
三、节流:按固定节奏动
3.1 原理与实现
节流的比喻:公交发车——不管乘客什么时候来,十分钟一班,到点就走。固定时间间隔内,较多执行一次:
function throttle(fn, interval = 300) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) { // 距上次执行超过间隔
last = now;
fn.apply(this, args); // 执行, 并重置计时
} // 间隔内的触发直接丢弃
};
}
// 用法: 滚动加载
window.addEventListener('scroll', throttle(checkLoad, 300));
节流的适用特征:过程也需要执行,但要控制密度——滚动加载要持续检测位置,但不能每像素都测;滚动埋点上报要记录轨迹,但每秒一次足够。和防抖的区别一句话:防抖是等静止(适用于只要结果的场景),节流是限频率(适用于过程也要响应的场景)——按场景的需求选,不是按喜好选——选错的典型症状:该防抖的用了节流,中间态满天飞;该节流的用了防抖,响应时有时无。
三、表单提交的完整防重
3.1 前缘防抖加禁用双保险
提交按钮的标准配置:前缘防抖——头一次点击立即提交,冷却期内的连击全部忽略;提交中禁用——点击后按钮立即置灰加加载状态,请求完成或失败恢复——禁用是视觉和行为双重的闸——用户看到按钮灰了,心理上也停止了再点;恢复的兜底——请求超时也要恢复按钮——别让按钮永久灰掉变成新故障——防重的闸门,不能变成困住用户的牢门。
3.2 别忘了键盘和手势
防重的覆盖面要全:回车键提交——表单的回车和按钮点击是两条触发路径——防抖逻辑要包在提交动作本身,不是包在按钮点击——统一入口,一处防抖;移动端的双触发——触摸加点击事件的偶发双触发——统一走指针事件或做触摸延迟判断;失焦自动保存——自动保存场景用防抖(停止输入后保存),别用节流(会保存一堆中间态)。提交防重的架构原则:防重逻辑在提交函数的门口,不在各个触发源上——门口一道闸,比每条路设卡可靠——安全设计的头号铁律:入口少,才守得住。
四、效果的量化
防重上线后的量化:重复订单事故——从前端和后端双层防重上线后归零——单是前端层的拦截统计:冷却期拦截的重复点击,提交类按钮平均拦截率百分之六——一百次点击里有六次是会被重复提交的狂点——乘以全站的提交量,就是每天默默避免的一堆事故;搜索联想的请求量——防抖后降七成——输入过程的中间请求全省了;滚动加载的执行次数——节流后每秒从几十次降到三次——页面滚动的流畅度反而提升——执行少了,渲染让路了。这些数字说明:频率控制不只是防事故,还是性能优化——两份收益,一份代码——这种买卖,在前端世界里不多见,见到了要抓住。
五、总结
防抖与节流的要点:防抖等静止——连续触发重置计时,静默期满执行一次——适用于只要结果的场景(搜索联想、输入校验);前缘防抖立即执行——首次有效加冷却期——按钮类写操作的选择;节流限频率——间隔内较多一次——适用于过程要响应的场景(滚动、拖拽);提交防重的完整方案——防抖包在提交函数门口,加按钮禁用视觉双保险,覆盖键盘手势所有触发源;前端防重是体验,后端幂等是底线——双层都上才叫完整。末了这道闸的哲学:机器的响应速度远超人的耐心——用户的手在狂点,代码要替他保持冷静。
常见问题
问:防抖节流的等待时间设多少合适?
答:按场景定,给一组实测经验值:搜索联想——三百到五百毫秒,再长用户觉得卡;按钮防重——八百到一千毫秒,覆盖双击和狂点;自动保存——一到两秒,输入停了再存;滚动加载——两百到三百毫秒,再稀会漏检。定值的方法:从经验值起步,看两个指标调——用户的操作跟不跟手(太长了卡)、目标函数的执行频率降没降到位(太短了没用)——参数没有教科书,有观测和迭代。
问:用了防抖,用户说点了没反应,怎么办?
答:大概率是标准防抖用错了场景——按钮类操作用户点了看不到立即反馈,就会觉得死了。两步修:换前缘防抖——立即执行加冷却,点击立刻有响应;加反馈状态——按钮点击立即变加载态——哪怕真有延迟,用户看到转圈就知道活着。防抖的初心是控制频率,不是制造迟钝——任何时候,让用户确认系统收到了他的操作,是交互的头等大事。
问:后端有幂等了,前端防重还需要吗?
答:需要,两层各干各的活:后端幂等是安全——保证重复请求不产生脏数据,这是底线,必须有;前端防重是体验——让重复请求根本不发出去,省流量、省服务端压力、更重要的是省用户的等待——幂等后端处理的重复请求,用户还是要等响应,前端拦掉的,用户立刻得到反馈。类比:后端幂等是安检,前端防重是排队引导——光有安检,人流照样挤在门口;引导做好了,安检的压力也小——两层协作,不是二选一。
问:第三方组件的按钮,没法改它的点击逻辑,怎么防重?
答:三个层次的绕法:包装层——组件外面包一层防抖的提交函数——按钮的点击事件指向防抖后的函数,组件无感知;状态层——用一个提交中的全局状态锁——请求发出置锁,完成解锁——任何组件的重复触发进门先看锁——这招对改不了源码的组件通杀;样式层——提交期间给按钮容器盖个透明遮罩——物理上点不到——较土但较彻底,移动端狂点免疫。组合建议:状态锁为主,遮罩兜底——改不了逻辑,还盖不住物理点击吗。