每次提交表单都要写
loading = true、disabled = true、.finally(() => loading = false)?你不是在写业务,你是在重复造轮子。
在日常开发中,我们无数次面对这样的场景:
- 用户点击"提交订单"
- 点击"发送验证码"
- 点击"保存设置"
而为了防止重复点击,你不得不:
- 定义一个
loading状态; - 在点击时设为
true; - 禁用按钮;
- 发起请求;
- 成功或失败后,再设回
false。
一段逻辑,复制粘贴十次。
更糟的是------一旦忘记写 .finally,按钮就永远禁用;一旦并发请求没处理好,照样重复提交。
今天,我们就用 一个自定义 Hook,彻底终结这种体力劳动。
手动管理 loading 的三大痛点
1. 代码冗余
js
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (submitting) return;
setSubmitting(true);
try {
await submitForm();
} finally {
setSubmitting(false); // 忘记这行?按钮就废了
}
};
每个按钮都要写一遍,毫无意义。
2. 无法天然防重
即使你写了 if (submitting) return,如果用户快速双击 ,在 setSubmitting(true) 异步更新前,两次点击仍可能触发两次请求。
3. 状态分散,难以维护
多个按钮?多个表单?每个都要独立管理状态,逻辑割裂。
解法:封装一个 useSubmitLock Hook
我们要实现的效果:
jsx
const [handleSubmit, isSubmitting] = useSubmitLock(async (formData) => {
await api.submitOrder(formData);
message.success('下单成功!');
});
return (
<button disabled={isSubmitting} onClick={() => handleSubmit(data)}>
{isSubmitting ? '提交中...' : '立即下单'}
</button>
);
一行调用,自动加锁、自动解锁、自动防重、自动透传参数!
实现原理:Promise 锁 + 状态同步
ts
// React + TypeScript 版本(JS 可轻松转写)
import { useState, useCallback } from 'react';
type AsyncFunction<T extends any[], R> = (...args: T) => Promise<R>;
export const useSubmitLock = <T extends any[], R>(
asyncFn: AsyncFunction<T, R>
) => {
const [isLocked, setIsLocked] = useState(false);
const wrappedFn = useCallback(
async (...args: T): Promise<R | undefined> => {
if (isLocked) {
console.warn('操作正在进行中,请勿重复提交');
return; // 直接拦截,不执行函数
}
setIsLocked(true);
try {
const result = await asyncFn(...args);
return result;
} finally {
setIsLocked(false); // 无论成功失败,一定解锁
}
},
[isLocked, asyncFn]
);
return [wrappedFn, isLocked] as const;
};
关键设计亮点:
| 特性 | 说明 |
|---|---|
| 闭包锁 | isLocked 为 true 时,直接 return,不执行原函数 |
| 自动 finally 解锁 | 即使接口报错、用户中断,也不会卡死 |
| 泛型支持 | 完美透传参数和返回值类型 |
| 无副作用 | 不依赖全局状态,每个调用独立隔离 |
使用场景全覆盖
场景 1:表单提交
js
const [submitForm, submitting] = useSubmitLock(api.createPost);
场景 2:发送验证码
js
const [sendCode, sending] = useSubmitLock(phoneApi.sendSmsCode);
// 按钮文案可结合倒计时:{sending ? '发送中...' : '获取验证码'}
场景 3:删除确认操作
js
const [confirmDelete, deleting] = useSubmitLock(api.deleteUser);
// 防止用户狂点"确定"导致多次删除
场景 4:组合多个异步操作
js
const [handlePay, paying] = useSubmitLock(async (orderId) => {
await api.createPayment(orderId);
await trackEvent('pay_clicked');
window.location.href = '/payment';
});
注意事项 & 进阶建议
1. 不要用于需要"取消"的操作
此 Hook 适用于"提交即不可逆"的场景。如果是上传、下载等可取消任务,应使用 AbortController。
2. 与防重 Token 不冲突
useSubmitLock 是前端体验层防护,后端仍需配合 Token 或幂等设计做最终校验。
3. Vue 用户怎么办?
同样可封装为 Composable:
js
// Vue 3 + Composition API
import { ref } from 'vue';
export function useSubmitLock(asyncFn) {
const isLocked = ref(false);
const wrappedFn = async (...args) => {
if (isLocked.value) return;
isLocked.value = true;
try {
return await asyncFn(...args);
} finally {
isLocked.value = false;
}
};
return { execute: wrappedFn, isLocked };
}
使用:
js
const { execute: submit, isLocked } = useSubmitLock(api.submit);
更进一步:自动绑定到按钮?
你可以再封装一个 <SubmitButton> 组件:
jsx
const SubmitButton = ({ onClick, children, ...props }) => {
const [handler, loading] = useSubmitLock(onClick);
return (
<button
disabled={loading}
onClick={handler}
{...props}
>
{loading ? '处理中...' : children}
</button>
);
};
// 使用
<SubmitButton onClick={submitOrder}>提交订单</SubmitButton>
从此,防重提交,零成本集成。
结语
优秀的工程师,不是写更多代码,而是让重复的事不再发生。
一个小小的 useSubmitLock,背后是对用户体验的尊重,对代码洁癖的坚持,更是对"DRY 原则"的践行。
下次当你又要写第 101 次 loading = true 时,停下来问问自己:
"这事,能不能一次解决?"
把这个 Hook 加到你的工具库里,团队效率提升 10%。
欢迎收藏、转发,拯救还在手写 loading 的同事!
各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!