Vue 3 useLockFn:为异步操作增加互斥锁

Vue 3 useLockFn:为异步操作增加互斥锁

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 前应先决定调用者需要哪一种结果。

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。