一、痛点:原生 provide/inject 类型安全写法为何如此繁琐?
跨层级共享状态时,Vue 官方提供了三种解决方案:props 逐层透传、provide/inject 依赖注入、以及 Pinia 全局状态管理。其中 provide/inject 最容易被低估——它专精于“组件子树内共享上下文”的场景,既比 props 透传简洁,又比 Pinia 轻量许多。

不过,但凡你亲手写过一次类型安全的 provide/inject,就一定会同意它也是最冗长的。
来看一段教科书式的实现:
复制代码// keys.ts
import type { InjectionKey, Ref } from 'vue'export interface CounterContext {
count: Ref<number>
double: Readonly<Ref<number>>
increment: () => void
}// 1) 必须手动声明 InjectionKey 并配合 Symbol
export const counterKey: InjectionKey<CounterContext> = Symbol('counter')
复制代码
复制代码
具体问题集中于三个方面:
- 必须手动声明 InjectionKey,每增加一个上下文就多一个 Symbol;
- 类型需要在 key、provide、inject 三处保持一致,修改一处而忘了另外两处,就会静默失效;
- inject 的返回值默认是
T | undefined,业务代码中到处充斥着!或if (!ctx),要么接受不安全的非空断言,要么编写大量防御性代码。
VueUse 的 createInjectionState 正是针对这三点而生。它用一个 composable 函数,把“声明上下文 + 提供 + 注入”三项操作打包成 [useProvideXxx, useXxxState] 的配对 API。下面逐项详细拆解。
本文基于 VueUse v14.3.0(2026 年 5 月发布,要求 Vue 3.5+),所有代码示例均可直接运行。
二、API 详解:一对配对函数搞定所有事
2.1 最小示例
直接看官方的计数器示例(来自 VueUse 官方文档):
复制代码// useCounterStore.ts
import { computed, shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'const [useProvideCounterStore, useCounterStore] = createInjectionState(
(initialValue: number) => {
const count = shallowRef(initialValue)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, double, increment }
},
)export { useProvideCounterStore, useCounterStore }
复制代码
复制代码
count: {{ count }} / double: {{ double }}
与原生写法对比,差异一目了然:
- 不再需要手动定义
InjectionKey——内部自动生成 Symbol; - 无需手动调用
provide/inject——这些逻辑封装在useProvideXxx/useXxxState内部; - 类型从 composable 返回值自动推导——你怎么编写,类型就怎么生成;
- 支持传参——
useProvideCounterStore(0)将initialValue直接透传给 composable,参数类型也由泛型推导。
2.2 配对设计哲学:明确角色边界
createInjectionState 返回的两个函数命名带有强烈的语义暗示:
useProvideXxx:在祖先组件中调用,表示“我来提供这套状态”;useXxxState:在后代组件中调用,表示“我要消费这套状态”。
数组解构出的两个函数本质上是配对使用的:调用 useProvideXxx 时,内部执行 composable,并将返回值通过 provideLocal 注入到组件子树;调用 useXxxState 时,通过 injectLocal 取出。这种配对模式比单一 hook 更清晰——你一看代码就知道当前组件在状态树中扮演什么角色。
设计上还有一个常被忽略的细节:useProvideXxx 也会把 composable 的返回值作为普通函数返回。也就是说,provider 自身也可以直接使用这些状态:
复制代码
这项特性从 v10.5.0 PR #3387 开始支持:允许 provide 和 inject 在同一组件中同时发生。在原生 API 中要做到这一点,你需要在 setup() 里手动 provide 完再 inject 一次,既繁琐又反直觉。
2.3 自定义 InjectionKey
默认情况下,key 是 Symbol(composable.name || 'InjectionState')——从 v10.8.0 之后,会自动使用 composable 的函数名作为 Symbol 描述符(PR #3788),便于调试时识别。
注意,每次调用 createInjectionState 都会生成新的 Symbol。模块级别声明一次,所有组件共用同一个 Symbol,这通常没有问题。但如果你需要:
- 在多个独立打包产物之间共享同一个上下文(例如微前端场景);
- 使用字符串 key 方便调试或 DevTools 识别;
可以传入 injectionKey 选项:
复制代码import type { InjectionKey } from 'vue'const counterKey: InjectionKey<CounterStore> = Symbol('counter-store')const [useProvideCounterStore, useCounterStore] = createInjectionState(
(initialValue: number) => { /* ... */ },
{ injectionKey: counterKey },
)// 也支持字符串
// { injectionKey: 'counter-store' }
字符串 key 是 Vue 原生 inject 所支持的,但这样一来会失去类型推导的桥梁,因此优先使用 InjectionKey 强类型。
2.4 默认值与 throw 兜底:三种错误处理策略
当 provide 尚未调用时就去 inject,是最常见的运行时陷阱。createInjectionState 提供了三档应对策略:
策略 A:不做任何处理(默认)
复制代码const counter = useCounterStore() // 类型: CounterStore | undefined
counter?.increment()
useXxxState 的返回类型默认包含 undefined,调用方需要自行处理。
策略 B:传入 defaultValue,自动兜底
该功能从 v10.10.0(PR #3902)开始新增。v14.3.0 进一步改进了类型(PR #5306)——一旦传了 defaultValue,返回类型就不再包含 undefined:
复制代码const [useProvideCounterStore, useCounterStore] = createInjectionState(
(initialValue: number) => ({ /* ... */ }),
{
defaultValue: {
count: shallowRef(0),
double: shallowRef(0),
increment: () => {},
},
},
)const { count, double, increment } = useCounterStore() // ✅ 类型中不再包含 undefined
适合“祖先没 provide 也能正常工作”的可选上下文场景。
策略 C:包裹一层抛错或回退函数
官方推荐的工程实践是:不要直接 export useCounterStore,而是额外封装一层:
复制代码// useCounterStore.ts
const [useProvideCounterStore, useCounterStoreRaw] = createInjectionState(
(initialValue: number) => { /* ... */ },
)export { useProvideCounterStore }// 强制存在:祖先未提供上下文时直接抛错
export function useCounterStoreOrThrow() {
const ctx = useCounterStoreRaw()
if (ctx == null) {
throw new Error(
'[CounterStore] 请先在祖先组件调用 useProvideCounterStore',
)
}
return ctx
}// 默认兜底:祖先未提供时返回缺省实现
export function useCounterStoreWithDefault() {
return useCounterStoreRaw() ?? {
count: shallowRef(0),
double: shallowRef(0),
increment: () => {},
}
}
通过封装,让 inject 端彻底摆脱 undefined,业务代码中再也不需要到处都是 ! 断言。这正是 VueUse 官方文档推荐的模式。
三、源码原理浅析:本质是 provide/inject 的薄包装
打开 packages/shared/createInjectionState/index.ts,核心实现非常简短:
复制代码import type { InjectionKey } from 'vue'
import { injectLocal } from '../injectLocal'
import { provideLocal } from '../provideLocal'export function createInjectionState<Arguments extends Array<any>, Return>(
composable: (...args: Arguments) => Return,
options?: CreateInjectionStateOptions<Return>,
) {
// 1) 默认使用 composable 的函数名作为 Symbol 描述符
const key: string | InjectionKey<Return> =
options?.injectionKey || Symbol(composable.name || 'InjectionState')
const defaultValue = options?.defaultValue // 2) provider 端:执行 composable + provide
const useProvidingState = (...args: Arguments) => {
const state = composable(...args)
provideLocal(key, state)
return state
} // 3) consumer 端:从同一个 key 注入
const useInjectedState = () => injectLocal(key, defaultValue) return [useProvidingState, useInjectedState]
}
总共不到 20 行。但有两个值得关注的细节:
1. provideLocal / injectLocal,并非原生的 provide / inject。
这是 VueUse 自己的小封装。原生的 Vue provide 不允许在同一个组件内,先 provide 完再 inject 同一个 key(取出来的仍然是上层的 provide 值)。provideLocal 通过将 provide 内容直接挂载到当前实例的本地缓存,使得“自身组件 inject 自身 provide 的值”成为可能。
这项能力来自 PR #3387,官方文档中“provider 自己也能用 store”那一段就是基于这一机制。
2. 函数重载提供两套类型签名。
复制代码// 重载 1:传了 defaultValue,返回类型不含 undefined
export function createInjectionState<Args, Return>(
composable: (...args: Args) => Return,
options: { defaultValue: Return } & CreateInjectionStateOptions<Return>,
): CreateInjectionStateReturn<Args, Return, Return>// 重载 2:没传 defaultValue,返回类型含 undefined
export function createInjectionState<Args, Return>(
composable: (...args: Args) => Return,
options?: CreateInjectionStateOptions<Return>,
): CreateInjectionStateReturn<Args, Return, Return | undefined>
通过函数重载,defaultValue 的存在与否会精确反映在 useXxxState 的返回类型上。这是 v14.3.0 PR #5306 新增的能力——之前的版本无论是否传 defaultValue,类型都会带上 undefined,开发者不得不反复断言;现在终于“传了就保证有”。
理解到这一层,你就能感受到 VueUse 对类型安全的执着:接口未变,但开发体验提升了一个台阶。
四、实战:四个跨层级状态共享的真实场景
4.1 表单上下文:让任意嵌套字段共享校验状态
复杂表单经常面临“嵌套区域 + 全局校验”的需求。比如下面这个用户资料表,外层控制整体禁用、是否正在提交、以及字段错误集合:
复制代码// useFormContext.ts
import { computed, reactive, shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'interface FormContextOptions {
initialValues?: Record<string, any>
}const [useProvideFormContext, useFormContextRaw] = createInjectionState(
(options: FormContextOptions = {}) => {
const values = reactive<Record<string, any>>({ ...options.initialValues })
const errors = reactive<Record<string, string>>({})
const submitting = shallowRef(false)
const disabled = shallowRef(false) const isValid = computed(() => Object.keys(errors).length === 0) function setValue(key: string, value: any) {
values[key] = value
delete errors[key]
} function setError(key: string, message: string) {
errors[key] = message
} return { values, errors, submitting, disabled, isValid, setValue, setError }
},
)export { useProvideFormContext }export function useFormContext() {
const ctx = useFormContextRaw()
if (!ctx) throw new Error('请在 )
return ctx
}
复制代码
复制代码
无论 嵌套多深、外层套了多少层 layout 容器,它始终能拿到表单上下文。整套机制比 Pinia 轻量得多——它只在这棵子树中生效,多个 实例互不干扰。
4.2 Tab 容器:父子组件解耦的经典场景
与 是 provide/inject 的经典用法。用 createInjectionState 改写:
复制代码// useTabsContext.ts
import { shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'const [useProvideTabsContext, useTabsContextRaw] = createInjectionState(
(initial?: string) => {
const active = shallowRef(initial ?? '')
const tabs = shallowRef<{ name: string; label: string }[]>([]) function register(name: string, label: string) {
if (!tabs.value.some(t => t.name === name)) {
tabs.value = [...tabs.value, { name, label }]
if (!active.value) active.value = name
}
} function unregister(name: string) {
tabs.value = tabs.value.filter(t => t.name !== name)
} return { active, tabs, register, unregister }
},
)export { useProvideTabsContext }
export function useTabsContext() {
const ctx = useTabsContextRaw()
if (!ctx) throw new Error(' 必须在 内使用' )
return ctx
}
复制代码
复制代码
使用示例:
复制代码
不需要了解 的内部实现,注册/注销都通过上下文完成。相比 的“声明式” props 配置,组合式 + provide/inject 更接近“插件化”的思维方式。
4.3 Stepper 步骤器:当前步、能否前进/后退
复制代码// useStepperContext.ts
import { computed, shallowRef } from 'vue'
import { createInjectionState } from '@vueuse/core'const [useProvideStepperContext, useStepperContextRaw] = createInjectionState(
(totalSteps: number) => {
const current = shallowRef(0) const isFirst = computed(() => current.value === 0)
const isLast = computed(() => current.value === totalSteps - 1) function next() {
if (!isLast.value) current.value++
}
function prev() {
if (!isFirst.value) current.value--
}
function goto(index: number) {
if (index >= 0 && index < totalSteps) current.value = index
} return { current, isFirst, isLast, next, prev, goto }
},
)export { useProvideStepperContext }
export function useStepperContext() {
const ctx = useStepperContextRaw()
if (!ctx) throw new Error(' 必须在 内使用' )
return ctx
}
复制代码
复制代码
与 Tab 同理, 完全不需要 props,它从上下文中自动获取所需信息。关键在于这种状态绝对不该上 Pinia——它与当前 实例强绑定,全局化反而成为负担。
4.4 表格行选:跨表头/行/批量操作栏共享选中集合
复制代码// useSelectionContext.ts
import { computed, reactive } from 'vue'
import { createInjectionState } from '@vueuse/core'interface SelectionContextOptions {
itemKey: (item: T) => string
}const [useProvideSelectionContext, useSelectionContextRaw] =
createInjectionState((options: SelectionContextOptions ) => {
const selected = reactive(new Set<string>()) function isSelected(item: T) {
return selected.has(options.itemKey(item))
} function toggle(item: T) {
const key = options.itemKey(item)
selected.has(key) ? selected.delete(key) : selected.add(key)
} function clear() {
selected.clear()
} function selectAll(items: T[]) {
items.forEach(item => selected.add(options.itemKey(item)))
} const count = computed(() => selected.size) return { selected, isSelected, toggle, clear, selectAll, count }
})export { useProvideSelectionContext }
export function useSelectionContext() {
const ctx = useSelectionContextRaw()
if (!ctx) throw new Error('请在 内使用')
return ctx as ReturnType<typeof useSelectionContextRaw> & {} // 类型见下文
}
复制代码
表头中的“全选”复选框、行中的“单选”、批量操作栏中的“已选 N 条”——三个部分分别在三个不同的 slot 中渲染,但都共享同一个选中状态。这正是 provide/inject 模式擅长的场景。
五、对比矩阵:跟 Pinia / props / createSharedComposable 怎么选
每隔半年就会出现“Pinia 和 provide/inject 选哪个”的争论。createInjectionState 的定位其实非常清晰——它是 provide/inject 的现代封装,而非 Pinia 的替代品。
方案 作用范围 实例隔离 类型推导 SSR 安全 适合的场景 props 透传 父子直接 每实例独立 ✅ 显式定义 ✅ 安全 一两层、字段有限 createInjectionState子树 每个 provider 子树独立 ✅ 完整推导 ✅ 安全 表单/Tab/Stepper/表格行选等“组件族” createSharedComposable整个客户端 所有调用共享同一份 ✅ 推导 ✅ SSR 自动降级为非共享 全局鼠标位置、滚动状态等“单例资源” createGlobalState整个客户端 所有调用共享 ✅ 推导 ✅ 同上 与 SharedComposable 类似,更轻量 Pinia 应用级 单例 store ✅ 一流 ✅(带 hydration) 用户身份、购物车、跨路由的业务状态
几条选型直觉:
- 状态生命周期跟随某个父组件 →
createInjectionState。 卸载时,所有内部字段的状态一同被回收,自然且安全。
- 状态需要在多个互相独立的实例间隔离 →
createInjectionState。同一页面中的两个 绝不能共享 active tab;如果使用 Pinia,反而需要给每个 store 加 id,徒增复杂度。
- 状态确实是“全局唯一资源” →
createSharedComposable。例如 useMouse() 希望全局只注册一个 mousemove 监听器,用 createSharedComposable 包裹一层即可。
- 状态是业务状态、跨路由、需要持久化或调试 → 选用 Pinia。
createInjectionState 不是 store,没有 devtools 集成、没有 SSR 序列化、没有时间旅行能力。
特别区分 createInjectionState 与 createSharedComposable,这两者最容易混淆——名字都带 “create”,都来自 @vueuse/shared:
复制代码// createSharedComposable:全应用单例
import { createSharedComposable, useMouse } from '@vueuse/core'
const useSharedMouse = createSharedComposable(useMouse)// CompA.vue
const { x, y } = useSharedMouse() // 第一次调用:注册 mousemove 事件
// CompB.vue
const { x, y } = useSharedMouse() // 复用上一次的状态,不再重复注册// createInjectionState:组件树作用域
const [useProvideXxx, useXxx] = createInjectionState(/* ... */)
// 必须先调用祖先组件的 useProvideXxx() 才能在后代组件中使用 useXxx()
核心区别:createSharedComposable 是“全应用一份”,没有 provider 概念;createInjectionState 是“每个 provider 子树一份”,必须有祖先 provide 才能在后代中使用。生命周期上,createSharedComposable 在最后一个订阅者卸载时停止 effectScope;createInjectionState 的生命周期跟随 provider 组件。
据 createSharedComposable 官方文档:
createInjectionState 因为基于 provide/inject,天然就是请求隔离的,不需要此类降级。SSR 项目中更推荐 createInjectionState。
六、坑与解
6.1 别忘了 provider 必须先挂载
跟 createReusableTemplate 一样,使用顺序是硬性约束:
复制代码
编写工具组件时,养成习惯:永远封装一个 useXxxOrThrow,让缺失 provider 时直接报错而非静默失败。这条建议来自官方文档。
6.2 异步 setup 中的 inject 时机
如果某个组件的 setup 是 async 的(例如包含 top-level await),需要确保 useXxxState() 调用发生在第一个 await 之前,否则会丢失当前组件实例:
复制代码
这是 Vue 3 inject 自身的限制,并非 VueUse 的特殊行为。
6.3 别在 createInjectionState 内部用 onMounted 等 hooks 期望它跟 consumer 绑定
composable 函数体是在 provider 组件的 setup 阶段执行:
复制代码const [useProvideXxx] = createInjectionState(() => {
onMounted(() => {
// 这是 provider 组件的 onMounted,并非 consumer
})
return { /* ... */ }
})
这与普通 composable 的行为一致,但由于 createInjectionState 的“消费”动作延迟到后代组件执行,新手容易误认为生命周期会跟着 consumer 走。记住:composable 体在 useProvideXxx() 被调用时立即执行,且只执行一次。
6.4 多个 provider 的优先级
如果在祖先链上多次调用同一个 useProvideXxx,inject 拿到的是最近的祖先那一份。这是 Vue 原生行为,可以利用它做“覆盖式上下文”(例如某个区域内的表单禁用规则覆盖父级),但也容易在不经意间产生 bug。建议使用 DevTools 的 Provided / Injected 面板检查实际拿到的是哪一份。
6.5 SSR 下不要在 composable 里直接访问 window
createInjectionState 本身 SSR 安全,但你的 composable 函数体一旦访问 window / document,就会像原生 composable 一样引起错误。该写的 if (typeof window !== 'undefined') 检查必不可少。
七、版本要求与变更速览
据 VueUse createInjectionState Changelog:
版本 关键变更 v14.3.0(2026-05-01)传 defaultValue 时返回类型不再含 undefined(#5306) v14.0.0(2025-10)要求 Vue 3.5+,构建产物迁移 v13.6.0(2025-07-28)添加 @__NO_SIDE_EFFECTS__ 注解,改善 tree-shaking v12.0.0-beta.1(2024-11-21)不再支持 Vue 2 v10.10.0(2024-05-27)新增 defaultValue 选项(#3902) v10.8.0(2024-02-20)injectionKey 默认使用 composable 名称(#3788)v10.5.0(2023-10-07)新增 injectionKey 选项(#3404);允许在同一组件中同时 provide 和 inject(#3387)
新项目直接安装 v14.3.0 最为稳妥:
复制代码npm i @vueuse/core
如果你的项目仍使用 Vue 2,需要锁定在 v9.x 版本。
八、小结
createInjectionState 的价值不在于它能做什么——这些功能原生 provide/inject 也同样能做到。它的价值在于将那些重复的样板代码全部砍掉,并将 TypeScript 类型推导推进到极致。
可以这样记忆它的定位:
- 想让 provide/inject 更好写? 用
createInjectionState。
- 想让一棵子树共享状态? 用
createInjectionState,不要轻易上 Pinia。
- 想让全局只有一份资源? 用
createSharedComposable。
- 想做应用级业务状态? 用 Pinia。
它的 API 设计——[useProvideXxx, useXxxState] 配对——精准填补了“抽象成 store 又怕过度工程”的灰色地带:你既无需手写 InjectionKey 三件套,也不必引入完整的 store 库。该有类型有类型,该有错误处理有错误处理,该轻量轻量,完全符合 VueUse 一贯的“够用就好”哲学。
下次再遇到跨层级状态共享的场景,先问自己:这状态真的需要 Pinia 吗?还是它只跟某个父组件相关? 如果是后者,createInjectionState 应该是你的第一选择。
参考资料:
- VueUse
createInjectionState 官方文档:vueuse.org/shared/crea…
- 源码(GitHub):github.com/vueuse/vueu…
- 同组件 provide+inject PR:github.com/vueuse/vueu…
- defaultValue 选项 PR:github.com/vueuse/vueu…
- 默认值非 undefined 类型 PR(v14.3.0):github.com/vueuse/vueu…
createSharedComposable 官方文档:vueuse.org/shared/crea…
- Vue 官方 Provide / Inject 指南:cn.vuejs.org/guide/compo…
- Composables vs. Provide/Inject vs. Pinia — When to Use What:vueschool.io/articles/vu…
来源:https://juejin.cn/post/7652194092413845542
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。
相关推荐
补充同频道和同主题内容,方便继续浏览更多相关内容。
同类最新
继续查看同栏目最近更新的文章。
更多
如何在JavaScript中实现基于旋转视野的FOV射线绘制详解
如果用一句话概括核心,那就是:在 RayCasting 游戏开发中,绘制动态视野边界线(FOV)最可靠的方式是在逻辑层通过数学公式将坐标“算”出来,而不是依赖 Canvas 绘图上下文的旋转操作。 在实现类似 Doom 风格的 RayCasting 游戏时,动态视野(Field of View, F
TypeScript后端数据正确映射为前端接口类型的方法
在后端数据与前端类型之间来回转换,几乎是每位 TypeScript 开发者都无法回避的常态。后端返回的 car_brand、reg_number,和前端接口中定义的 brand、govtNumber,命名风格常常对不上号。此时,如果为了省事直接用 as 类型断言“强行”指认类型,那就踩进了常见的陷阱
动态HTML表格按层级条件合并单元格的JavaScript实现
本文详细讲解一种递归式 JavaScript 合并单元格方法,用于按列优先级(如前3列)智能合并表格行:仅当前一列已合并的前提下,才允许后续列合并相同值,从而精准实现多级分组与层级表格合并效果。 在动态生成的 HTML 表格中,按业务逻辑合并重复行是常见需求。然而,简单地对单列分别遍历合并——例如先
Next.js 13+重定向后滚动失效解决方案
在 Next js App Router 的日常开发中,有一个令人颇为困扰的异常现象——当服务端执行 `redirect()` 跳转后,目标页面竟然无法正常滚动。没错,页面已经渲染完成,内容也完整显示,但垂直滚动条仿佛凭空消失。这个问题在 Next js 13 5 4 版本中尤为突出。 先给出结论:
WebGL图像加载延迟的纹理初始化时立即显示方法
本文详细介绍如何利用 Promise 与 async await 重构 WebGL 纹理加载流程,彻底解决首次渲染显示蓝色占位色、需要手动交互才能刷新的问题,实现文件导入后四张纹理平面即时正确渲染。 实际上,这个坑在 WebGL 开发中相当常见——纹理异步加载的小陷阱,说起来不大,但第一次遇到确实令
