你写了 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状态 - 可能注册生命周期钩子(
onMounted、onUnmounted) - 可能使用
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 里不能直接访问 window、document:
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管理 - 转换 :通过
computed和watch响应变化 - 副作用:在生命周期钩子中注册和清理
- 输出:返回响应式状态和操作方法
这个模型不只适用于 Vue。你在 React 的 custom hooks、Solid 的 primitives、甚至后端的 Actor Model 里,都能看到类似的影子------封装状态 + 暴露行为 + 管理生命周期。
下次动手写 composable 之前,问自己三个问题:
- 它有独立的响应式状态吗?(不然放 utils)
- 第三次复用了吗?(不然别急着抽)
- 参数超过 5 个了吗?(该拆了)
想清楚再动手。好的抽象不是代码少,而是改的时候不用到处找。