跨层级共享状态,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')
复制代码<!-- Provider.vue -->
<script setup lang="ts">
import { computed, provide, ref } from 'vue'
import { counterKey, type CounterContext } from './keys'const count = ref(0)
const double = computed(() => count.value * 2)
const increment = () => count.value++// 2) provide 时类型对得上才行
provide<CounterContext>(counterKey, { count, double, increment })
</script>
复制代码<!-- Consumer.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { counterKey } from './keys'// 3) 默认值是 undefined,要么写默认值,要么 ! 断言,要么手写 if 校验
const ctx = inject(counterKey)
if (!ctx) throw new Error('counter context not provided')const { count, double, increment } = ctx
</script>
问题集中在三点:
T | undefined,业务代码里到处充斥着 ! 或 if (!ctx),要么忍受不安全的非空断言,要么写一坨防御代码。VueUse 的 createInjectionState 就是冲着这三点来的。它用一个 composable 函数,把"声明上下文 + 提供 + 注入"三件事打包成 [useProvideXxx, useXxxState] 的配对 API。下面我们一项一项拆开讲。
本文基于 VueUse v14.3.0(2026 年 5 月发布,要求 Vue 3.5+),所有代码示例可直接运行。
直接看官方的计数器例子(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 }
复制代码<!-- Root.vue:在祖先里 provide -->
<script setup lang="ts">
import { useProvideCounterStore } from './useCounterStore'useProvideCounterStore(0)
</script><template>
<slot />
</template>
复制代码<!-- Counter.vue:在任意后代里 inject -->
<script setup lang="ts">
import { useCounterStore } from './useCounterStore'// 类型已经被推导为 { count, double, increment } | undefined
const { count, double, increment } = useCounterStore()!
</script><template>
<button @click="increment">+</button>
count: {{ count }} / double: {{ double }}
</template>
跟原生写法相比,一目了然的差异:
InjectionKey——内部自动生成 Symbol;provide / inject 调用——封装在 useProvideXxx / useXxxState 里;useProvideCounterStore(0) 把 initialValue 透传给 composable,参数类型也被泛型推导。createInjectionState 返回的两个函数命名带有强烈的语义暗示:
useProvideXxx:在祖先组件调用,"我来提供这套状态";useXxxState:在后代组件调用,"我要消费这套状态"。数组解构出来的两个函数本质上是配对使用的:调用 useProvideXxx 时内部执行 composable、把返回值通过 provideLocal 注入;调用 useXxxState 时通过 injectLocal 取出。这种配对模式比单一的 hook 更清晰——你看代码就知道当前组件在状态树里扮演什么角色。
设计上还有个细节常被忽略:useProvideXxx 也会把 composable 的返回值当作普通函数返回。意思是 provider 自己也可以直接用:
复制代码<!-- Root.vue -->
<script setup lang="ts">
const { count, increment } = useProvideCounterStore(0)// provider 自己就能消费这套状态,不需要再跑一遍 useCounterStore
console.log(count.value)
</script>
这是从 v10.5.0 PR #3387 起就支持的设计:允许 provide 和 inject 在同一组件里发生。在原生 API 里要做到这点你得在 setup() 里手动 provide 完再 inject 一次,繁琐且违反直觉。
默认 key 是 Symbol(composable.name || 'InjectionState')——v10.8.0 之后会自动用 composable 的函数名作为 Symbol 描述符(PR #3788),方便调试。
但每次调用 createInjectionState 都会生成新的 Symbol。模块级别声明一次,所有组件用同一个 Symbol,没问题。但如果你需要:
可以传 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<T> 强类型。
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 }// 强制存在:祖先没 provide 直接抛错
export function useCounterStoreOrThrow() {
const ctx = useCounterStoreRaw()
if (ctx == null) {
throw new Error(
'[CounterStore] 请先在祖先组件调用 useProvideCounterStore',
)
}
return ctx
}// 默认兜底:祖先没 provide 时返回缺省实现
export function useCounterStoreWithDefault() {
return useCounterStoreRaw() ?? {
count: shallowRef(0),
double: shallowRef(0),
increment: () => {},
}
}
通过封装让 inject 端完全摆脱 undefined,再也不需要业务代码满天 ! 断言。这是 VueUse 官方文档推荐的模式。
打开 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 对类型安全的执念:接口没变,但开发体验提升一个台阶。
复杂表单经常有"嵌套区域 + 全局校验"的需求。比如下面这个用户资料表,外层管理整体禁用、是否提交中、字段错误集合:
复制代码// 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('请在 <Form> 组件内使用表单字段')
return ctx
}
复制代码<!-- Form.vue:祖先 -->
<script setup lang="ts">
import { useProvideFormContext } from './useFormContext'const props = defineProps<{ initialValues?: Record<string, any> }>()
useProvideFormContext({ initialValues: props.initialValues })
</script><template>
<form>
<slot />
</form>
</template>
复制代码<!-- Field.vue:任意层级的子组件 -->
<script setup lang="ts">
import { computed } from 'vue'
import { useFormContext } from './useFormContext'const props = defineProps<{ name: string; label: string }>()
const { values, errors, disabled, setValue } = useFormContext()const value = computed({
get: () => values[props.name] ?? '',
set: (v) => setValue(props.name, v),
})
</script><template>
<label>
{{ label }}
<input v-model="value" :disabled="disabled" />
<em v-if="errors[name]">{{ errors[name] }}</em>
</label>
</template>
无论 <Field> 嵌套多深、外层套了几层 layout 容器,它始终能拿到表单上下文。整套机制比 Pinia 轻得多——它只在这棵子树里有效,多个 <Form> 互不干扰。
<Tabs> 与 <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('<Tab> 必须在 <Tabs> 内使用')
return ctx
}
复制代码<!-- Tabs.vue -->
<script setup lang="ts">
import { useProvideTabsContext } from './useTabsContext'const props = defineProps<{ modelValue?: string }>()
const { active, tabs } = useProvideTabsContext(props.modelValue)
</script><template>
<div class="tabs">
<nav class="tabs__nav">
<button
v-for="t in tabs"
:key="t.name"
:class="{ active: t.name === active }"
@click="active = t.name"
>
{{ t.label }}
</button>
</nav>
<div class="tabs__panel">
<slot />
</div>
</div>
</template>
复制代码<!-- Tab.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useTabsContext } from './useTabsContext'const props = defineProps<{ name: string; label: string }>()
const { active, register, unregister } = useTabsContext()onMounted(() => register(props.name, props.label))
onBeforeUnmount(() => unregister(props.name))
</script><template>
<div v-show="active === name">
<slot />
</div>
</template>
使用方:
复制代码<Tabs model-value="profile">
<Tab name="profile" label="基本资料">
<UserProfile />
</Tab>
<Tab name="security" label="安全设置">
<Security />
</Tab>
</Tabs>
<Tab> 不需要知道 <Tabs> 的内部结构,注册/注销都通过上下文调用。比起 <Tabs :tabs="..."> 的"声明式" props 配置,组合式 + provide/inject 更接近"插件化"的思维方式。
复制代码// 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('<Step*> 必须在 <Stepper> 内使用')
return ctx
}
复制代码<!-- Stepper.vue -->
<script setup lang="ts">
import { useProvideStepperContext } from './useStepperContext'
const props = defineProps<{ steps: number }>()
useProvideStepperContext(props.steps)
</script><template>
<slot />
</template>
复制代码<!-- StepNav.vue:放在表单底部当导航 -->
<script setup lang="ts">
import { useStepperContext } from './useStepperContext'
const { isFirst, isLast, next, prev } = useStepperContext()
</script><template>
<div class="step-nav">
<button :disabled="isFirst" @click="prev">上一步</button>
<button :disabled="isLast" @click="next">下一步</button>
</div>
</template>
跟前面的 Tab 同理,<StepNav> 完全不需要 props,它从上下文里自取所需。重点是这种状态绝对不该上 Pinia——它跟当前 <Stepper> 实例强绑定,全局化反而是负担。
复制代码// useSelectionContext.ts
import { computed, reactive } from 'vue'
import { createInjectionState } from '@vueuse/core'interface SelectionContextOptions<T> {
itemKey: (item: T) => string
}const [useProvideSelectionContext, useSelectionContextRaw] =
createInjectionState(<T,>(options: SelectionContextOptions<T>) => {
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<T>() {
const ctx = useSelectionContextRaw()
if (!ctx) throw new Error('请在 <Table> 内使用')
return ctx as ReturnType<typeof useSelectionContextRaw> & {} // 类型见下文
}
复制代码<!-- DataTable.vue -->
<script setup lang="ts" generic="T">
import { useProvideSelectionContext } from './useSelectionContext'const props = defineProps<{
data: T[]
itemKey: (item: T) => string
}>()const { selected, isSelected, toggle, selectAll, clear, count } =
useProvideSelectionContext({ itemKey: props.itemKey })
</script><template>
<div>
<div v-if="count" class="batch-bar">
已选 {{ count }} 条
<button @click="clear">取消</button>
<slot name="batch-actions" :selected-keys="[...selected]" />
</div> <table>
<thead>
<tr>
<th>
<input
type="checkbox"
:checked="count === data.length && count > 0"
@change="count === data.length ? clear() : selectAll(data)"
/>
</th>
<slot name="header" />
</tr>
</thead>
<tbody>
<tr v-for="item in data" :key="itemKey(item)">
<td>
<input type="checkbox" :checked="isSelected(item)" @change="toggle(item)" />
</td>
<slot name="row" :item="item" />
</tr>
</tbody>
</table>
</div>
</template>
表头里的"全选"复选框、行里的"单选"、批量操作栏里的"已选 N 条"——三处分别在三个不同的 slot 里渲染,但都基于同一个选中状态。这是 provide/inject 模式的天然战场。
每隔半年都会出现"Pinia 和 provide/inject 选哪个"的争论。createInjectionState 的位置其实非常清楚——它是 provide/inject 的现代写法,而不是 Pinia 的替代品。
| 方案 | 作用范围 | 实例隔离 | 类型推导 | SSR 安全 | 适合的场景 |
|---|---|---|---|---|---|
| props 透传 | 父子直接 | 每实例独立 | 一两层、字段有限 | ||
createInjectionState | 子树 | 每个 provider 子树独立 | 完整推导 | 表单/Tab/Stepper/表格行选等"组件族" | |
createSharedComposable | 整个客户端 | 所有调用共享同一份 | SSR 自动降级为非共享 | 全局鼠标位置、滚动状态等"单例资源" | |
createGlobalState | 整个客户端 | 所有调用共享 | 同上 | 与 SharedComposable 类似,更轻量 | |
| Pinia | 应用级 | 单例 store | 一流 | (带 hydration) | 用户身份、购物车、跨路由的业务状态 |
几条选型直觉:
createInjectionState。<Form> 卸载,所有内部字段的状态一起被回收,自然且安全。createInjectionState。同一个页面里两个 <Tabs>,绝不能共享 active tab;如果你用 Pinia 反而要给每个 store 加 id,繁琐。createSharedComposable。比如 useMouse() 想全局只注册一个 mousemove 监听,加 createSharedComposable 包一层即可。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。
跟 createReusableTemplate 一样,使用顺序是硬约束:
复制代码<!-- 会得到 undefined -->
<App>
<UseSomething /> <!-- 这里 inject 不到 -->
<Provider /> <!-- provider 还没出现 -->
</App><!-- -->
<App>
<Provider>
<UseSomething />
</Provider>
</App>
写工具组件时养成习惯:永远封装一个 useXxxOrThrow,让缺失 provider 时直接报错而不是静默失败。这条建议来自官方文档。
如果某个组件的 setup 是 async 的(比如里面有 top-level await),需要确认 useXxxState() 调用发生在第一个 await 之前,否则会丢失当前组件实例:
复制代码<script setup lang="ts">
// 在 await 之前 inject
const ctx = useTabsContext()const data = await fetchData()// 在 await 之后 inject
// const ctx = useTabsContext() // 此时 currentInstance 已经变了
</script>
这是 Vue 3 inject 自身的限制,不是 VueUse 的特殊行为。
createInjectionState 内部用 onMounted 等 hooks 期望它跟 consumer 绑定composable 函数体是在 provider 组件的 setup 阶段执行的:
复制代码const [useProvideXxx] = createInjectionState(() => {
onMounted(() => {
// 这是 provider 组件的 onMounted,不是 consumer
})
return { /* ... */ }
})
这跟普通 composable 的行为一致,但因为 createInjectionState 的"消费"动作是延迟到后代组件的,新手容易误以为生命周期会跟着 consumer 走。记住:composable 体在 useProvideXxx() 被调用的那一刻执行,且只执行一次。
如果在祖先链上多次调用同一个 useProvideXxx,inject 拿到的是最近的祖先那一份。这是 Vue 原生行为,可以利用它做"覆盖式上下文"(比如某个区域的表单禁用规则覆盖父级),但也容易意外。建议用 DevTools 的 Provided / Injected 面板检查实际拿到的是哪一份。
windowcreateInjectionState 本身 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 类型推到极致。
可以这样记忆它的定位:
createInjectionState。createInjectionState,不要轻易上 Pinia。createSharedComposable。它的 API 设计——[useProvideXxx, useXxxState] 配对——是把"抽象成 store 又怕过度工程"的灰色地带做了精确的填补:你既不必手写 InjectionKey 三件套,也不必引入完整的 store 库。该有类型有类型,该有错误处理有错误处理,该轻量轻量,跟 VueUse 一贯的"够用就好"哲学一致。
下次再写跨层级状态共享,先想想:这状态真的需要 Pinia 吗?还是它只跟某个父组件相关? 如果是后者,createInjectionState 应该是你的第一反应。
参考资料:
createInjectionState 官方文档:vueuse.org/shared/crea…createSharedComposable 官方文档:vueuse.org/shared/crea…