Vue3组合式API进阶:自定义Hook设计模式与响应式性能优化方案

为什么需要自定义Hook以及设计原则

Vue3的Composition API把逻辑组织从选项式的分散结构变成了可组合的函数块。自定义Hook是这个模型的核心产物——把可复用的响应式逻辑封装成独立函数,组件只需调用即可获得完整功能。好的Hook设计遵循单一职责、显式依赖、可测试三个原则。

不好的做法:把一个页面的所有逻辑写在一个setup里,然后在”重构”时粗暴抽成函数。好的做法:从需求分析阶段就把功能拆成独立Hook。

数据获取Hook的标准实现

远程数据获取是最高频的Hook场景。一个生产级的useFetch需要处理加载状态、错误重试、请求取消和缓存。

// composables/useFetch.ts
import { ref, shallowRef, watchEffect, onScopeDispose } from 'vue'

interface UseFetchOptions<T> {
  immediate?: boolean
  initialData?: T
  refetch?: boolean
  dedupe?: number
}

export function useFetch<T>(
  urlGetter: () => string,
  options: UseFetchOptions<T> = {}
) {
  const { immediate = true, initialData, refetch = false, dedupe = 0 } = options

  const data = shallowRef<T | undefined>(initialData)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  const lastFetchTime = ref(0)

  let abortController: AbortController | null = null

  async function execute() {
    // 请求去重
    if (dedupe > 0 && Date.now() - lastFetchTime.value < dedupe) {
      return
    }

    // 取消上一个请求
    abortController?.abort()
    abortController = new AbortController()

    loading.value = true
    error.value = null

    try {
      const url = urlGetter()
      const response = await fetch(url, {
        signal: abortController.signal,
      })

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      data.value = await response.json()
      lastFetchTime.value = Date.now()
    } catch (e: any) {
      if (e.name !== 'AbortError') {
        error.value = e
      }
    } finally {
      loading.value = false
    }
  }

  if (immediate) {
    watchEffect(() => {
      execute()
    })
  }

  onScopeDispose(() => {
    abortController?.abort()
  })

  return { data, error, loading, execute }
}

使用方式:

// 在组件中使用
const { data: users, error, loading } = useFetch(
  () => `/api/users?page=${page.value}`,
  { dedupe: 2000 }
)

响应式性能陷阱与shallowRef

Vue3的reactive和ref默认深度代理整个对象树。当数据量大或嵌套深时,代理开销会成为性能瓶颈。

典型问题:从API拿到一个包含500条记录的数组,放进reactive后,首次渲染耗时翻倍。

解决方案:对只读的大对象使用shallowRef,避免深度代理。

// 不好的做法
const list = ref([])  // 深度代理每个元素
list.value = await fetchLargeList()  // 每个元素的属性都被Proxy包装

// 好的做法
const list = shallowRef([])  // 只代理.value本身
list.value = await fetchLargeList()  // 数组元素不被代理

// 如果需要修改数组中某个元素,整体替换触发更新
function updateItem(index: number, newData: any) {
  const newList = [...list.value]
  newList[index] = { ...newList[index], ...newData }
  list.value = newList  // 触发响应式更新
}

另一个常见陷阱:在v-for中对大列表使用computed做复杂转换。每次依赖变化都重新计算整个列表。

// 不好的做法
const filteredList = computed(() =>
  hugeList.value.filter(expensivePredicate).map(transform)
)

// 好的做法:分页 + 手动触发
const pageSize = 50
const currentPage = ref(1)
const pagedList = computed(() => {
  const start = (currentPage.value - 1) * pageSize
  return hugeList.value.slice(start, start + pageSize)
})

跨组件状态共享的Hook模式

小型项目用Pinia当然没问题,但有时只需要在几个兄弟组件间共享状态,引入Pinia有些重。这时可以用Hook闭包实现轻量状态共享。

// composables/useCounter.ts
const counter = ref(0)  // 模块级变量,跨组件共享

export function useCounter() {
  function increment() { counter.value++ }
  function decrement() { counter.value-- }
  function reset() { counter.value = 0 }

  return { count: counter, increment, decrement, reset }
}

// 任何组件调用useCounter()拿到的都是同一个ref
// 这比provide/inject更轻量,比全局变量更清晰

关键点:ref声明在函数外部(模块作用域),不是函数内部。这样每次调用useCounter返回的是同一个响应式引用。

Hook的可测试性设计

Hook应该可以脱离组件单独测试。使用@vue/test-utils的withSetup辅助函数。

// tests/useFetch.spec.ts
import { withSetup } from './test-utils'

describe('useFetch', () => {
  it('should fetch data on mount', async () => {
    const { data, loading } = withSetup(() =>
      useFetch(() => '/api/test')
    )

    expect(loading.value).toBe(true)

    await new Promise(r => setTimeout(r, 100))

    expect(loading.value).toBe(false)
    expect(data.value).toBeDefined()
  })
})

Hook内如果有外部依赖(如fetch),通过参数注入替代硬编码,方便测试时mock。

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

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

相关推荐

