先记住这个答案
为 watch 每一轮请求建立独立有效标记,并在清理回调中使旧轮失效;若请求接口支持 AbortSignal,再同时取消旧请求以节省工作。所有异步状态提交都要检查本轮是否仍然有效,包括成功数据、错误提示和 loading。不能只保护成功分支,因为旧请求的 catch 或 finally 也可能覆盖当前请求的界面。
- 清理让上一轮失去状态提交资格
- 取消请求与拒绝旧结果各有职责
- 成功、失败、结束状态都需要守卫
竞态来自完成顺序不受控制
用户先选择甲再选择乙,乙的数据先返回并显示,随后甲的慢响应到达。如果两个回调都直接赋值,界面会退回甲的数据。源已经变成乙也不会自动阻止甲回调里的普通赋值。
为了复现,应使用可控制完成顺序的请求替身,先完成新请求再完成旧请求。单靠真实网络偶发延迟难以稳定验证,也容易漏掉旧请求失败后覆盖当前错误提示的另一条路径。
把失效和取消放进同一轮清理
下例返回一个可在组件同步初始化期调用的组合式函数,加载器由调用方传入。清理在开始等待之前就注册,使请求尚未完成时源切换或监听停止也能够使该轮失效。
默认侦听器会批量调度;若业务要求源赋值后立刻取消,就要另行设计同步失效策略。这里保护的是新一轮侦听器运行后旧任务不得再提交,并未声称改变源变量的瞬间所有 Promise 都被中断。
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
function useLatestResult(id: Ref<string>, load: (id: string, signal: AbortSignal) => Promise<string>) {
const data = ref<string | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)
const stop = watch(id, async (key, _old, onCleanup) => {
let active = true
const controller = new AbortController()
onCleanup(() => { active = false; controller.abort() })
data.value = null
error.value = null
loading.value = true
try {
const result = await load(key, controller.signal)
if (active) data.value = result
} catch (cause) {
if (active) error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (active) loading.value = false
}
}, { immediate: true })
return { data, error, loading, stop }
}加载器即便忽略取消信号并最终返回,active 守卫也会挡住旧结果。停止监听后本例不主动重置可见 loading,因为停止后的界面保留还是清空属于调用方的结束流程。
取消不能取代提交校验
有些缓存读取、第三方 SDK 或后处理步骤不支持取消,甚至请求已成功但解析仍未完成。有效标记保护的是最终写入位置,因此应保留到所有异步转换结束,而不是仅检查 fetch 是否取消成功。
对提交订单之类写请求,应使用显式用户动作及服务端幂等设计。把写请求塞进自动重跑的侦听器,再寄希望于取消旧请求来防止重复提交,会混淆界面更新和业务副作用的执行保证。
容易答错的地方
- 只给成功赋值加判断
- 旧任务的 finally 仍会把新任务的 loading 设为假,旧异常也可能盖住新结果。检查回调所有状态写入点,让错误与完成分支使用同一轮资格判断,而不是各自维护不一致的标记。
- 认为发起取消就必然没有旧回调
- 取消是否生效取决于具体接口和任务所处阶段。即便 Promise 以异常结束,catch 与 finally 仍会执行;应把取消当成资源优化,把有效标记当成状态一致性的最后一道条件。
面试官还会怎么问?
为什么每一轮都需要自己的 active 变量?
共享一个布尔值会被新一轮重新设为真,旧回调随后可能误以为自己仍然有效。闭包里的局部标记只属于本轮,旧轮清理后永远不会被后来的请求重新启用。
序号比较能替代布尔有效标记吗?
可以用递增请求编号,在提交时比较当前编号。不过停止监听和组件退出也必须推进失效状态,否则最后一个尚未完成的请求仍可能通过编号检查;清理回调更方便统一这些结束路径。
如何验证旧 finally 不影响当前 loading?
让旧请求保持未完成,启动新请求后先让旧请求结束,此时新请求仍在等待,loading 应继续为真。随后完成新请求才应变为假,这比只验证最终 data 更能覆盖实际界面错误。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。