组合式函数的设计模式:如何写出真正可复用的 Vue3 Composables

你写了 20 个 composable,其中 15 个只有一个组件在用。

剩下 5 个被 3 个团队成员各 fork 了一份改了改,现在你有 20 个 composable。

这不是段子,这是我在一个中型项目里的真实经历。Vue3 的组合式函数给了我们前所未有的逻辑复用能力,但"能复用"和"复用得好"之间,隔着一整套设计思维。


一、先搞清楚一个问题:Composable 不是 utils

很多人写 composable 的第一反应是------把原来 mixins 里的东西搬过来。

ts 复制代码
// ❌ 这不是 composable,这是一个普通函数
const useFormat = () => {
  const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD')
  const formatMoney = (val: number) => `¥${val.toFixed(2)}`
  return { formatDate, formatMoney }
}

这个函数里没有任何响应式状态,没有生命周期,没有副作用。它就是一个工具函数集合,套了个 use 前缀。

Composable 的本质是:封装带有响应式状态的逻辑单元。

它和普通函数的区别在于:

  • 内部持有 ref / reactive 状态
  • 可能注册生命周期钩子(onMountedonUnmounted
  • 可能使用 watch / computed 建立响应式依赖
  • 返回值是响应式的,能驱动模板更新

如果你的函数不涉及以上任何一项,请放到 utils/ 目录下,别浪费 use 这个前缀。


二、一个好的 Composable 长什么样

先看一个真实场景:几乎每个后台系统都有的列表页------请求数据、分页、加载状态、错误处理。

第一版:能用但不好用

ts 复制代码
const useList = () => {
  const data = ref([])
  const loading = ref(false)
  const total = ref(0)
  const page = ref(1)
  const pageSize = ref(20)

  const fetchData = async () => {
    loading.value = true
    const res = await api.getList({ page: page.value, pageSize: pageSize.value })
    data.value = res.data
    total.value = res.total
    loading.value = false
  }

  onMounted(() => fetchData())

  return { data, loading, total, page, pageSize, fetchData }
}

看起来没问题?用一下就知道了:

  • API 地址写死了,换个列表页得再抄一份
  • 没有错误处理,接口挂了 loading 永远是 true
  • 分页参数变了不会自动请求
  • 无法控制是否立即执行

这是第一个设计原则:composable 必须接受参数,而不是硬编码行为。

第二版:参数化设计

ts 复制代码
interface UseListOptions<T> {
  api: (params: { page: number; pageSize: number }) => Promise<{ data: T[]; total: number }>
  immediate?: boolean
  defaultPageSize?: number
}

const useList = <T>(options: UseListOptions<T>) => {
  const { api, immediate = true, defaultPageSize = 20 } = options

  const data = ref<T[]>([]) as Ref<T[]>
  const loading = ref(false)
  const error = ref<Error | null>(null)
  const total = ref(0)
  const page = ref(1)
  const pageSize = ref(defaultPageSize)

  const fetchData = async () => {
    loading.value = true
    error.value = null
    try {
      const res = await api({ page: page.value, pageSize: pageSize.value })
      data.value = res.data
      total.value = res.total
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  watch([page, pageSize], () => fetchData())

  if (immediate) {
    onMounted(() => fetchData())
  }

  return { data, loading, error, total, page, pageSize, fetchData }
}

使用方式变成了:

ts 复制代码
const { data, loading, page, pageSize } = useList({
  api: (params) => getUserList(params),
  defaultPageSize: 10,
})

换个页面,换个 API 就行了。这才是可复用。


三、五个核心设计模式

经过大量实践,我总结出 composable 设计的五个关键模式。

模式一:参数支持 Ref 和原始值

这是很多人会忽略的点。看这个例子:

ts 复制代码
// 使用方不知道该传 ref 还是原始值
const useSearch = (keyword: string) => {
  // keyword 变了怎么办?拿不到新值
}

Vue 官方用了一个叫 MaybeRef 的模式:

ts 复制代码
import { ref, unref, watch, type Ref, type MaybeRef } from 'vue'

const useSearch = (keyword: MaybeRef<string>) => {
  const result = ref([])

  const doSearch = async () => {
    const val = unref(keyword) // 不管传的是 ref 还是原始值都能拿到值
    result.value = await searchApi(val)
  }

  // 如果传入的是 ref,自动监听变化
  watch(() => unref(keyword), () => doSearch())

  return { result, doSearch }
}

调用方就自由了:

ts 复制代码
// 传原始值------一次性搜索
const { result } = useSearch('vue3')

// 传 ref------响应式搜索
const keyword = ref('')
const { result } = useSearch(keyword)

这是 composable 灵活性的基础。 你的参数越宽容,使用场景就越多。

模式二:返回值用对象,不用数组

ts 复制代码
// ❌ React 风格,Vue 里不推荐
const [data, loading] = useList(api)

// ✅ 返回对象,按需解构
const { data, loading, error, fetchData } = useList({ api })

原因很简单:Vue 的 composable 通常返回 5 个以上的值。数组解构第五个位置是什么,三天后你自己都不记得了。

而且对象解构可以按需取用,不用的字段不解构就行,不会影响性能。

模式三:副作用自动清理

这条规则说起来简单,但忘了就是内存泄漏。

ts 复制代码
const useEventListener = (
  target: MaybeRef<EventTarget>,
  event: string,
  handler: EventListener
) => {
  onMounted(() => {
    unref(target).addEventListener(event, handler)
  })

  // 关键:组件卸载时自动移除
  onUnmounted(() => {
    unref(target).removeEventListener(event, handler)
  })
}

任何在 composable 里注册的事件、定时器、订阅,都必须在 onUnmounted 里清理。

更优雅的做法是用 onScopeDispose,它在 effectScope 销毁时触发,比 onUnmounted 更通用:

ts 复制代码
import { onScopeDispose } from 'vue'

const useInterval = (callback: () => void, interval: number) => {
  const id = setInterval(callback, interval)
  onScopeDispose(() => clearInterval(id))
}

模式四:支持配置合并与覆盖

当你的 composable 被多个团队使用时,不同业务线有不同的默认值需求。

ts 复制代码
interface UseDialogOptions {
  width?: string
  closable?: boolean
  maskClosable?: boolean
}

const DEFAULT_OPTIONS: UseDialogOptions = {
  width: '600px',
  closable: true,
  maskClosable: false,
}

const useDialog = (userOptions?: UseDialogOptions) => {
  const options = { ...DEFAULT_OPTIONS, ...userOptions }
  const visible = ref(false)

  const open = () => { visible.value = true }
  const close = () => { visible.value = false }

  return { visible, open, close, options }
}

为什么不直接把默认值写在参数解构里?因为当选项超过 5 个时,一个独立的默认对象更容易维护,也方便做全局配置覆盖。

模式五:组合而非继承

Composable 最强大的地方在于------它们可以互相组合

ts 复制代码
const usePagedList = <T>(options: UseListOptions<T>) => {
  // 复用 useList 的核心逻辑
  const listState = useList(options)

  // 在此之上增加搜索能力
  const keyword = ref('')
  const debouncedKeyword = useDebouncedRef(keyword, 300)

  watch(debouncedKeyword, () => {
    listState.page.value = 1
    listState.fetchData()
  })

  return {
    ...listState,
    keyword,
  }
}

usePagedList 没有重写 useList 的逻辑,而是组合了它。这就像搭积木------每个 composable 是一块积木,复杂功能通过组合实现。

写到这里我意识到,composable 的设计哲学和 Unix 管道惊人地相似:每个单元只做一件事,通过组合完成复杂任务


四、一个实战案例:表单验证 composable

来看一个生产环境中的真实设计过程。

需求

  • 支持多个字段的验证规则
  • 支持异步验证(比如检查用户名是否已存在)
  • 支持表单级和字段级验证
  • 返回每个字段的错误信息

第一层:单字段验证

ts 复制代码
type ValidateRule = (value: any) => true | string
type AsyncValidateRule = (value: any) => Promise<true | string>

const useFieldValidation = (
  value: Ref<any>,
  rules: (ValidateRule | AsyncValidateRule)[]
) => {
  const error = ref('')
  const validating = ref(false)

  const validate = async (): Promise<boolean> => {
    validating.value = true
    error.value = ''

    for (const rule of rules) {
      const result = await rule(value.value)
      if (result !== true) {
        error.value = result
        validating.value = false
        return false
      }
    }

    validating.value = false
    return true
  }

  return { error, validating, validate }
}

第二层:组合成表单验证

ts 复制代码
const useFormValidation = <T extends Record<string, any>>(
  form: T,
  rules: Partial<Record<keyof T, (ValidateRule | AsyncValidateRule)[]>>
) => {
  const fields: Record<string, ReturnType<typeof useFieldValidation>> = {}

  for (const key of Object.keys(rules) as (keyof T)[]) {
    const fieldRef = isRef(form[key]) ? form[key] : toRef(form, key as string)
    fields[key as string] = useFieldValidation(fieldRef, rules[key]!)
  }

  const validateAll = async (): Promise<boolean> => {
    const results = await Promise.all(
      Object.values(fields).map((f) => f.validate())
    )
    return results.every(Boolean)
  }

  const errors = computed(() => {
    const result: Record<string, string> = {}
    for (const [key, field] of Object.entries(fields)) {
      if (field.error.value) result[key] = field.error.value
    }
    return result
  })

  return { fields, errors, validateAll }
}

使用起来是这样的:

ts 复制代码
const form = reactive({ username: '', email: '' })

const { errors, validateAll } = useFormValidation(form, {
  username: [
    (v) => (v ? true : '用户名不能为空'),
    async (v) => (await checkUsername(v)) ? true : '用户名已存在',
  ],
  email: [
    (v) => (v ? true : '邮箱不能为空'),
    (v) => (/\S+@\S+\.\S+/.test(v) ? true : '邮箱格式不对'),
  ],
})

注意看,useFormValidation 内部组合了多个 useFieldValidation。每一层各司其职,但合在一起就是一个完整的表单验证系统。


五、设计权衡:该抽和不该抽

这是最容易踩坑的地方。

该抽成 composable 的

信号 示例
3 个以上组件有相同逻辑 列表请求、分页、搜索
逻辑涉及生命周期管理 事件监听、定时器、WebSocket
逻辑涉及响应式状态 + 副作用 防抖搜索、权限控制
逻辑单独可测试、可描述 "管理弹窗的开关状态"

不该抽的

信号 原因
只有 1 个组件在用 过早抽象,增加理解成本
纯计算,无响应式 放 utils 就够了
逻辑和 UI 强绑定 抽出来也没法复用
抽出来之后参数超过 8 个 说明它不是一个内聚的单元

我的经验法则:第一次写在组件里,第二次复制过去,第三次再抽。 过早抽象比不抽象更危险------你会基于不完整的场景做出错误的 API 设计,后面改起来成本极高。


六、可扩展性:当项目变大了

命名约定

arduino 复制代码
composables/
  useList.ts          // 通用列表
  useDialog.ts        // 通用弹窗
  usePermission.ts    // 权限相关

views/user/
  composables/
    useUserList.ts    // 用户列表(组合了 useList)
    useUserForm.ts    // 用户表单

全局通用的放 composables/ 目录,业务级别的跟着模块走。命名上,通用的用 useXxx,业务的用 use[模块]Xxx

依赖注入:跨层级共享

当 composable 的状态需要跨组件共享时:

ts 复制代码
// 提供方
const useProvideUserContext = () => {
  const currentUser = ref<User | null>(null)
  provide('userContext', { currentUser })
  return { currentUser }
}

// 消费方
const useUserContext = () => {
  const ctx = inject('userContext')
  if (!ctx) throw new Error('useUserContext 必须在 UserProvider 内部使用')
  return ctx
}

这种模式在复杂表单、主题系统、多级联动场景里非常实用。

可插拔架构

更大型的项目里,你可以把 composable 设计成插件化的:

ts 复制代码
type Plugin<T> = (context: T) => void

const useList = <T>(options: UseListOptions<T>, plugins: Plugin<ListContext<T>>[] = []) => {
  const context = createListContext(options) // 创建内部上下文

  // 执行所有插件
  for (const plugin of plugins) {
    plugin(context)
  }

  return context.expose() // 暴露公开 API
}

// 使用
const { data } = useList(
  { api: getUserList },
  [withCache({ ttl: 60000 }), withRetry({ times: 3 })]
)

这就像给你的 composable 装上了中间件流水线------核心逻辑不变,扩展能力通过插件注入。


七、容易踩的坑

坑 1:在 composable 里解构 props

ts 复制代码
// ❌ 响应式丢失
const useFeature = (props: Props) => {
  const { name } = props // name 不再是响应式的
  watch(name, () => {}) // 不会触发
}

// ✅ 用 toRefs 或 getter
const useFeature = (name: MaybeRef<string>) => {
  watch(() => unref(name), () => {})
}

坑 2:共享状态的意外污染

ts 复制代码
// ❌ 模块级变量会被所有调用方共享
const cache = ref(new Map())

const useCache = () => {
  return { cache } // 所有组件共享同一个 cache
}

// ✅ 如果需要独立状态,必须在函数内部创建
const useCache = () => {
  const cache = ref(new Map()) // 每个组件独立
  return { cache }
}

这个坑特别隐蔽。如果你确实需要全局共享,请显式用 provide/inject 或 Pinia,而不是模块级变量------后者会让人误以为状态是独立的。

坑 3:忘了处理 SSR

如果你的项目涉及 SSR(如 Nuxt),composable 里不能直接访问 windowdocument

ts 复制代码
const useMouse = () => {
  const x = ref(0)
  const y = ref(0)

  // ✅ 通过生命周期钩子确保只在客户端执行
  onMounted(() => {
    window.addEventListener('mousemove', (e) => {
      x.value = e.clientX
      y.value = e.clientY
    })
  })

  return { x, y }
}

八、总结:Composable 设计的通用模型

回头看,一个设计良好的 composable 其实就是一个最小化的响应式状态机

  • 输入 :通过参数(支持 MaybeRef)接收配置和依赖
  • 状态 :内部用 ref / reactive 管理
  • 转换 :通过 computedwatch 响应变化
  • 副作用:在生命周期钩子中注册和清理
  • 输出:返回响应式状态和操作方法

这个模型不只适用于 Vue。你在 React 的 custom hooks、Solid 的 primitives、甚至后端的 Actor Model 里,都能看到类似的影子------封装状态 + 暴露行为 + 管理生命周期

下次动手写 composable 之前,问自己三个问题:

  1. 它有独立的响应式状态吗?(不然放 utils)
  2. 第三次复用了吗?(不然别急着抽)
  3. 参数超过 5 个了吗?(该拆了)

想清楚再动手。好的抽象不是代码少,而是改的时候不用到处找

相关推荐
我叫黑大帅2 小时前
前端总说的防抖与节流到底是什么?
前端·javascript·面试
掘金安东尼2 小时前
从平面到空间:用 React Three Fiber 构建 3D 产品网格
前端·javascript·面试
swipe2 小时前
#用这 9 个浏览器 API,我把页面从“卡成 PPT”救回到 90+(每个都有能直接抄的例子)
前端·javascript·面试
摸鱼的春哥4 小时前
Agent教程15:认识LangChain,Agent框架的王(上)
前端·javascript·后端
明月_清风5 小时前
自定义右键菜单:在项目里实现“选中文字即刻生成新提示”
前端·javascript
明月_清风5 小时前
告别后端转换:高质量批量导出实战
前端·javascript
刘发财9 小时前
弃用html2pdf.js,这个html转pdf方案能力是它的几十倍
前端·javascript·github
ssshooter16 小时前
看完就懂 useSyncExternalStore
前端·javascript·react.js
Live0000018 小时前
在鸿蒙中使用 Repeat 渲染嵌套列表,修改内层列表的一个元素,页面不会更新
前端·javascript·react native