Vue3 组合式函数设计:从逻辑复用到响应式架构的工程实践
Vue3 组合式函数设计:从逻辑复用到响应式架构的工程实践

一、选项式 API 的复用瓶颈:当 mixins 变成技术债
Vue2 时代的 mixins 是逻辑复用的主流方案,但它有三个难以回避的缺陷:命名冲突(多个 mixin 可能定义同名属性,后者覆盖前者,且无警告)、数据来源不透明(模板中使用的变量无法判断来自哪个 mixin)、类型推导困难(TypeScript 无法推断 mixin 注入的属性类型)。当项目中的 mixin 数量超过 5 个时,维护成本急剧上升。
Vue3 的组合式函数(Composable)从根本上解决了这些问题。它以函数为复用单元,通过闭包隔离状态,通过参数传递依赖,天然支持 TypeScript 类型推导。但 Composable 的设计并非"把选项式 API 的代码搬到 setup() 里"那么简单——响应式数据的生命周期管理、副作用清理、异步错误处理、组合函数之间的依赖关系,都需要系统化的设计思路。
二、Composable 的架构分层与响应式数据流
flowchart TB
subgraph 视图层
TEMPLATE[组件模板] --> USE1[useUserList]
TEMPLATE --> USE2[useUserForm]
TEMPLATE --> USE3[usePermission]
end
subgraph 组合式函数层
USE1 --> CORE1[useFetch: 数据请求]
USE1 --> CORE2[usePagination: 分页控制]
USE2 --> CORE3[useForm: 表单状态]
USE2 --> CORE4[useValidation: 校验逻辑]
USE3 --> CORE5[useAuth: 权限判断]
USE3 --> CORE6[useStorage: 持久化]
end
subgraph 基础设施层
CORE1 --> API[HTTP 客户端]
CORE4 --> RULES[校验规则引擎]
CORE5 --> TOKEN[Token 管理]
CORE6 --> LS[localStorage]
end
subgraph 响应式数据流
API --> |ref/reactive| STATE1[请求状态]
RULES --> |computed| STATE2[校验结果]
TOKEN --> |readonly ref| STATE3[权限状态]
end
style USE1 fill:#e3f2fd
style USE2 fill:#e3f2fd
style USE3 fill:#e3f2fd
style CORE1 fill:#fff3e0
style CORE3 fill:#fff3e0
style CORE5 fill:#fff3e0
Composable 的架构分为三层:业务组合函数(如 useUserList)、基础组合函数(如 useFetch、useForm)和基础设施层(HTTP 客户端、存储等)。业务组合函数编排基础组合函数,基础组合函数封装基础设施。这种分层确保了每一层只关注自己的职责,避免业务逻辑与底层实现耦合。
关键设计原则:输入是 ref,输出也是 ref。Composable 接收 ref 类型的参数,内部通过 watch 响应参数变化,输出 ref 类型的结果供模板或其他 Composable 消费。这种"ref 进、ref 出"的模式确保了数据流的响应式贯穿始终。
三、生产级 Composable 的实现模式
3.1 useFetch:带缓存与去重的数据请求
// composables/useFetch.ts
import { ref, watch, toValue, type MaybeRef } from 'vue'
// 请求缓存:避免重复请求同一 URL
const cache = new Map<string, {
data: unknown
timestamp: number
ttl: number
}>()
interface UseFetchOptions<T> {
/** 请求方法 */
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
/** 请求体 */
body?: MaybeRef<unknown>
/** 缓存时间(毫秒),0 表示不缓存 */
cacheTTL?: number
/** 是否立即执行 */
immediate?: boolean
/** 请求前的数据转换 */
transform?: (data: unknown) => T
/** 错误处理回调 */
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
/** 响应数据 */
data: Ref<T | null>
/** 加载状态 */
isLoading: Ref<boolean>
/** 错误信息 */
error: Ref<Error | null>
/** 手动触发请求 */
execute: () => Promise<void>
/** 刷新数据(忽略缓存) */
refresh: () => Promise<void>
}
export function useFetch<T = unknown>(
url: MaybeRef<string>,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const {
method = 'GET',
body,
cacheTTL = 0,
immediate = true,
transform = (d) => d as T,
onError,
} = options
const data = ref<T | null>(null) as Ref<T | null>
const isLoading = ref(false)
const error = ref<Error | null>(null)
// 请求去重:同一时刻对同一 URL 的请求只发一次
const pendingRequests = new Map<string, Promise<unknown>>()
async function execute(useCache = true): Promise<void> {
const urlValue = toValue(url)
const bodyValue = toValue(body)
// 检查缓存
if (useCache && cacheTTL > 0) {
const cached = cache.get(urlValue)
if (cached && Date.now() - cached.timestamp < cached.ttl) {
data.value = transform(cached.data)
return
}
}
// 检查是否有进行中的相同请求
const pendingKey = `${method}:${urlValue}`
if (pendingRequests.has(pendingKey)) {
try {
const result = await pendingRequests.get(pendingKey)
data.value = transform(result)
} catch (e) {
// 去重请求的错误由首次发起者处理
}
return
}
isLoading.value = true
error.value = null
const requestPromise = fetch(urlValue, {
method,
headers: { 'Content-Type': 'application/json' },
body: bodyValue ? JSON.stringify(bodyValue) : undefined,
})
.then(async (res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json()
})
pendingRequests.set(pendingKey, requestPromise)
try {
const result = await requestPromise
// 写入缓存
if (cacheTTL > 0) {
cache.set(urlValue, {
data: result,
timestamp: Date.now(),
ttl: cacheTTL,
})
}
data.value = transform(result)
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
error.value = err
onError?.(err)
} finally {
isLoading.value = false
pendingRequests.delete(pendingKey)
}
}
// 响应式 URL 变化时自动重新请求
watch(
() => toValue(url),
() => execute(),
{ immediate }
)
return {
data,
isLoading,
error,
execute: () => execute(true),
refresh: () => execute(false),
}
}
3.2 useForm:表单状态与校验的组合式管理
// composables/useForm.ts
import { ref, reactive, computed, type Ref } from 'vue'
interface FieldRule<T> {
/** 校验函数,返回 true 表示通过 */
validate: (value: T, form: Record<string, unknown>) => boolean
/** 校验失败时的错误信息 */
message: string
}
interface FieldConfig<T> {
/** 初始值 */
initial: T
/** 校验规则列表 */
rules?: FieldRule<T>[]
}
interface UseFormOptions<T extends Record<string, unknown>> {
/** 字段配置 */
fields: { [K in keyof T]: FieldConfig<T[K]> }
/** 提交处理函数 */
onSubmit: (values: T) => Promise<void> | void
}
interface UseFormReturn<T extends Record<string, unknown>> {
/** 表单值(响应式) */
values: T
/** 各字段的错误信息 */
errors: Record<keyof T, string[]>
/** 整体是否通过校验 */
isValid: Ref<boolean>
/** 是否正在提交 */
isSubmitting: Ref<boolean>
/** 校验单个字段 */
validateField: (key: keyof T) => boolean
/** 校验所有字段 */
validateAll: () => boolean
/** 提交表单 */
submit: () => Promise<void>
/** 重置表单到初始值 */
reset: () => void
}
export function useForm<T extends Record<string, unknown>>(
options: UseFormOptions<T>
): UseFormReturn<T> {
const { fields, onSubmit } = options
// 初始化表单值
const values = reactive({} as T)
const initialValues = {} as T
for (const key in fields) {
values[key] = fields[key].initial
initialValues[key] = fields[key].initial
}
// 初始化错误信息
const errors = reactive(
{} as Record<keyof T, string[]>
)
for (const key in fields) {
errors[key] = []
}
const isSubmitting = ref(false)
const isValid = computed(() => {
return Object.values(errors).every(
(errs) => errs.length === 0
)
})
function validateField(key: keyof T): boolean {
const fieldConfig = fields[key]
if (!fieldConfig.rules || fieldConfig.rules.length === 0) {
errors[key] = []
return true
}
const fieldErrors: string[] = []
for (const rule of fieldConfig.rules) {
if (!rule.validate(values[key], values)) {
fieldErrors.push(rule.message)
}
}
errors[key] = fieldErrors
return fieldErrors.length === 0
}
function validateAll(): boolean {
let allValid = true
for (const key in fields) {
if (!validateField(key)) {
allValid = false
}
}
return allValid
}
async function submit(): Promise<void> {
if (!validateAll()) {
return
}
isSubmitting.value = true
try {
await onSubmit({ ...values } as T)
} catch (e) {
// 提交失败时的错误由调用方处理
throw e
} finally {
isSubmitting.value = false
}
}
function reset(): void {
for (const key in fields) {
values[key] = initialValues[key]
errors[key] = []
}
}
return {
values,
errors,
isValid,
isSubmitting,
validateField,
validateAll,
submit,
reset,
}
}
四、Composable 设计的边界与权衡
粒度选择:Composable 的粒度直接影响复用性。粒度过细(如 useCounter)复用场景有限,粒度过粗(如 useAdminPanel)失去灵活性。经验法则是:一个 Composable 应只封装一个"可独立测试的逻辑单元"。useFetch 封装了"数据请求"这个单元,useForm 封装了"表单状态管理"这个单元。判断标准是:如果移除某个 Composable 后,剩余逻辑仍然可以独立运行,说明粒度合理。
副作用清理:Composable 中注册的 watch、addEventListener、定时器等副作用,必须在组件卸载时清理。Vue 的 onScopeDispose 和 onUnmounted 是清理的时机,但并非所有 Composable 都在组件上下文中使用——在非组件上下文(如 Pinia Store)中使用时,需要手动管理生命周期。建议在 Composable 内部通过 getCurrentScope() 检测上下文,自动注册清理逻辑。
响应式泄漏:Composable 返回的 ref 如果被外部直接修改,可能破坏内部状态一致性。对于只读的状态(如 isLoading、error),应使用 readonly() 包装后返回,防止外部误修改。这是"最小暴露原则"在 Composable 设计中的体现。
服务端渲染兼容:Composable 中如果有 DOM 操作或浏览器 API 调用(如 window、document),在 SSR 环境下会报错。解决方案是将浏览器特定的逻辑延迟到 onMounted 中执行,或通过 import.meta.client 条件导入。
五、总结
Vue3 组合式函数的设计核心是"以函数为复用单元,以 ref 为数据载体"。分层架构(业务→基础→基础设施)确保了职责清晰,"ref 进、ref 出"的模式确保了响应式数据流的连贯。生产级 Composable 需要处理缓存与去重(useFetch)、校验与提交(useForm)、副作用清理和响应式泄漏防护等工程细节。建议从基础 Composable(useFetch、useForm、useStorage)起步,逐步构建业务 Composable 库,避免过早抽象,但也不要等到代码重复三次才提取。
更多推荐
所有评论(0)