Vue 3 Composition API 进阶:自定义 Hooks 与 provide/inject 的高级玩法

自定义 Hooks 和 provide/inject 是 Composition API 的两大核心支柱。搞懂它们,你的 Vue 3 代码组织能力会上一个台阶。


一、自定义 Hooks:逻辑复用的"瑞士军刀"

1.1 为什么需要自定义 Hooks?

用过 Vue 2 的同学应该都经历过 Mixins 的痛苦:

javascript 复制代码
// Mixins 的噩梦
export default {
  mixins: [userMixin, authMixin, fetchMixin],
  // data 里的 name 是哪个 mixin 的?methods 里的 submit 呢?
  // 谁也说不清,命名冲突了更惨
}

Mixins 有三个致命问题:

  1. 命名冲突 ------ 多个 mixin 有同名属性,后面的覆盖前面的,排查起来想砸键盘
  2. 来源不清晰 ------ 模板里用了一个 loading 变量,你根本不知道它来自哪个 mixin
  3. 逻辑碎片化 ------ 同一个功能的 data、methods、watch 分散在不同选项里,维护起来东找西找

自定义 Hooks 就是为了解决这些痛点而生的。

1.2 本质是什么?

说白了,自定义 Hook 就是一个封装了有状态逻辑的普通函数 。它用 refcomputedwatch 等 Composition API 来管理状态,然后返回响应式数据和方法。

javascript 复制代码
// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return { count, increment, decrement, reset }
}

使用的时候,解构引入就行:

vue 复制代码
<script setup>
import { useCounter } from '@/composables/useCounter'

const { count, increment, decrement } = useCounter(10)
</script>

<template>
  <button @click="decrement">-</button>
  <span>{{ count }}</span>
  <button @click="increment">+</button>
</template>

你看,来源清晰、命名可控、逻辑内聚。比 Mixins 舒服太多了。

1.3 编写 Hook 的最佳实践

我在项目中总结了几条规则,分享给大家:

规则一:单一职责,组合使用

一个 Hook 只做一件事。复杂功能通过组合多个 Hook 实现,像搭积木一样。

javascript 复制代码
// ✅ 好的设计:每个 Hook 职责单一
const { user } = useUser()
const { posts, loading } = useUserPosts(user.id)
const { hasPermission } = useAuth()

// ❌ 坏的设计:一个 Hook 塞了一堆逻辑
const { user, posts, auth, theme, loading } = useEverything()

规则二:命名规范

  • 函数名以 use 开头,驼峰命名:useMousePositionuseFetch
  • 文件名与函数名保持一致
  • 统一放在 src/composables/src/hooks/ 目录下

规则三:返回对象,方便扩展

javascript 复制代码
// ✅ 推荐:返回对象,解构时可以重命名,扩展性强
return { data, error, loading, refresh }

// ❌ 不推荐:返回数组,不能重命名,添加新属性会破坏顺序
return [data, error, loading]

规则四:务必清理副作用!

这个坑我踩过好几次。Hook 里如果有事件监听、定时器,必须在组件卸载时清理,不然会内存泄漏。

javascript 复制代码
// composables/useMousePosition.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)
  
  const update = (e) => {
    x.value = e.pageX
    y.value = e.pageY
  }
  
  onMounted(() => window.addEventListener('mousemove', update))
  // 关键!组件卸载时移除监听
  onUnmounted(() => window.removeEventListener('mousemove', update))
  
  return { x, y }
}

1.4 高级案例:封装数据请求 Hook

这个 Hook 在项目中出场率极高,几乎每个页面都要用:

typescript 复制代码
// composables/useFetch.ts
import { ref, type Ref } from 'vue'

export function useFetch<T>(url: string | Ref<string>) {
  const data: Ref<T | null> = ref(null)
  const error = ref(null)
  const loading = ref(false)
  
  const fetchData = async () => {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(
        typeof url === 'string' ? url : url.value
      )
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json()
    } catch (e: any) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  return { data, error, loading, fetchData }
}

使用起来很简洁:

vue 复制代码
<script setup>
import { useFetch } from '@/composables/useFetch'

const url = ref('/api/users')
const { data, error, loading, fetchData } = useFetch(url)

// url 变化时自动重新请求
watch(url, fetchData)
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">出错了:{{ error.message }}</div>
  <div v-else>{{ data }}</div>
