一个页面里出现三份相似逻辑,不一定要立刻抽成 composable。先确定它们是否拥有相同的数据来源、生命周期和失败处理。否则复用出来的只是一个参数越来越多、调用者越来越难理解的函数。本文针对 Vue 3 Composition API。
每次调用默认拥有自己的状态
在 useSearch 内创建 ref,每个调用者就有独立的结果和加载状态;把 ref 放到模块顶层,所有调用者会共享同一份数据。前者适合多个独立选择器,后者需要明确的全局业务含义,SSR 场景还要避免跨请求共享用户数据。
返回包含 ref 的普通对象,调用者解构后仍保留响应式连接。对 reactive 对象直接解构基本类型属性则可能失去连接;需要时使用 toRefs,或者继续通过原对象访问。
computed 负责推导,watch 负责副作用
筛选结果、按钮是否可用、汇总数量适合 computed。根据关键词请求服务端,或者将偏好写入浏览器存储,才需要副作用。不要用 watch 把一个可计算值不断复制到另一个 ref。
下面的组合式函数使用 watch 的 onCleanup 参数取消失效请求,并保护结果写入。示例假定已存在返回 items 数组的搜索接口,生产实现还应验证响应结构。
import { ref, watch, type Ref } from "vue";
type Item = { id: string; name: string };
export function useSearch(query: Ref<string>) {
const items = ref<Item[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
watch(query, async (value, _oldValue, onCleanup) => {
const controller = new AbortController();
let active = true;
onCleanup(() => {
active = false;
controller.abort();
});
loading.value = true;
error.value = null;
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(value)}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (active) items.value = data.items;
} catch (cause) {
if (active) error.value = String(cause);
} finally {
if (active) loading.value = false;
}
}, { immediate: true });
return { items, loading, error };
}
在 setup 中同步调用这个函数,watch 会绑定组件作用域并在卸载时停止。在异步回调中才创建的侦听器不能照搬这个生命周期假设,需要自行保存和调用停止函数。SSR 项目还应选择服务端可用的数据加载入口,上例定位是浏览器交互。
组件负责展示,组合式函数负责状态过程
函数暴露 loading 和 error,页面决定显示骨架、保留旧列表还是弹出提示。不要让通用请求函数直接操作某个弹窗 DOM,也不要让它内置只属于一个页面的成功文案。
父子组件通过 props 和事件协作。表单子组件发出修改意图,父层持有正式值;需要取消编辑时,单独保存草稿,不要直接改动传入对象的嵌套字段后才发现无法撤销。
排查顺序
先确认两个组件是否误用了共享 ref,再看模板消费的是 ref 还是被解构后的普通值,随后检查请求竞态,最后检查卸载清理。引入 Pinia 不能自动修复这些问题;当状态确实跨页面共享、需要集中动作和调试时,再建立 store。
继续阅读:一次点击,到底改变了什么?、React 状态与 Effect。