Provide/inject у Vue.js
provide/inject - механізм у Vue.js для передачі даних від батьківського компонента до будь-якого нащадка без явного прокидання через проміжні компоненти як пропси.
Теорія
TL;DR
- Аналогія: як спільний локер в офісі - батько кладе щось всередину (provide), будь-який нащадок бере за ключем (inject), ніхто посередині нічого не чіпає
- Головна різниця: пропси вимагають від кожного проміжного компонента явно приймати і передавати дані; provide/inject оминає їх повністю
- Реактивність працює: якщо надати
ref, нащадки отримають той самий об'єкт ref, а не копію - зміни поширюються автоматично - Правило вибору: provide/inject для теми, авторизації, локалі, конфігурації; пропси для прямого зв'язку батько-дитина; Pinia для загального стану застосунку
Швидкий приклад
// App.vue - батьківський компонент
<script setup>
import { provide, ref } from 'vue'
const user = ref({ name: 'Alice', role: 'admin' })
provide('currentUser', user) // доступно для ВСІХ нащадків
</script>
// DeepChild.vue - може бути на 10 рівнів глибше, жодних проміжних пропсів
<script setup>
import { inject } from 'vue'
const user = inject('currentUser', ref({ name: 'Guest' })) // другий аргумент = дефолт
console.log(user.value.name) // 'Alice'
</script>Нащадок отримує той самий об'єкт ref, а не копію. Змінюєш в одному місці - всі передплатники оновлюються.
Ключова різниця від пропсів
Пропси створюють явний ланцюжок: кожен компонент у дереві повинен оголосити пропс, прийняти і передати далі. При п'яти рівнях вкладеності це п'ять компонентів, які несуть дані яких самі не використовують. provide/inject розриває цей ланцюжок. Батько оголошує один раз; будь-який нащадок на будь-якій глибині отримує дані напряму. Проміжні компоненти залишаються чистими.
Коли що використовувати
- provide/inject: перемикання теми, стан авторизації, глобальна конфігурація, feature flags, локаль, спільні сервіси як API-клієнт
- Пропси: прямий зв'язок батько-дитина, дані яким потрібні тільки 1-2 рівні, коли важлива явна і прозора передача
- Pinia: загальний стан застосунку, який потребує devtools, time-travel debugging або складних мутацій
Як це працює всередині
Vue підтримує окремий provide-ланцюжок для кожного екземпляра компонента. Коли компонент викликає provide(key, value), Vue зберігає значення в цьому екземплярі. Коли нащадок викликає inject(key), Vue йде вгору по дереву і шукає найближчого предка, який надав цей ключ. Якщо два предки надають однаковий ключ, перемагає ближчий - це дозволяє перевизначати значення у піддеревах, наприклад для вкладеної теми. Якщо нічого не знайдено, inject повертає вказане дефолтне значення або undefined.
provide на рівні застосунку
Можна надавати значення на рівні всього застосунку - тоді воно буде доступне скрізь:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.provide('apiUrl', 'https://api.example.com')
app.mount('#app')// AnyComponent.vue
<script setup>
import { inject } from 'vue'
const apiUrl = inject('apiUrl') // 'https://api.example.com'
</script>Саме так Vue Router і Vuetify роблять свої внутрішні дані доступними без явних імпортів у кожен файл.
Символьні ключі
Рядкові ключі можуть конфліктувати, якщо різні частини коду випадково використовують однакову назву. Symbol вирішує це:
// keys.js
export const ThemeKey = Symbol('theme')
export const UserKey = Symbol('user')
// Parent.vue
import { ThemeKey, UserKey } from './keys'
provide(ThemeKey, theme)
provide(UserKey, user)
// Child.vue
import { ThemeKey } from './keys'
const theme = inject(ThemeKey) // помилки в рядковому ключі неможливіУ TypeScript пари Symbol з InjectionKey<T> дають повну типізацію:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const UserKey: InjectionKey<Ref<{ name: string }>> = Symbol('user')
// inject(UserKey) автоматично повертає Ref<{ name: string }> | undefinedТипові помилки
Помилка 1: передача значення замість ref
// Неправильно - передається знімок, а не реактивний ref
const count = ref(0)
provide('count', count.value) // number, а не Ref<number>
// Правильно
provide('count', count)Якщо передати count.value, нащадок отримає 0 як звичайне число. Подальші зміни до нього не дійдуть.
Помилка 2: пряма мутація даних у дочірньому компоненті
// Працює, але не рекомендується - незрозуміло хто власник даних
const config = inject('config')
config.value.apiUrl = 'https://new.api.com'
// Краще - разом з даними надати функцію оновлення
// У батьку:
const updateConfig = (key, value) => { config.value[key] = value }
provide('config', config)
provide('updateConfig', updateConfig)
// У дочірньому:
const updateConfig = inject('updateConfig')
updateConfig('apiUrl', 'https://new.api.com') // явно і прозороПомилка 3: відсутність дефолтного значення
// Якщо батько ніколи не надав 'user', це впаде в runtime
const user = inject('user')
console.log(user.value.name) // TypeError: Cannot read properties of undefined
// Безпечний варіант
const user = inject('user', ref({ name: 'Guest' }))Завжди додавай дефолт, коли батьківський компонент, що надає дані, може бути відсутній у дереві.
Помилка 4: очікування ізоляції замість спільного посилання
// Батько
const count = ref(0)
provide('count', count)
// Child A
const count = inject('count')
count.value++ // збільшує спільний ref
// Child B
const count = inject('count')
console.log(count.value) // 1, а не 0 - той самий об'єктЦе правильна поведінка, але вона дивує тих, хто очікує копію. Якщо потрібна ізоляція, надай окремі ref-и.
Помилка 5: циклічні provide-ланцюжки
Якщо дочірній компонент повторно надає дані, які він отримав від батька, а онук отримує від обох, виникає прихована залежність. Зміна структури дерева ламає онука без явної помилки. Тримай provide/inject-ланцюжки лінійними.
Де зустрічається в реальних проєктах
- Vue Router надає метадані маршруту вкладеним компонентам - саме так працює
useRoute()всередині - Vuetify надає конфігурацію теми всім дочірнім компонентам без prop drilling
- Vee-Validate - батьківська форма надає контекст валідації (validation context) кожному вкладеному полю
- Pinia всередині використовує provide/inject щоб зробити сторінки доступними по всьому застосунку
Найчастіше я використовував provide/inject для контексту авторизації - надаючи поточного користувача і функцію виходу на рівні застосунку, щоб будь-який компонент міг їх отримати без пропсів.
Питання на співбесіді
Q: Що відбувається, якщо два предки надають однаковий ключ?
A: Перемагає найближчий предок. Нащадки не можуть отримати значення від вищих предків в обхід ближчого. Це дозволяє перевизначати значення у піддеревах, наприклад надати іншу тему всередині модального вікна.
Q: Яка різниця між provide/inject і Pinia?
A: provide/inject обмежений підгіллям компонентного дерева. Pinia - це глобальні синглтони зі підтримкою devtools, time-travel debugging і структурованими патернами мутацій. Використовуй provide/inject для локального спільного контексту; Pinia - коли потрібен загальний стан по всьому застосунку.
Q: Що станеться, якщо inject не знайде відповідного provide?
A: inject повертає undefined або дефолтне значення, яке ти передав другим аргументом. Тому відсутність дефолту - часте джерело runtime-помилок.
Q: Як правильно типізувати provide/inject у TypeScript?
A: Використовуй InjectionKey<T> з Vue: const UserKey: InjectionKey<Ref<User>> = Symbol('user'). Тоді inject(UserKey) автоматично повертає Ref<User> | undefined. Додай дефолт щоб звузити тип до Ref<User>.
Q: (Senior) Як побудувати систему багаторівневих тем за допомогою provide/inject?
A: Кожен компонент-границя теми надає власний ключ теми. Дочірні компоненти отримують дані від найближчого предка. Оскільки Vue завжди вирішує до найближчого провайдера, вкладення компонента темної теми у світлий застосунок просто працює - він перекриває значення батька для всіх своїх нащадків.
Приклади
Система тем через provide/inject
// ThemeProvider.vue
<template>
<div :class="`theme-${theme}`">
<button @click="toggleTheme">
Переключити на {{ theme === 'dark' ? 'light' : 'dark' }}
</button>
<slot />
</div>
</template>
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
// Надаємо і стан, і функцію зміни
provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>// DeepButton.vue - 8 рівнів вкладеності, жодного prop drilling
<template>
<button @click="toggleTheme" :class="`btn-${theme}`">
Поточна тема: {{ theme }}
</button>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
// Клік оновлює тему глобально - всі нащадки перерендеряться
</script>Надання функції разом з даними зберігає логіку мутацій у компоненті, якому належить стан. Скільки б нащадків не викликало її - джерело правди одне.
Типобезпечне отримання через символьні ключі
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface User {
id: number
name: string
role: 'admin' | 'viewer'
}
export const UserKey: InjectionKey<Ref<User>> = Symbol('user')
// Parent.vue
import { provide, ref } from 'vue'
import { UserKey } from './keys'
const user = ref<User>({ id: 1, name: 'Alice', role: 'admin' })
provide(UserKey, user)
// Child.vue - TypeScript знає точну структуру даних
import { inject } from 'vue'
import { UserKey } from './keys'
const user = inject(UserKey) // Ref<User> | undefined
if (user) {
console.log(user.value.role) // TypeScript підтверджує що 'role' існує
}Рядкові ключі працюють, але при inject дають тип unknown. Symbol з InjectionKey<T> дають повний тип і виловлюють помилки імпорту на етапі компіляції, до запуску коду.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.