</template>

1.5 更高级的玩法:虚拟列表 Hook

如果你做过大数据量的列表渲染,肯定接触过虚拟滚动。这个逻辑非常适合封装成 Hook:

typescript 复制代码
// composables/useVirtualList.ts
export function useVirtualList<T>(options: VirtualListOptions<T>) {
  const { items, itemHeight, containerHeight, buffer = 5 } = options
  
  const scrollTop = ref(0)
  const startIndex = computed(() => {
    const start = Math.floor(scrollTop.value / itemHeight) - buffer
    return Math.max(0, start)
  })
  const endIndex = computed(() => {
    const end = Math.ceil(
      (scrollTop.value + containerHeight) / itemHeight
    ) + buffer
    return Math.min(items.value.length, end)
  })
  const visibleItems = computed(() => 
    items.value.slice(startIndex.value, endIndex.value)
  )
  
  const onScroll = (e: Event) => {
    scrollTop.value = (e.target as HTMLElement).scrollTop
  }
  
  return { visibleItems, startIndex, endIndex, onScroll }
}

核心思路就是根据滚动位置计算可视范围,只渲染可见的元素。把这么复杂的逻辑封装进 Hook,组件里只需要关心渲染就行。


二、provide/inject:跨层级通信的"直通车"

2.1 解决什么问题?

在 Vue 中,父子组件通信用 props 和 emit,但如果组件层级很深呢?

css 复制代码
App.vue
  └── Layout.vue
        └── Page.vue
              └── Section.vue
                    └── Card.vue
                          └── Button.vue  ← 需要用到 App.vue 的主题色

如果用 props 逐层传递,中间每一层都要写 theme prop,哪怕自己根本不用。这就是"prop 逐层透传"问题,写起来烦,维护起来更烦。

provide/inject 就是来解决这个问题的:祖先组件提供数据,后代组件按需注入,中间层完全不用管。

2.2 基本用法

javascript 复制代码
// 祖先组件
import { provide, ref } from 'vue'

const theme = ref('dark')
provide('theme', theme)

// 后代组件(不管隔了多少层)
import { inject } from 'vue'

const theme = inject('theme') // 'dark'

就这么简单。但实际使用中有几个坑需要注意。

2.3 保持响应式

provide 的时候直接传 ref 对象,Vue 会自动保持响应式:

javascript 复制代码
// ✅ 响应式的
const theme = ref('dark')
provide('theme', theme)

// ❌ 非响应式的(解构后丢失了 ref)
const { value: themeValue } = toRefs(theme)
provide('theme', themeValue)

后代组件拿到的是同一个 ref 引用,修改时会触发更新。

2.4 用 Symbol 做 Key(强烈推荐)

字符串 key 容易冲突,特别是在大型项目或插件开发中。用 Symbol 就不会有这个问题:

typescript 复制代码
// keys/injectionKeys.ts
import type { InjectionKey, Ref } from 'vue'

export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
export const localeKey: InjectionKey<Ref<string>> = Symbol('locale')
export const authKey: InjectionKey<Ref<AuthState>> = Symbol('auth')

使用的时候:

typescript 复制代码
// 提供者
provide(themeKey, readonly(theme))

// 注入者
const theme = inject(themeKey)

InjectionKey 还能提供类型推断,TypeScript 用起来很舒服。

2.5 最佳实践:Hook + provide/inject 组合

这是我觉得最优雅的用法。把状态和逻辑封装在 Hook 里,通过 provide/inject 共享:

typescript 复制代码
// composables/useTheme.ts
import { ref, provide, inject, readonly, type Ref } from 'vue'
import { themeKey } from '@/keys/injectionKeys'

// 在根组件调用:提供主题
export function provideTheme(initialTheme = 'light') {
  const theme = ref(initialTheme)
  
  const toggleTheme = () => {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }
  
  provide(themeKey, readonly(theme))
  
  return { theme, toggleTheme }
}

// 在后代组件调用:注入主题
export function useTheme() {
  const theme = inject(themeKey)
  
  if (!theme) {
    throw new Error(
      'useTheme() must be used within a component that calls provideTheme()'
    )
  }
  
  return { theme }
}

使用起来非常干净:

vue 复制代码
<!-- App.vue -->
<script setup>
import { provideTheme } from '@/composables/useTheme'

const { theme, toggleTheme } = provideTheme('dark')
</script>

