useLockFn 用来保证同一个异步动作在完成前不会再次进入,适合保存、支付前确认、生成文件等操作。它解决的是“执行中互斥”,不是搜索输入防抖,也不是固定频率节流。
实现
import { readonly, ref } from 'vue'
interface UseLockFnOptions {
/**
* 从本次调用开始计算的最短锁定时间。
* 默认 0,表示请求结束后立即解锁。
*/
minLockMs?: number
}
function wait(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
export function useLockFn<
TArgs extends unknown[],
TResult
>(
fn: (...args: TArgs) => Promise<TResult>,
options: UseLockFnOptions = {}
) {
const locked = ref(false)
const minLockMs = Math.max(0, options.minLockMs ?? 0)
async function run(
...args: TArgs
): Promise<TResult | undefined> {
if (locked.value) return undefined
locked.value = true
const startedAt = Date.now()
try {
return await fn(...args)
} finally {
const elapsed = Date.now() - startedAt
const remaining = minLockMs - elapsed
if (remaining > 0) {
await wait(remaining)
}
locked.value = false
}
}
return {
run,
locked: readonly(locked)
}
}
finally 是关键。无论请求成功、抛错还是业务校验拒绝,锁都必须恢复。调用方仍能收到原始异常,不会因为 Hook 捕获错误而误判成功。这里使用全局 setTimeout,避免把组合式函数无意限制在有 window 的浏览器环境。
使用示例
<script setup lang="ts">
import { useLockFn } from '@/hooks/useLockFn'
import { saveProfile } from '@/request/profile'
const { run: submit, locked } = useLockFn(
async () => {
await saveProfile(form.value)
uni.showToast({ title: '保存成功' })
},
{ minLockMs: 300 }
)
async function handleSubmit() {
try {
await submit()
} catch {
uni.showToast({
title: '保存失败,请重试',
icon: 'none'
})
}
}
</script>
<template>
<button :disabled="locked" @click="handleSubmit">
{{ locked ? '保存中…' : '保存' }}
</button>
</template>
按钮禁用是反馈和可用性措施,锁才是逻辑兜底。只禁用按钮仍可能被键盘快捷键、其他入口或重复事件绕过。
锁、防抖和节流的区别
- 异步锁: 第一次调用未完成时,忽略后续调用。
- 防抖: 连续触发停止一段时间后,只执行最后一次,适合搜索输入。
- 节流: 在一个时间窗口内至多执行一次,适合滚动或拖动采样。
- 幂等: 同一业务请求重复到达服务端时,不产生重复副作用。
前端锁不能替代服务端幂等。下单、支付、领取权益等关键操作仍需要幂等键、唯一约束或状态机保护。
要不要返回上一次 Promise
上面的实现让锁定期间的调用返回 undefined,含义是“本次没有执行”。另一种策略是缓存正在执行的 Promise,让所有调用者等待同一个结果:
let pending: Promise<TResult> | undefined
function runShared(...args: TArgs) {
if (!pending) {
pending = fn(...args).finally(() => {
pending = undefined
})
}
return pending
}
两者语义不同。按钮点击通常适合忽略重复调用;多个模块请求同一份初始化数据时,共享 Promise 更自然。写 Hook 前应先决定调用者需要哪一种结果。