Vue3组合式API进阶实战:自定义Hook设计模式与性能优化策略

组合式API不是Option API的语法糖

Vue3的组合式API(Composition API)经常被当作Option API的替代写法来使用——把data放到ref、methods放到普通函数、生命周期放到onMounted,逻辑结构没有任何改变。这种用法完全浪费了组合式API的核心价值:逻辑聚合与复用。

组合式API的真正优势是按功能维度组织代码,而不是按选项类型。一个用户认证功能的状态、方法、副作用全部聚合在一起,而不是分散在data、methods、computed各个选项中。这种组织方式使得逻辑提取为可复用的Hook成为可能。

自定义Hook的设计原则

好的Hook设计需要遵循三个原则:单一职责、显式依赖、可控副作用。

单一职责要求一个Hook只做一件事。useAuth负责认证,useFetch负责数据请求,usePagination负责分页逻辑。把认证和数据请求混在一个useAuthAndFetch中,看似方便,实际上降低了复用性。

显式依赖意味着Hook的参数就是它的依赖项,不隐式依赖全局状态或路由参数:

// 不好的设计:隐式依赖路由参数
function useArticleDetail() {
  const route = useRoute()
  const id = route.params.id  // 隐式依赖
  const article = ref(null)
  // ...
}

// 好的设计:显式传入依赖
function useArticleDetail(id: Ref<string>) {
  const article = ref(null)
  watch(id, (newId) => {
    fetchArticle(newId).then(data => article.value = data)
  }, { immediate: true })
  return { article }
}

可控副作用指Hook内部创建的定时器、事件监听、WebSocket连接等必须在Hook销毁时自动清理:

function useWebSocket(url: Ref<string>) {
  const data = ref(null)
  const status = ref('connecting')
  let ws: WebSocket | null = null

  const connect = () => {
    ws = new WebSocket(url.value)
    ws.onmessage = (e) => { data.value = JSON.parse(e.data) }
    ws.onopen = () => { status.value = 'connected' }
    ws.onclose = () => { status.value = 'disconnected' }
  }

  watch(url, () => {
    ws?.close()
    connect()
  }, { immediate: true })

  // 副作用自动清理
  onUnmounted(() => {
    ws?.close()
  })

  const send = (msg: unknown) => {
    ws?.send(JSON.stringify(msg))
  }

  return { data, status, send }
}

请求Hook的通用设计:useRequest

数据请求是前端最常见的需求,一个设计良好的useRequest Hook可以覆盖90%的请求场景:

interface UseRequestOptions<T> {
  immediate?: boolean       // 是否立即执行
  debounceMs?: number      // 防抖延迟
  retryCount?: number       // 失败重试次数
  cache?: boolean           // 是否缓存结果
  cacheTime?: number        // 缓存时间(ms)
  onError?: (err: Error) => void
  onSuccess?: (data: T) => void
}

function useRequest<T>(
  fn: () => Promise<T>,
  options: UseRequestOptions<T> = {}
) {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(false)
  const cacheMap = new Map<string, { data: T; expire: number }>()

  const execute = async () => {
    const cacheKey = fn.toString()
    
    // 缓存检查
    if (options.cache) {
      const cached = cacheMap.get(cacheKey)
      if (cached && cached.expire > Date.now()) {
        data.value = cached.data
        return
      }
    }

    loading.value = true
    error.value = null
    
    let attempts = 0
    const maxAttempts = (options.retryCount || 0) + 1

    while (attempts < maxAttempts) {
      try {
        const result = await fn()
        data.value = result
        if (options.cache) {
          cacheMap.set(cacheKey, {
            data: result,
            expire: Date.now() + (options.cacheTime || 60000)
          })
        }
        options.onSuccess?.(result)
        break
      } catch (e) {
        attempts++
        if (attempts >= maxAttempts) {
          error.value = e as Error
          options.onError?.(e as Error)
        }
      }
    }
    loading.value = false
  }

  if (options.immediate !== false) {
    execute()
  }

  return { data, error, loading, execute }
}

使用时只需传入异步函数和配置项:

const { data: userList, loading, error } = useRequest(
  () => api.getUsers({ page: 1, size: 20 }),
  { immediate: true, cache: true, retryCount: 2 }
)

响应式性能陷阱:避免不必要的重新渲染

组合式API中常见的性能问题是大对象的深度响应式和computed的过度使用。

问题一:大型列表的深度响应式。1000条数据的列表用reactive包裹后,任何一条数据的任何字段变化都会触发依赖它的组件重新渲染。对于只读展示的列表,shallowRef更合适:

// 不好的做法:1000条数据全部深度响应式
const list = reactive(await fetchList())  // 性能差

// 好的做法:只在需要修改时使用深度响应式
const list = shallowRef(await fetchList())

// 修改某一条时手动触发更新
function updateItem(index, newData) {
  const newList = [...list.value]
  newList[index] = { ...newList[index], ...newData }
  list.value = newList  // 浅层替换触发更新
}

问题二:computed函数内的复杂计算。computed会缓存结果,但getter函数中的复杂计算每次依赖变化时都会重新执行。如果计算逻辑重,使用手动缓存:

// 计算密集型场景用watch + ref替代computed
const sortedList = ref([])

watch(
  sourceList,
  (list) => {
    requestIdleCallback(() => {
      sortedList.value = heavySort(list)
    })
  },
  { immediate: true }
)

requestIdleCallback将排序计算放到浏览器空闲时段执行,避免阻塞用户交互。

Hook组合:构建业务逻辑层

多个基础Hook可以组合出面向业务的复合Hook:

// 复合Hook:分页列表
function usePaginatedList<T>(fetchFn: (params: any) => Promise<T>) {
  const page = ref(1)
  const pageSize = ref(20)
  const total = ref(0)
  
  const { data: rawdata, loading, error, execute } = useRequest(
    () => fetchFn({ page: page.value, pageSize: pageSize.value }),
    { immediate: true }
  )

  const list = computed(() => rawdata.value?.items || [])
  
  watch([page, pageSize], () => execute())

  const gotoPage = (p: number) => { page.value = p }
  const changeSize = (s: number) => { pageSize.value = s; page.value = 1 }

  return { list, loading, error, page, pageSize, total, gotoPage, changeSize }
}

基础Hook保持通用性,复合Hook封装业务逻辑。这种分层设计让基础Hook可以在不同业务场景中复用,而复合Hook让页面组件的代码量大幅减少。

Hook测试策略

Hook的单元测试使用@vue/test-utils的mount组合setup函数:

import { mount } from '@vue/test-utils'

function withSetup<T>(hook: () => T): T {
  let result: T
  const wrapper = mount({
    setup() {
      result = hook()
      return () => null
    }
  })
  return result!
}

// 测试useRequest
test('useRequest应该正确处理请求', async () => {
  const { data, loading, execute } = withSetup(() =>
    useRequest(() => Promise.resolve({ name: 'test' }))
  )
  expect(loading.value).toBe(true)
  await flushPromises()
  expect(data.value).toEqual({ name: 'test' })
  expect(loading.value).toBe(false)
})

withSetup辅助函数让Hook测试脱离组件上下文,专注验证Hook本身的逻辑正确性。每个Hook的测试只关注输入输出和副作用,不关心UI渲染细节。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-jin-jie-shi-zhan-zi-ding-yi-hook-she-ji/

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

相关推荐