<!-- 任意后代组件 -->
<script setup>
import { useTheme } from '@/composables/useTheme'

const { theme } = useTheme()
</script>

<template>
  <div :class="theme">当前主题:{{ theme }}</div>
</template>

2.6 用 readonly 保护数据

注意到上面 provide 的时候用了 readonly(theme)。这是为了防止后代组件直接修改注入的数据,所有修改都应该通过提供者暴露的方法来进行。

typescript 复制代码
// ✅ 推荐:用 readonly 保护,通过方法修改
provide(themeKey, readonly(theme))

// ❌ 不推荐:直接暴露可修改的 ref
provide(themeKey, theme)  // 后代组件可以随意改

这个原则跟 React 的"状态提升"思想类似 ------ 谁提供数据,谁负责修改。


三、provide/inject 的源码原理

聊完用法,再简单说说原理,面试的时候能讲出深度。

Vue 3 的组件实例有一个 provides 属性。默认情况下,它指向父组件的 provides(原型链继承)。当组件首次调用 provide 时,会触发写时复制(Copy-on-Write)

javascript 复制代码
// 简化的源码逻辑
if (currentInstance.provides === parentInstance.provides) {
  currentInstance.provides = Object.create(parentInstance.provides)
}
currentInstance.provides[key] = value

也就是说,子组件的 provides 通过原型链指向父组件的 provides。查找 key 的时候,沿原型链向上找就行,没找到就是 undefined

响应式的奥秘在于:provide 的是 ref 对象时,注入者拿到的是同一个引用。修改这个 ref,自然就触发了 Vue 的响应式更新。


四、Hooks vs provide/inject:什么时候用哪个?

这两个东西经常一起用,但场景不太一样:

特性 自定义 Hooks provide/inject
核心目标 逻辑复用 跨层级数据共享
作用范围 调用它的组件实例 提供者的整个组件子树
耦合程度 低耦合,通过参数传配置 有隐式依赖,需在正确的组件树内
典型场景 封装鼠标位置、请求、表单验证 主题、国际化、权限、全局配置

我的经验

  • 需要复用逻辑 → 写 Hook
  • 需要跨层级共享数据 → 用 provide/inject
  • 两者结合 → Hook 封装逻辑 + provide/inject 共享状态

五、总结

概念 一句话本质 核心优势
自定义 Hooks 封装有状态逻辑的函数 逻辑复用、来源清晰、无命名冲突
provide/inject 跨层级依赖注入 避免 prop 透传、原型链高效查找

几个关键实践点:

  1. Hook 要单一职责,复杂逻辑通过组合实现
  2. Hook 有副作用一定要在 onUnmounted 中清理
  3. provide/inject 的 key 用 Symbol,避免冲突
  4. provide 的数据用 readonly 保护,通过方法修改
  5. 最佳模式:Hook 封装逻辑 + provide/inject 共享状态

把这些用好了,你的 Vue 3 代码会非常干净、模块化,维护起来也舒服很多。

有问题评论区交流,觉得有帮助点个赞 👍

相关推荐
前端若水2 小时前
在 Vue 2 与 Vue 3 中使用 markdown-it-vue 渲染 Markdown 和数学公式
前端·javascript·vue.js
Aolith2 小时前
用 Vue 递归组件实现嵌套回复,我的评论系统升级全记录
vue.js·全栈
我叫黑大帅3 小时前
最简单的生产-消费者,你都会遇到哪些问题?
后端·面试·go
叫我少年4 小时前
Vue3 状态管理 Pinia 入门指南
vue.js
折哥的程序人生 · 物流技术专研5 小时前
《Java 100 天进阶之路》第23篇:缓冲区数据结构 ByteBuffer
java·开发语言·数据结构·后端·面试·求职招聘
yqcoder5 小时前
Vue 的心脏:深度解析 Vue 2 vs Vue 3 响应式机制
前端·javascript·vue.js
wand codemonkey5 小时前
【第五步+前后分离调】最后的联动调试--java+Vue3项目
java·开发语言·vue.js
骑自行车的码农6 小时前
react hooks原理:为什么不能在条件中使用 hook ?
vue.js·react.js
Aolith6 小时前
从一堆 Bug 到一行代码:我是如何用 keep-alive 优雅解决 Vue 滚动位置恢复的
vue.js·全栈