跳到主要内容
返回墨迹
/陈墨

Vue 组合式函数设计的三条心法

写了一年组合式函数后沉淀的方法论:单一职责的「作用域」、显式的依赖声明,以及为什么返回值应该是普通对象而不是响应式对象。

从一个坏例子开始

// ❌ 一个什么都想管的 composable
export function useUser() {
  const user = ref(null);
  const orders = ref([]);
  const theme = ref('dark');

  async function fetchUser(id: string) { /* ... */ }
  async function fetchOrders(id: string) { /* ... */ }
  function toggleTheme() { /* ... */ }

  return { user, orders, theme, fetchUser, fetchOrders, toggleTheme };
}

这不是组合式函数,这是把组件换个地方写。三个月后没人说得清 theme 为什么住在这里,而 fetchOrders 被三个页面共享,其中两个根本不需要它——但它们都得为这份多余的响应式开销买单。

心法一:按「作用域」切,而不是按「领域」切

组合式函数的粒度单位是一段可复用的响应式作用域,而不是业务实体。领域划分是服务层的职责。所以 useUser 应该裂解成:

useFetchUser(id)   // 一次获取 + loading + error
useOrders(userId)  // 订单列表的查询与分页
useTheme()         // 主题读写与持久化

判断标准很简单:如果两个状态的生命周期或依赖关系不同步,它们就不该住在一起。

心法二:依赖必须从参数进来

// ✅ 依赖显式声明
export function useOrders(userId: Ref<string>) {
  const orders = ref([]);
  watchEffect(async () => {
    orders.value = await api.getOrders(userId.value);
  });
  return { orders };
}

凡是 composable 内部自己 import 服务、自己读全局单例的,测试时都会让你痛苦。依赖从参数进来,配合 vi.fn() 就能覆盖所有分支;从全局来,你就得连环境一起 mock。

一个例外:真正的应用级单例(如主题)可以用模块级状态,但要显式命名 useThemeStore,让读代码的人知道它不是纯函数。

心法三:返回普通对象,包住响应式引用

// ✅ 调用方拿到的是稳定的 API
export function useCounter() {
  const count = ref(0);
  function inc() { count.value++; }
  return { count, inc };
}

const { count, inc } = useCounter();
// count 是 Ref —— 保持引用,而不是快照

反过来,如果你返回 reactive({ count, inc }),解构会立刻失去响应性,调用方被迫整块持有。Ref 是 Vue 里响应式的「最小包装」,让它保持裸露,生态(watch、computed、模板)才接得上。

结语

三条心法其实是同一句话:让作用域、依赖、返回值都显式。 组合式 API 的自由是「显式的自由」——没有了选项 API 的结构约束,你就得自己把结构立起来。立得住,两百行的逻辑树也能清爽如新;立不住,那就是另一坨 setup 而已。