Vue3 Composables设计到底该怎么写
Vue3组合式API的核心价值不是setup语法糖,而是Composables——可复用的状态逻辑单元。但实际项目中大量Composables变成了”把代码从组件搬到函数”的搬运工,既没有做到逻辑内聚,也没有实现真正的复用。本文从设计原则出发,结合组件库架构实践,拆解Composables的正确写法。
Composables设计的三个核心原则
好的Composable满足三个标准:输入明确、副作用可控、返回值可组合。反模式是把组件里的ref和watch直接搬到函数里,变成一个没有封装边界的逻辑碎片。
// 反模式:逻辑碎片化,依赖外部状态
export function useUserList() {
// 隐式依赖外部store
const store = useUserStore()
const list = ref([])
// 副作用散落在函数体外
onMounted(async () => {
list.value = await store.fetchAll()
})
return { list }
}
问题在哪?调用者无法控制请求时机,无法传参,无法复用在不同场景。
// 正确模式:输入明确,副作用可控
export function useUserList(options: {
pageSize?: number
immediate?: boolean
}) {
const { pageSize = 20, immediate = true } = options
const list = ref<User[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
const page = ref(1)
const total = ref(0)
async function fetch(currentPage = 1) {
loading.value = true
error.value = null
try {
const res = await api.getUsers({ page: currentPage, pageSize })
list.value = res.data
total.value = res.total
page.value = currentPage
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// immediate控制是否自动执行
if (immediate) {
onMounted(() => fetch())
}
return { list, loading, error, page, total, fetch }
}
调用方决定何时请求,可以传参控制分页,还能在不同组件中独立使用。
响应式状态管理:避免Ref套Ref陷阱
Composable返回值混用ref和reactive是常见混乱源。建议统一用ref返回,原因:ref可以替换整个值,reactive不能替换引用。
// 返回reactive的问题
export function useForm() {
const form = reactive({ name: '', email: '' })
const reset = () => {
// 这里无法重置——form = { name: '', email: '' } 丢失响应式
form.name = ''
form.email = ''
}
return { form, reset }
}
// 用ref重构——可以整体替换
export function useForm() {
const form = ref({ name: '', email: '' })
const reset = () => {
form.value = { name: '', email: '' } // 直接替换,保留响应式
}
return { form, reset }
}
Composable组合:构建业务逻辑积木
真正的复用发生在Composable之间的组合。一个复杂表单场景可以这样拆分:
// 基础Composable:表单校验
export function useFormValidation<T extends Record<string, any>>(
form: Ref<T>,
rules: Record<keyof T, (v: any) => string | true>
) {
const errors = ref<Record<keyof T, string>>({} as any)
const validateField = (key: keyof T) => {
const rule = rules[key]
const result = rule(form.value[key])
errors.value[key] = result === true ? '' : result
return result === true
}
const validate = () => {
let valid = true
for (const key in rules) {
if (!validateField(key)) valid = false
}
return valid
}
return { errors, validate, validateField }
}
// 基础Composable:防抖请求
export function useDebouncedRequest<T>(
requestFn: () => Promise<T>,
delay = 300
) {
const timer = ref<ReturnType<typeof setTimeout>>()
const data = ref<T | null>(null)
const loading = ref(false)
const execute = () => {
clearTimeout(timer.value)
timer.value = setTimeout(async () => {
loading.value = true
try {
data.value = await requestFn()
} finally {
loading.value = false
}
}, delay)
}
onUnmounted(() => clearTimeout(timer.value))
return { data, loading, execute }
}
// 业务Composable:组合基础能力
export function useUserForm() {
const form = ref({ name: '', email: '', role: 'user' })
const { errors, validate } = useFormValidation(form, {
name: v => v.length >= 2 || '姓名至少2个字符',
email: v => /^[^\s@]+@[^\s@]+$/.test(v) || '邮箱格式不正确',
role: v => ['user', 'admin'].includes(v) || '角色无效'
})
const { loading, execute } = useDebouncedRequest(
() => api.createUser(form.value),
500
)
const submit = () => {
if (validate()) execute()
}
return { form, errors, loading, submit }
}
组件库中的Composables架构设计
在组件库场景中,Composables需要处理更复杂的问题:跨组件通信、可覆盖的默认行为、TypeScript类型推导。
// 组件库Composable标准模板
export interface UsePopoverOptions {
trigger?: 'click' | 'hover' | 'focus'
placement?: 'top' | 'bottom' | 'left' | 'right'
offset?: number
disabled?: Ref<boolean>
onOpen?: () => void
onClose?: () => void
}
export function usePopover(target: Ref<HTMLElement | undefined>, options: UsePopoverOptions = {}) {
const {
trigger = 'click',
placement = 'bottom',
offset = 8,
disabled = ref(false),
onOpen,
onClose
} = options
const visible = ref(false)
const popoverStyle = ref<Record<string, string>>({})
let cleanup: (() => void) | null = null
const updatePosition = () => {
if (!target.value || !visible.value) return
const rect = target.value.getBoundingClientRect()
const positions = {
top: { top: `${rect.top - offset}px`, left: `${rect.left + rect.width / 2}px` },
bottom: { top: `${rect.bottom + offset}px`, left: `${rect.left + rect.width / 2}px` },
left: { top: `${rect.top + rect.height / 2}px`, left: `${rect.left - offset}px` },
right: { top: `${rect.top + rect.height / 2}px`, left: `${rect.right + offset}px` },
}
popoverStyle.value = positions[placement]
}
const open = () => {
if (disabled.value) return
visible.value = true
nextTick(updatePosition)
onOpen?.()
}
const close = () => {
visible.value = false
onClose?.()
}
const toggle = () => visible.value ? close() : open()
// 按Esc关闭
useEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && visible.value) close()
})
// 点击外部关闭
onClickOutside(target, close)
return { visible, popoverStyle, open, close, toggle }
}
这个Composable的设计要点:配置项全部可选且有合理默认值;事件回调让调用方可以覆盖默认行为;返回open/close/toggle方法而非内部绑定事件,让调用方决定触发方式。
跨组件状态共享:provide/inject模式
深层嵌套组件间共享状态,不要用全局store,用provide/inject做作用域隔离:
// 创建注入键
export const FORM_KEY: InjectionKey<ReturnType<typeof useUserForm>> = Symbol('form')
// 父组件provide
export function provideUserForm() {
const formComposable = useUserForm()
provide(FORM_KEY, formComposable)
return formComposable
}
// 子组件inject
export function useInjectUserForm() {
const form = inject(FORM_KEY)
if (!form) throw new Error('useInjectUserForm must be used inside a FormProvider')
return form
}
这种方式下,同一页面可以存在多个独立的表单实例,互不干扰。比Pinia更适合局部作用域的状态共享。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3composables-she-ji-shi-zhan-cong-luo-ji-fu-yong-dao-zu/