在 Vue 3 工程化实践中,异步任务的取消往往比发起请求更考验代码质量。组件卸载后仍执行回调、快速切换筛选条件导致旧请求覆盖新数据、用户离开页面后定时器依旧运行,这些问题都指向同一个核心:异步中止没有和组件生命周期绑定。TSX 语法让 Vue 组件更接近函数式表达,也为管理 AbortController 提供了更紧凑的书写方式。本文会从底层信号机制讲起,逐步给出可落地的工程化封装。

一、AbortController 的信号传递与取消原理
AbortController 是浏览器原生提供的取消机制,它包含一个 signal 属性和一个 abort() 方法。当调用 abort() 后,所有监听该 signal 的异步操作都会收到取消通知。在 fetch 中,可以把 signal 作为第二个参数的选项传入,一旦取消,fetch 的 Promise 会以 AbortError 拒绝。这个错误不能当作普通业务错误处理,否则会在控制台产生不必要的未捕获异常。
理解信号传递的细节很重要:AbortController 与 AbortSignal 是一对一关系,但一个 signal 可以被多个异步操作共享。在组件场景中,我们通常在每个任务里创建一个新的 controller,或者让多个任务复用同一个 controller 以便统一取消。前者适合独立请求,后者适合页面级离开时批量中断。下面的代码展示了最基础的 fetch 取消写法,注意 catch 中需要区分 AbortError 与真实网络错误。
const controller = new AbortController();
fetch('/api/users', { signal: controller.signal })
.then(res => res.json())
.then(data => {
// 处理数据
})
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求已被取消');
return;
}
console.error('请求失败', err);
});
// 需要取消时
controller.abort();
axios 从 0.22 版本开始也支持 signal 选项,用法与 fetch 类似,但 axios 抛出的取消错误需要通过 axios.isCancel(err) 或判断 err.code === 'ERR_CANCELED' 来识别。不同 HTTP 库对取消错误的包装不同,工程化封装时最好做一层适配,避免业务代码里写死判断。
二、在 TSX 组件中绑定异步生命周期
Vue 3 的 <script setup lang="tsx"> 允许直接返回渲染函数,这让 TSX 与组合式 API 可以无缝共存。在组件中,我们通常在 onMounted 中发起请求,在 onBeforeUnmount 中调用 abort。但更推荐的做法是利用 setup 作用域内的变量保存 controller,并通过 onScopeDispose 或 onBeforeUnmount 统一清理。下面这段代码展示了一个简单的用户列表组件,它会在卸载时自动取消未完成的请求。
import { defineComponent, ref, onBeforeUnmount } from 'vue';
export default defineComponent({
setup() {
const users = ref<User[]>([]);
const loading = ref(false);
let controller: AbortController | null = null;
const loadUsers = async () => {
controller = new AbortController();
loading.value = true;
try {
const res = await fetch('/api/users', { signal: controller.signal });
users.value = await res.json();
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error(err);
}
} finally {
loading.value = false;
}
};
onBeforeUnmount(() => {
controller?.abort();
});
loadUsers();
return () => (
<div>
<p v-show={loading.value}>加载中...</p>
<ul>
{users.value.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
});
上面的代码解决了卸载后继续更新状态的问题,但存在一个竞态缺陷:如果用户快速触发多次 loadUsers,前一个请求尚未完成就被新的请求取代,旧的 controller 会被覆盖,导致旧请求无法被取消。更完善的方案是在每次发起新请求前主动 abort 上一次的 controller。这样即使没有组件卸载,也能保证只有最新的请求会真正更新状态。
除了手动管理 controller,Vue 的 watch 与 watchEffect 也可以配合信号来取消副作用。比如监听筛选条件变化时,在回调内部创建 controller,并在回调返回的清理函数中取消。这种模式与 React 的 useEffect 清理函数非常相似,适合依赖响应式数据的异步任务。
三、封装 useAsyncTask:统一竞态与中止逻辑
把取消逻辑散落在每个组件里容易遗漏,也增加了维护成本。我们可以封装一个组合式函数 useAsyncTask,它接收一个异步函数,返回 run、abort、loading、error 等状态。内部自动维护 controller,每次调用 run 时先取消上一次任务,确保竞态安全。同时提供手动 abort 方法,让组件卸载时可以统一中止。
这个组合式函数的关键在于:异步函数需要接收 signal 参数,并把 signal 传给底层的 fetch 或 axios。这样调用方无需关心 controller 的创建与销毁,只需要在业务函数里透传 signal 即可。下面的代码给出了完整实现,其中对错误做了归一化处理,AbortError 不进入 error 状态,避免误报。
import { ref, shallowRef } from 'vue';
type AsyncTaskFn<T, Args extends any[] = any[]> = (
signal: AbortSignal,
...args: Args
) => Promise<T>;
export function useAsyncTask<T, Args extends any[] = any[]>(
taskFn: AsyncTaskFn<T, Args>
) {
const loading = ref(false);
const error = shallowRef<Error | null>(null);
const data = shallowRef<T | null>(null);
let controller: AbortController | null = null;
const abort = () => {
controller?.abort();
controller = null;
};
const run = async (...args: Args) => {
abort(); // 取消上一次未完成的请求
controller = new AbortController();
loading.value = true;
error.value = null;
try {
const result = await taskFn(controller.signal, ...args);
data.value = result;
return result;
} catch (err) {
if ((err as Error).name === 'AbortError') {
return;
}
error.value = err as Error;
throw err;
} finally {
if (controller?.signal.aborted === false) {
loading.value = false;
}
}
};
return {
loading,
error,
data,
run,
abort
};
}
使用时,将 API 请求函数定义为接收 signal 参数的形式,然后在组件中解构出 run 与 abort。组件卸载时调用 abort 即可。这种封装可以进一步扩展,例如增加 onScopeDispose(abort) 自动绑定当前组件作用域,或者支持并发模式,允许多个任务独立取消。
工程化项目中,还可以把 useAsyncTask 与 Vue Router 的路由守卫结合,在路由离开时触发统一的取消。比如在全局路由 beforeEach 中维护一个任务注册表,或者在 Pinia store 中存放所有活跃请求的 signal,集中管理。不过需要注意,过度集中化可能让取消逻辑变得隐晦,建议先保证组件内部的自洽,再考虑全局拦截。
四、避免常见误区与错误处理
第一类误区是忽略 AbortError 的识别。很多开发者在 catch 中统一把错误上报到监控系统,导致用户每次正常取消操作都产生一条错误记录。正确的做法是在捕获时先判断 err.name === 'AbortError' 或使用 axios 的 isCancel,这类取消错误应当静默处理。第二类误区是在 finally 中无条件修改 loading 状态。如果请求被取消后立即发起新请求,旧的 finally 可能把 loading 置为 false,造成状态闪烁。因此需要判断当前 controller 是否已经被替换或取消。
第三个常见问题是把 signal 传入不支持取消的 API。例如某些第三方库没有提供 signal 参数,或者内部封装了 XMLHttpRequest,无法直接取消。这种情况下可以通过包装 Promise 并监听 signal 的 abort 事件,手动 reject 一个 AbortError。这样虽然底层请求仍在继续,但至少业务层状态不会更新。不过要意识到这只是一种降级方案,网络连接并未真正中断。
最后,TSX 虽然灵活,但要注意在 setup 返回的渲染函数中不要执行副作用。所有异步操作都应放在 setup 主体或生命周期钩子里,渲染函数只负责根据响应式状态生成 VNode。如果渲染函数每次都创建新的 controller 或触发请求,会导致性能浪费和不可预测的行为。