一、问题背景
我们有一个统一的支付入口 preOrderPay,内部流程大致是:
postPreOrder -> requestPayment -> getPayResult
多个页面都会调用它。
**方法调用已经加了 1s 的防抖,但是还是存在重复调用的情况** 问题在于:
如果用户在弱网的情况下点击两次支付按钮,触发时间可能由于网络延迟超出防抖限制时间,就会触发两次
preOrderPay。
于是就可能出现:
- 两次
postPreOrder(生成两个订单) - 两次
requestPayment(拉起两次支付) - 状态难以控制
二、解决方案:Promise 单飞
核心思路非常简单:
同一时间只允许存在一个“进行中的支付 Promise”
实现方式
在模块级维护一个变量:
let currentPreOrderPayPromise: Promise<any> | null = null;
然后改造 preOrderPay:
export function preOrderPay() {
// 如果已经有支付在进行,直接复用
if (currentPreOrderPayPromise) {
return currentPreOrderPayPromise;
}
currentPreOrderPayPromise = (async () => {
try {
const order = await postPreOrder();
await requestPayment(order);
return await getPayResult(order);
} finally {
// 无论成功失败,都释放
currentPreOrderPayPromise = null;
}
})();
return currentPreOrderPayPromise;
}
三、执行流程拆解
第一次点击
currentPreOrderPayPromise === null- 创建一个完整支付 Promise
- 赋值给
currentPreOrderPayPromise - 开始执行支付流程
第二次点击(关键点)
- 发现
currentPreOrderPayPromise已存在 - 不再重新执行支付流程
- 直接 return 这个 Promise
👉 结果就是:
两次调用共享同一个支付结果
支付结束
在 finally 中:
currentPreOrderPayPromise = null;
👉 下一次支付才允许重新开始
四、这个方案本质在做什么?
不是“按钮防抖”,而是:
在“支付公共入口”做串行化控制
核心能力是:
- 同一时间只允许一条支付链路执行
- 后续请求不会新开流程,而是复用
- 流程结束后再放行
五、和“传统加锁”的区别
很多人第一反应是用一个布尔锁:
if (isPaying) {
return toast('请勿重复点击');
}
isPaying = true;
try {
...
} finally {
isPaying = false;
}
两种方案的本质区别:
方案 | 后续请求处理方式 -- | -- 普通锁(isPaying) | ❌ 直接拒绝 Promise 单飞 | ✅ 合并请求
六、为什么支付场景更适合“单飞”?
1. 重复点击 ≠ 新需求
用户点两次支付,本质上还是同一次支付行为。
👉 所以更合理的方式是“合并”,而不是“拒绝”。
2. 对调用方更友好
普通锁会带来:
- 需要处理“已锁定”的异常
- UI 逻辑复杂
而单飞:
- 所有调用方拿到的都是同一个 Promise
- 不需要额外分支处理
3. 改动更小,覆盖更全
只需要改造 preOrderPay:
- 所有页面自动生效
- 不需要逐个按钮处理
七、局限性
这个方案也不是完美的。
❗ 内存级锁
let currentPreOrderPayPromise = null;
意味着:
- 只在当前小程序实例有效
- 页面重进 / 进程重建后失效
👉 不能作为跨会话的幂等保障
八、最佳实践(推荐组合)
实际项目中,不要二选一,而是组合使用:
✅ 公共层:Promise 单飞
- 真正防住重复支付
- 保证业务一致性
✅ 页面层:按钮禁用 / loading
- 给用户明确反馈
- 避免“疯狂点击”
九、总结
一句话总结这个方案:
用 Promise 把“重复请求”变成“共享结果”
相比传统加锁:
- 更优雅
- 更贴近业务语义
- 对调用方更友好