自定义 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 有三个致命问题:
- 命名冲突 ------ 多个 mixin 有同名属性,后面的覆盖前面的,排查起来想砸键盘
- 来源不清晰 ------ 模板里用了一个
loading变量,你根本不知道它来自哪个 mixin - 逻辑碎片化 ------ 同一个功能的 data、methods、watch 分散在不同选项里,维护起来东找西找
自定义 Hooks 就是为了解决这些痛点而生的。
1.2 本质是什么?
说白了,自定义 Hook 就是一个封装了有状态逻辑的普通函数 。它用 ref、computed、watch 等 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开头,驼峰命名:useMousePosition、useFetch - 文件名与函数名保持一致
- 统一放在
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 透传、原型链高效查找 |
几个关键实践点:
- Hook 要单一职责,复杂逻辑通过组合实现
- Hook 有副作用一定要在 onUnmounted 中清理
- provide/inject 的 key 用 Symbol,避免冲突
- provide 的数据用 readonly 保护,通过方法修改
- 最佳模式:Hook 封装逻辑 + provide/inject 共享状态
把这些用好了,你的 Vue 3 代码会非常干净、模块化,维护起来也舒服很多。
有问题评论区交流,觉得有帮助点个赞 👍