Vue3组合式API实战:自定义Hook封装与响应式性能优化策略

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/

(0)
小编小编
上一篇 19小时前
下一篇 19小时前

相关推荐