Перейти к содержимому

13. Provide / Inject

Imagine у тебя компонент AppLayoutSidebarUserAvatar. Хочешь передать данные пользователя в UserAvatar. Прокидывать через все 3 уровня через props — это prop drilling, и это больно 😩

provide / inject — это как “телепорт для данных”. Родитель делает provide, а любой потомок на любой глубине делает inject и получает данные. Никаких цепочек пропсов! 🚀


<!-- Родительский компонент (или App.vue) -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const user = ref({ name: 'Яша', role: 'admin' })
const theme = ref<'light' | 'dark'>('dark')
// Делаем данные доступными для всех потомков
provide('user', user)
provide('theme', theme)
provide('appVersion', '1.0.0') // Можно и простые значения
</script>
<!-- Дочерний компонент (на любой глубине!) -->
<script setup lang="ts">
import { inject, ref } from 'vue'
// Получаем данные от предка
const user = inject('user') // Тип: unknown 😢
const theme = inject('theme')
const version = inject('appVersion')
// С дефолтным значением (если provide не было)
const locale = inject('locale', 'ru')
</script>
<template>
<div>Привет, {{ user?.name }}!</div>
</template>

⚠️ Проблема: inject('user') возвращает тип unknown. Нет TypeScript поддержки! Для этого есть InjectionKey.


InjectionKey<T> — это специальный Symbol с дженериком для типобезопасного provide/inject:

// keys.ts — общие ключи для всего приложения
import type { InjectionKey, Ref } from 'vue'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user' | 'guest'
}
interface Theme {
mode: 'light' | 'dark'
primary: string
}
// Символ-ключ с типом!
export const UserKey = Symbol() as InjectionKey<Ref<User>>
export const ThemeKey = Symbol() as InjectionKey<Ref<Theme>>
export const LocaleKey = Symbol() as InjectionKey<Ref<string>>
export const RouterKey = Symbol() as InjectionKey<ReturnType<typeof useRouter>>
<!-- Провайдер с типами -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { UserKey, ThemeKey } from '@/keys'
const user = ref<User>({
id: 1,
name: 'Яша',
role: 'admin'
})
const theme = ref<Theme>({
mode: 'dark',
primary: '#42b883'
})
provide(UserKey, user) // TypeScript знает тип!
provide(ThemeKey, theme)
</script>
<!-- Потребитель с типами -->
<script setup lang="ts">
import { inject } from 'vue'
import { UserKey, ThemeKey } from '@/keys'
// user имеет тип Ref<User> — автодополнение работает! 🎉
const user = inject(UserKey)
const theme = inject(ThemeKey)
// С обязательным дефолтным значением
const locale = inject(LocaleKey, ref('ru'))
// Если значение гарантированно есть — используй !
// Но лучше всегда давать default
</script>
<template>
<div :style="{ color: theme?.primary }">
{{ user?.name }} ({{ user?.role }})
</div>
</template>

Можно делать provide на уровне всего приложения — идеально для глобальных сервисов:

main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { ApiService } from './services/api'
import { I18n } from './i18n'
const app = createApp(App)
// Глобальный provide — доступен везде в приложении
app.provide('apiService', new ApiService())
app.provide('i18n', new I18n('ru'))
app.provide('featureFlags', {
darkMode: true,
betaFeatures: false,
})
app.mount('#app')

Если передаёшь ref или reactive — изменения автоматически отразятся у всех потребителей:

