响应式接口保留业务语义Vue3 的组合式 API 便于复用逻辑但团队若没有统一的输入、输出和错误处理约定调用方很难判断状态归属和失败后的行为。常见反模式包括有的 Composable 返回 plainref有的返回reactive包裹的对象有的直接在内部catch掉异步报错抛出console.error导致调用方根本无法感知网络失败更有甚者把全局状态混淆在单例闭包里在 SSR 场景下直接引发跨请求状态污染。可通过类型化的 Composable 契约和清晰的错误传递方式约束这些边界。1. 现场排障自由度过高引发的跨组件状态污染与未捕获异常前阵子帮一个兄弟团队排查线上 Bug他们的项目严重依赖自定义useAsyncFetchComposable。测试环境偶然暴露了一个极其怪异的现象用户在页面 A 触发了查询超时跳转到页面 B 后页面 B 居然弹出了页面 A 的网络错误 Toast。翻开useAsyncFetch.ts源码隐患清晰可见// 线上存在严重隐患的 Composable 写法 import { ref } from vue; // 致命缺陷 1全局单例状态留在文件顶层SSR 或多实例共享时造成状态污染 const globalError refstring | null(null); export function useAsyncFetch(url: string) { const data ref(null); const execute async () { try { const res await fetch(url); data.value await res.json(); } catch (e: any) { // 致命缺陷 2直接强行改写全局单例 error缺少作用域隔离与错误抛出语义 globalError.value e.message; } }; // 致命缺陷 3返回解构对象时混淆了 ref 与普通函数缺少 Readonly 强契约保护 return { data, globalError, execute }; }作者为了“省事”把globalError放在了函数外层作为全局单例导致所有调用useAsyncFetch的组件都共享了同一个错误引用。一旦某个请求失败整套系统的错误状态全乱套了。此外直接暴露可写的ref允许外部随手修改data.value ...彻底破坏了状态的单向数据流契约。2. 契约架构Typed Composable 与三层错误隔离团队可以将以下三项作为 Composable 的约定状态只读保护Readonly Protection暴露给外部的状态必须用readonly()包裹禁止外部直接突变内部ref。明确的错误语义划分Error Scope Semantic区分“局部组件可恢复错误”与“全局致命崩溃错误”。安全上下文绑定Scope Isolation单例状态必须显式使用provide / inject或显式工厂严禁写文件顶层裸 Ref。这套架构能确保状态的修改权始终留在 Composable 内部错误的处理权有清晰的层级分工。3. 代码实现标准 Typed Composable 范例下面是按照工程规范重构后的强类型、防污染、自带错误语义的useTypedAsync组合式函数。import { ref, readonly, DeepReadonly, Ref, inject, provide, App } from vue; // 1. 规范输入与输出强类型契约 export interface UseTypedAsyncOptionsT { initialData?: T; onError?: (err: Error) void; throwOnError?: boolean; } export interface UseTypedAsyncReturnT, P extends any[] { data: DeepReadonlyRefT | null; loading: DeepReadonlyRefboolean; error: DeepReadonlyRefError | null; execute: (...args: P) PromiseT | null; reset: () void; } // 2. 实现符合工程规范的组合式函数 export function useTypedAsyncT, P extends any[] []( asyncFn: (...args: P) PromiseT, options: UseTypedAsyncOptionsT {} ): UseTypedAsyncReturnT, P { const { initialData null, onError, throwOnError false } options; // 内部私有可变状态 const data refT | null(initialData) as RefT | null; const loading refboolean(false); const error refError | null(null); const reset () { data.value initialData; loading.value false; error.value null; }; const execute async (...args: P): PromiseT | null { loading.value true; error.value null; try { const result await asyncFn(...args); data.value result; return result; } catch (e: any) { const errObj e instanceof Error ? e : new Error(String(e)); error.value errObj; // 触发回调句柄 if (onError) { onError(errObj); } // 如果配置了向上抛出交由全局 app.config.errorHandler 捕获 if (throwOnError) { throw errObj; } return null; } finally { loading.value false; } }; // 核心契约对外暴露 Status 时必须做 readonly 封印 return { data: readonly(data), loading: readonly(loading), error: readonly(error), execute, reset, }; }组件中可按以下方式使用template div classuser-profile-card p-4 border rounded button :disabledloading clickfetchUser(user_1024) classpx-4 py-2 bg-indigo-600 text-white rounded {{ loading ? 加载中... : 拉取用户信息 }} /button div v-iferror classmt-2 text-red-600 text-sm 网络请求失败: {{ error.message }} /div div v-else-ifdata classmt-2 text-gray-800 p姓名: {{ data.name }}/p p角色: {{ data.role }}/p /div /div /template script setup langts import { useTypedAsync } from ./useTypedAsync; interface UserDto { id: string; name: string; role: string; } // 模拟 API 请求 const apiFetchUser async (id: string): PromiseUserDto { const res await fetch(/api/users/${id}); if (!res.ok) throw new Error(HTTP 错误状态码: ${res.status}); return res.json(); }; // 引入 Composable const { data, loading, error, execute: fetchUser } useTypedAsync(apiFetchUser, { onError: (err) console.warn([UserProfile] 局部识别到网络异常:, err.message), }); /script4. 最佳实践与团队约定可将以下内容放入代码审查清单按需限制 Ref 的写入权对外只读的状态用readonly()包裹若调用方确实需要写入应通过明确的 Action 或文档说明其所有权。解构语法适配Vue3 的reactive解构会丢失响应性。Composable 返回值统一推荐使用包含Ref的普通 Plain Object 结构方便调用方灵活解构const { data, loading } useComposable()。显式销毁与 Scope 绑定如果在 Composable 内使用了addEventListener或watch必须确保使用onUnmounted或onScopeDispose进行清理防止组件 Unmount 后闭包悬挂。这些约定会增加少量样板代码但能让状态所有权和错误处理更容易追踪。