一个页面里出现三份相似逻辑,不一定要立刻抽成 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

参考资料