ThemeProvider.vue
<script setup lang="ts">
import { provide, ref, computed } from 'vue'
import type { InjectionKey, Ref } from 'vue'
interface ThemeContext {
isDark: Ref<boolean>
toggleTheme: () => void
primary: Ref<string>
}
export const ThemeContextKey = Symbol() as InjectionKey<ThemeContext>
const isDark = ref(true)
const primary = computed(() => isDark.value ? '#42b883' : '#2196f3')
// Предоставляем как данные, так и методы!
provide(ThemeContextKey, {
isDark,
toggleTheme: () => { isDark.value = !isDark.value },
primary,
})
</script>
<template>
<div :class="isDark ? 'dark' : 'light'">
<slot />
</div>
</template>
<!-- Кнопка где-то глубоко в дереве -->
<script setup lang="ts">
import { inject } from 'vue'
import { ThemeContextKey } from './ThemeProvider.vue'
const theme = inject(ThemeContextKey)
// theme?.isDark — реактивно!
// При смене темы компонент автоматически обновится
</script>
<template>
<button
:style="{ background: theme?.primary.value }"
@click="theme?.toggleTheme()"
>
{{ theme?.isDark.value ? '🌙 Тёмная' : '☀️ Светлая' }}
</button>
</template>

Хорошей практикой является запрет мутаций из дочерних компонентов. Дочерние должны только читать, а изменения — через методы от родителя:

<script setup lang="ts">
import { provide, ref, readonly } from 'vue'
const count = ref(0)
provide('count', readonly(count)) // Дочерние могут только читать!
provide('increment', () => count.value++) // Мутация только через метод
provide('decrement', () => count.value--)
</script>
<!-- Дочерний компонент -->
<script setup lang="ts">
import { inject } from 'vue'
const count = inject<Readonly<Ref<number>>>('count')
const increment = inject<() => void>('increment')
const decrement = inject<() => void>('decrement')
</script>
<template>
<div>
<button @click="decrement">−</button>
{{ count }}
<button @click="increment">+</button>
</div>
</template>

Паттерн provide/inject очень популярен для составных компонентов форм:

Form.vue
<script setup lang="ts">
import { provide, reactive, ref } from 'vue'
import type { InjectionKey } from 'vue'
interface FormField {
value: unknown
error: string | null
touched: boolean
}
interface FormContext {
registerField: (name: string) => void
getField: (name: string) => FormField | undefined
setError: (name: string, error: string | null) => void
submitting: Ref<boolean>
}
export const FormKey = Symbol() as InjectionKey<FormContext>
const fields = reactive<Record<string, FormField>>({})
const submitting = ref(false)
provide(FormKey, {
registerField(name) {
if (!fields[name]) {
fields[name] = { value: '', error: null, touched: false }
}
},
getField: (name) => fields[name],
setError: (name, error) => {
if (fields[name]) fields[name].error = error
},
submitting,
})
async function handleSubmit() {
submitting.value = true
// Валидация...
submitting.value = false
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<slot />
</form>
</template>
<!-- FormInput.vue — использует контекст формы -->
<script setup lang="ts">
import { inject, onMounted } from 'vue'
import { FormKey } from './Form.vue'
const props = defineProps<{
name: string
label: string
}>()
const form = inject(FormKey)
onMounted(() => {
form?.registerField(props.name)
})
const field = computed(() => form?.getField(props.name))
</script>
<template>
<div class="field">
<label>{{ label }}</label>
<input
:value="field?.value as string"
@input="/* обновить значение */"
:class="{ error: field?.error }"
/>
<span v-if="field?.error" class="error">{{ field.error }}</span>
</div>
</template>

┌─────────────────┬──────────────────────────────────────────────────┐
│ Ситуация │ Решение │
├─────────────────┼──────────────────────────────────────────────────┤
│ Данные между │ Props — прямой и понятный способ │
│ родитель-ребёнок│ │
├─────────────────┼──────────────────────────────────────────────────┤
│ Глубокое дерево │ Provide/Inject — избегаем prop drilling │
│ компонентов │ │
├─────────────────┼──────────────────────────────────────────────────┤
│ Составные │ Provide/Inject — Form+Input, Tabs+Tab, │
│ компоненты │ Accordion+Item паттерны │
├─────────────────┼──────────────────────────────────────────────────┤
│ Глобальное │ Pinia — управление состоянием, │
│ состояние │ девтулс, SSR поддержка │
├─────────────────┼──────────────────────────────────────────────────┤
│ Сервисы │ app.provide() — ApiService, i18n, router │
│ (singleton) │ │
└─────────────────┴──────────────────────────────────────────────────┘