如何使用watchEffect()防止被中止的异步请求破坏组件状态
组件通过prop接收参数resourceId,需要从API拉取对应资源并渲染,同时处理加载态、错误态,实现模式参考Vue Router官方导航后数据拉取方案。
API拉取函数会同步返回abort中止函数,调用后当前请求会立即抛出拒绝结果。需求要求:当resourceId发生变化时,所有进行中的请求必须中止,转而发起新的请求,本次实现基于fetch()与AbortController完成请求中止能力。
基于Vue3组合式API的初始实现代码如下:
const loading = ref(false); const data = ref(null); const error = ref(null); watchEffect(async (onCancel) => { loading.value = true; data.value = error.value = null; const { response, abort } = fetchResourceFromApi(props.resourceId); onCancel(abort); try { data.value = await (await response).json(); } catch (e) { error.value = e; } finally { loading.value = false; } });
对应模板代码:
<div v-if="loading">Loading...</div> <div v-else-if="error">Error: {{ error }}</div> <div v-else>{{ data }}</div>
上述代码在单请求场景下运行正常,但在快速切换resourceId触发请求取消时会出现状态混乱:当上一个API请求未完成时resourceId就发生变化,事件会按以下顺序执行:
- 旧请求的
abort()被调用 watchEffect回调重新执行,重置loading、error、data的状态值- 旧请求的
catch和finally代码块执行,修改loading和error的值 - 新发起的API请求完成,设置
loading和data的值
最终会出现不符合预期的异常状态:第二个请求仍在加载过程中时loading就被设为false,error中存储了第一个请求被中止抛出的异常,虽然最终data能拿到第二个请求的返回值,但中间状态完全混乱。
问题核心原因是被主动中止的旧请求,其异步回调仍然持有响应式状态的修改权限,会在请求作废后覆盖新请求设置的初始状态。解决思路很明确:给每个请求增加作废标记,只有未被作废的最新请求,才允许修改全局响应式状态。
方案1:请求ID校验(适合多请求竞态场景)
通过自增ID标记每次请求的优先级,只有ID匹配最新请求的回调才允许修改状态:
const loading = ref(false); const data = ref(null); const error = ref(null); // 记录最新请求的标识 let latestRequestId = 0; watchEffect(async (onCancel) => { loading.value = true; data.value = error.value = null; // 为当前请求生成唯一ID,更新为最新请求标识 const currentRequestId = ++latestRequestId; const { response, abort } = fetchResourceFromApi(props.resourceId); onCancel(abort); try { const res = await (await response).json(); // 仅当前请求是最新请求时,才更新数据 if (currentRequestId === latestRequestId) { data.value = res; } } catch (e) { // 忽略主动中止产生的错误,且仅最新请求可更新错误状态 if (currentRequestId === latestRequestId && e.name !== 'AbortError') { error.value = e; } } finally { // 仅最新请求可更新加载状态 if (currentRequestId === latestRequestId) { loading.value = false; } } });
方案2:内部取消标记(逻辑更内聚)
不需要维护全局ID,在每个watchEffect回调内部维护取消标记即可,更贴合onCancel的设计逻辑:
const loading = ref(false); const data = ref(null); const error = ref(null); watchEffect(async (onCancel) => { loading.value = true; data.value = error.value = null; // 标记当前请求是否已被作废 let isCancelled = false; const { response, abort } = fetchResourceFromApi(props.resourceId); onCancel(() => { isCancelled = true; abort(); }); try { const res = await (await response).json(); if (!isCancelled) { data.value = res; } } catch (e) { if (!isCancelled && e.name !== 'AbortError') { error.value = e; } } finally { if (!isCancelled) { loading.value = false; } } });
两种方案都可以彻底解决旧请求回调覆盖状态的问题,其中方案2实现更简洁,不需要额外维护全局变量,是单资源拉取场景下的首选实现。
内容的提问来源于stack exchange,提问作者Ryan