Vue3组合式API进阶:自定义Hook设计模式与响应式性能优化方案

为什么需要自定义Hook以及设计原则

Vue3的Composition API把逻辑组织从选项式的分散结构变成了可组合的函数块。自定义Hook是这个模型的核心产物——把可复用的响应式逻辑封装成独立函数,组件只需调用即可获得完整功能。好的Hook设计遵循单一职责、显式依赖、可测试三个原则。

不好的做法:把一个页面的所有逻辑写在一个setup里,然后在”重构”时粗暴抽成函数。好的做法:从需求分析阶段就把功能拆成独立Hook。

数据获取Hook的标准实现

远程数据获取是最高频的Hook场景。一个生产级的useFetch需要处理加载状态、错误重试、请求取消和缓存。

// composables/useFetch.ts
import { ref, shallowRef, watchEffect, onScopeDispose } from 'vue'

interface UseFetchOptions<T> {
  immediate?: boolean
  initialData?: T
  refetch?: boolean
  dedupe?: number
}

export function useFetch<T>(
  urlGetter: () => string,
  options: UseFetchOptions<T> = {}
) {
  const { immediate = true, initialData, refetch = false, dedupe = 0 } = options

  const data = shallowRef<T | undefined>(initialData)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  const lastFetchTime = ref(0)

  let abortController: AbortController | null = null

  async function execute() {
    // 请求去重
    if (dedupe > 0 && Date.now() - lastFetchTime.value < dedupe) {
      return
    }

    // 取消上一个请求
    abortController?.abort()
    abortController = new AbortController()

    loading.value = true
    error.value = null

    try {
      const url = urlGetter()
      const response = await fetch(url, {
        signal: abortController.signal,
      })

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }

      data.value = await response.json()
      lastFetchTime.value = Date.now()
    } catch (e: any) {
      if (e.name !== 'AbortError') {
        error.value = e
      }
    } finally {
      loading.value = false
    }
  }

  if (immediate) {
    watchEffect(() => {
      execute()
    })
  }

  onScopeDispose(() => {
    abortController?.abort()
  })

  return { data, error, loading, execute }
}

使用方式:

// 在组件中使用
const { data: users, error, loading } = useFetch(
  () => `/api/users?page=${page.value}`,
  { dedupe: 2000 }
)

响应式性能陷阱与shallowRef

Vue3的reactive和ref默认深度代理整个对象树。当数据量大或嵌套深时,代理开销会成为性能瓶颈。

典型问题:从API拿到一个包含500条记录的数组,放进reactive后,首次渲染耗时翻倍。

解决方案:对只读的大对象使用shallowRef,避免深度代理。

// 不好的做法
const list = ref([])  // 深度代理每个元素
list.value = await fetchLargeList()  // 每个元素的属性都被Proxy包装

// 好的做法
const list = shallowRef([])  // 只代理.value本身
list.value = await fetchLargeList()  // 数组元素不被代理

// 如果需要修改数组中某个元素,整体替换触发更新
function updateItem(index: number, newData: any) {
  const newList = [...list.value]
  newList[index] = { ...newList[index], ...newData }
  list.value = newList  // 触发响应式更新
}

另一个常见陷阱:在v-for中对大列表使用computed做复杂转换。每次依赖变化都重新计算整个列表。

// 不好的做法
const filteredList = computed(() =>
  hugeList.value.filter(expensivePredicate).map(transform)
)

// 好的做法:分页 + 手动触发
const pageSize = 50
const currentPage = ref(1)
const pagedList = computed(() => {
  const start = (currentPage.value - 1) * pageSize
  return hugeList.value.slice(start, start + pageSize)
})

跨组件状态共享的Hook模式

小型项目用Pinia当然没问题,但有时只需要在几个兄弟组件间共享状态,引入Pinia有些重。这时可以用Hook闭包实现轻量状态共享。

// composables/useCounter.ts
const counter = ref(0)  // 模块级变量,跨组件共享

export function useCounter() {
  function increment() { counter.value++ }
  function decrement() { counter.value-- }
  function reset() { counter.value = 0 }

  return { count: counter, increment, decrement, reset }
}

// 任何组件调用useCounter()拿到的都是同一个ref
// 这比provide/inject更轻量,比全局变量更清晰

关键点:ref声明在函数外部(模块作用域),不是函数内部。这样每次调用useCounter返回的是同一个响应式引用。

Hook的可测试性设计

Hook应该可以脱离组件单独测试。使用@vue/test-utils的withSetup辅助函数。

// tests/useFetch.spec.ts
import { withSetup } from './test-utils'

describe('useFetch', () => {
  it('should fetch data on mount', async () => {
    const { data, loading } = withSetup(() =>
      useFetch(() => '/api/test')
    )

    expect(loading.value).toBe(true)

    await new Promise(r => setTimeout(r, 100))

    expect(loading.value).toBe(false)
    expect(data.value).toBeDefined()
  })
})

Hook内如果有外部依赖(如fetch),通过参数注入替代硬编码,方便测试时mock。

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

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

相关推荐