Vue3组合式API的封装思路
Vue3的setup语法糖让组件逻辑组织更灵活,但逻辑分散在多个生命周期钩子和响应式声明中时,代码可读性反而不如选项式API。自定义Hook(组合式函数)的核心价值在于将相关逻辑聚合为内聚单元——状态声明、计算属性、副作用、方法全部封装在一个函数中,组件只需解构调用。封装的关键不是把代码搬出组件,而是建立清晰的输入输出边界。
自定义Hook的标准封装模式
一个合格的Hook应具备:明确的参数接口、返回只读的响应式状态、内部自动管理生命周期清理。以分页数据加载为例:
// hooks/usePagination.ts
import { ref, computed, watch, onUnmounted, readonly } from 'vue'
import type { Ref } from 'vue'
interface PaginationOptions {
pageSize: number
immediate?: boolean
}
interface PaginationState<T> {
data: readonly Ref<T[]>
total: readonly Ref<number>
currentPage: readonly Ref<number>
loading: readonly Ref<boolean>
error: readonly Ref<string | null>
hasNext: boolean
hasPrev: boolean
goTo: (page: number) => void
refresh: () => void
nextPage: () => void
prevPage: () => void
}
export function usePagination<T>(
fetchFn: (page: number, size: number) => Promise<{ list: T[]; total: number }>,
options: PaginationOptions
): PaginationState<T> {
const data = ref<T[]>([]) as Ref<T[]>
const total = ref(0)
const currentPage = ref(1)
const loading = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | null = null
const hasNext = computed(() => currentPage.value * options.pageSize < total.value)
const hasPrev = computed(() => currentPage.value > 1)
async function fetchData() {
// 取消上一次未完成的请求
abortController?.abort()
abortController = new AbortController()
loading.value = true
error.value = null
try {
const result = await fetchFn(currentPage.value, options.pageSize)
// 检查是否已被新请求取代
if (abortController.signal.aborted) return
data.value = result.list
total.value = result.total
} catch (e: any) {
if (e.name !== 'AbortError') {
error.value = e.message
}
} finally {
loading.value = false
}
}
function goTo(page: number) {
const maxPage = Math.ceil(total.value / options.pageSize) || 1
currentPage.value = Math.max(1, Math.min(page, maxPage))
}
function refresh() {
fetchData()
}
function nextPage() {
if (hasNext.value) goTo(currentPage.value + 1)
}
function prevPage() {
if (hasPrev.value) goTo(currentPage.value - 1)
}
// 页码变化自动重新请求
watch(currentPage, () => {
fetchData()
})
// 组件卸载时取消请求
onUnmounted(() => {
abortController?.abort()
})
// 自动首次加载
if (options.immediate !== false) {
fetchData()
}
return {
data: readonly(data),
total: readonly(total),
currentPage: readonly(currentPage),
loading: readonly(loading),
error: readonly(error),
hasNext: hasNext.value,
hasPrev: hasPrev.value,
goTo,
refresh,
nextPage,
prevPage
}
}
组件中使用时逻辑极度精简:
<script setup lang="ts">
import { usePagination } from '@/hooks/usePagination'
import { fetchUserList } from '@/api/user'
const { data, loading, currentPage, total, nextPage, prevPage, hasNext, hasPrev } =
usePagination(fetchUserList, { pageSize: 20 })
</script>
<template>
<div>
<UserTable :data="data" :loading="loading" />
<Pagination
:current="currentPage"
:total="total"
:has-next="hasNext"
:has-prev="hasPrev"
@next="nextPage"
@prev="prevPage"
/>
</div>
</template>
响应式性能优化的关键场景
Vue3的响应式系统基于Proxy实现,追踪精度到属性级别。但不当使用仍会导致不必要的重渲染。以下是三个高频性能陷阱及解法。
陷阱一:大数组的全量响应式追踪
当一个包含上万条记录的数组被ref包裹后,数组的每个元素的每个属性访问都会触发依赖收集。如果组件只依赖数组长度,每次访问都会收集到完整数组的依赖:
// 性能差:整个大数组都是响应式的
const largeList = ref(largeArray) // 10000+ items
// 优化:只对需要的部分做响应式
const listLength = ref(largeArray.length)
const displayList = computed(() => {
// 只对当前页数据创建响应式
return largeArray.slice(start, end).map(item => reactive(item))
})
更好的方案是使用shallowRef,只追踪.value本身的变化而不深度追踪内部属性:
import { shallowRef, triggerRef } from 'vue'
const largeList = shallowRef(largeArray)
// 更新时手动触发
function updateItem(index: number, newData: any) {
const newList = [...largeList.value]
newList[index] = { ...newList[index], ...newData }
largeList.value = newList
// shallowRef赋值会自动触发,不需要手动triggerRef
}
陷阱二:computed中执行昂贵计算
computed默认不会缓存中间结果。如果computed依赖的响应式数据频繁变化,每次变化都会重新计算:
// 性能差:每次filter都遍历全量数据
const filteredList = computed(() =>
largeList.value.filter(item => item.status === activeStatus.value)
)
// 优化:分离昂贵计算和响应式依赖
import { computed, ref } from 'vue'
const activeStatus = ref('active')
const filteredList = computed(() => {
// 用索引预过滤
const status = activeStatus.value
return largeList.value.filter(item => item.status === status)
})
对于计算量更大的场景(如排序、聚合),使用独立的缓存层:
import { ref, watch } from 'vue'
const sortKey = ref('name')
const sortedCache = ref<any[]>([])
// 只在排序键变化时重新排序
watch(
[sortKey, largeList],
([key, list]) => {
// 昂贵的排序操作
sortedCache.value = [...list].sort((a, b) => {
return a[key].localeCompare(b[key])
})
},
{ immediate: true }
)
陷阱三:v-for缺少key或使用index作key
使用index作为key时,列表顺序变化会导致组件错误复用。实际场景中应使用唯一业务ID:
<!-- 错误:index作key -->
<div v-for="(item, index) in list" :key="index">
{{ item.name }}
</div>
<!-- 正确:业务唯一ID -->
<div v-for="item in list" :key="item.id">
{{ item.name }}
</div>
Hook间的状态共享与隔离
多个组件需要共享同一份数据时,Hook可以充当轻量级状态仓库,无需引入Pinia:
// hooks/useGlobalConfig.ts
import { ref, readonly } from 'vue'
// 模块级变量,所有组件共享同一实例
const theme = ref<'light' | 'dark'>('light')
const locale = ref('zh-CN')
export function useGlobalConfig() {
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
function setLocale(newLocale: string) {
locale.value = newLocale
}
return {
theme: readonly(theme),
locale: readonly(locale),
toggleTheme,
setLocale
}
}
如果需要隔离,则让Hook每次调用创建独立实例——只需在函数内部声明ref即可。这个区分看似简单,却是组件架构设计的核心决策:共享状态放在函数外部,隔离状态放在函数内部。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-shi-zhan-zi-ding-yi-hook-feng-zhuang-yu/