provide/inject: внедрение зависимостей
Урок про внедрение зависимостей во Vue: как протащить данные сквозь любое число уровней без props drilling, как типизировать связь через InjectionKey и когда inject уместнее пропсов.
provide/inject — механизм Vue, которым предок объявляет значение (
provide), а любой потомок на любой глубине получает его (inject) без передачи через каждый промежуточный компонент.
Пропсы — основной способ передачи данных вниз, но у них есть болезненный сценарий. Представьте дерево: App знает тему оформления, а нужна она глубоко внизу — в кнопке внутри карточки внутри списка внутри страницы. С пропсами тему пришлось бы передавать через каждый промежуточный компонент, даже если им самим она не нужна. Это называют props drilling — «продалбливание пропсов». Код раздувается, промежуточные компоненты обрастают чужими пропсами, рефакторинг становится мучением.
provide/inject решает ровно эту проблему. Предок один раз «предоставляет» значение, а потомок на любой глубине его «внедряет» — промежуточные компоненты ничего не знают и не передают. В этом уроке разберём базовую передачу через уровни, типобезопасный вариант с InjectionKey, реактивный provide и главный вопрос: когда это лучше пропсов, а когда — нет.
Зачем это на практике
provide/inject — рабочая лошадка для «сквозных» вещей, которые нужны многим компонентам на разной глубине: текущая тема, локаль, данные авторизованного пользователя, конфигурация формы, экземпляр какого-то сервиса. Именно так устроены многие UI-библиотеки: <Form> предоставляет контекст валидации, а вложенные <Field> его внедряют, не получая длинной цепочки пропсов. Это позволяет строить компоненты, которые «просто работают» внутри своего родителя, договариваясь через общий контекст.
Базовая передача через уровни
Предок объявляет значение функцией provide(ключ, значение), потомок берёт его функцией inject(ключ). Ключом служит строка или Symbol.
<!-- App.vue (предок) -->
<script setup>
import { provide } from 'vue'
provide('theme', 'dark')
</script>
<template>
<Layout /> <!-- сколько угодно уровней вложенности ниже -->
</template>
<!-- ThemedButton.vue — глубоко внизу дерева -->
<script setup>
import { inject } from 'vue'
// второй аргумент — значение по умолчанию, если предок ничего не предоставил
const theme = inject('theme', 'light')
</script>
<template>
<button :class="theme">Кнопка темы «{{ theme }}»</button>
</template>
Сколько бы компонентов ни лежало между App и ThemedButton — они не участвуют в передаче. Второй аргумент inject — значение по умолчанию: оно подставится, если ни один предок не предоставил такой ключ (полезно, чтобы компонент работал и вне «своего» родителя).
Типизация через InjectionKey
У строкового ключа есть слабое место: TypeScript не знает, какой тип значения за ним стоит, и не свяжет provide с inject. Для типобезопасности Vue даёт обёртку InjectionKey<T> — это Symbol, несущий в себе тип значения.
<!-- keys.ts — общий модуль с ключами -->
import type { InjectionKey, Ref } from 'vue'
export interface UserCtx {
name: string
role: 'admin' | 'user'
}
// ключ «знает», что за ним лежит Ref<UserCtx>
export const userKey = Symbol() as InjectionKey<Ref<UserCtx>>
<!-- предок -->
import { provide, ref } from 'vue'
import { userKey } from './keys'
const user = ref({ name: 'Аня', role: 'admin' as const })
provide(userKey, user) // тип проверяется: должен быть Ref<UserCtx>
<!-- потомок -->
import { inject } from 'vue'
import { userKey } from './keys'
const user = inject(userKey) // тип выведен как Ref<UserCtx> | undefined
console.log(user?.value.name)
Теперь provide не даст положить значение неверного типа, а inject(userKey) вернёт точно типизированное значение (с | undefined, ведь предок мог и не предоставить). Ключ-Symbol заодно исключает случайные коллизии имён: два разных Symbol() не равны, даже если их «назвали» одинаково.
Реактивный provide
Если предоставить обычное значение (provide('count', 0)), потомок получит застывшее число — изменения не дойдут. Чтобы потомки реагировали на изменения, предоставляйте реактивный объект: ref, reactive или computed. Тогда связь становится «живой».
<!-- предок: предоставляем и состояние, и метод его менять -->
<script setup>
import { provide, ref, readonly } from 'vue'
const count = ref(0)
function increment() { count.value++ }
// отдаём readonly-состояние + контролируемый метод изменения
provide('counter', { count: readonly(count), increment })
</script>
<!-- потомок: реактивно читает count и вызывает increment -->
<script setup>
import { inject } from 'vue'
const { count, increment } = inject('counter')
</script>
<template>
<button @click="increment">Нажато: {{ count }}</button>
</template>
Когда потомок вызовет increment, изменится count.value в предке, и все потомки, читающие count, перерисуются. Важный приём: состояние отдают как readonly, а менять его разрешают только через предоставленный метод. Так данные остаются в одном месте (у предка), а потомки не могут испортить их напрямую — это держит поток данных предсказуемым.
Как это работает под капотом
У каждого экземпляра компонента есть внутренний объект provides. По умолчанию он прототипно наследуется от provides родителя — то есть образует цепочку до корня приложения. Когда компонент вызывает provide(key, value), Vue создаёт у него собственную копию provides и кладёт туда пару ключ-значение. Когда другой компонент вызывает inject(key), поиск идёт вверх по этой прототипной цепочке: от текущего компонента к родителю, к его родителю и так далее до корня — берётся первое найденное значение.
Отсюда вытекают два свойства. Во-первых, inject «видит» только предков, но не соседей и не потомков — это строго восходящий поиск. Во-вторых, ближний предок «перекрывает» дальнего: если и App, и промежуточный Layout предоставили один ключ, потомок получит значение от ближайшего — от Layout. Реактивность же — заслуга не самого механизма, а того, что вы кладёте в provide: положили ref — связь живая, положили примитив — застывшая.
Когда вместо пропсов — а когда нет
provide/inject заманчив, но это не замена пропсам, а инструмент для другого случая. Ориентир такой:
| Берите пропсы | Берите provide/inject |
| данные нужны прямому ребёнку (1–2 уровня) | данные нужны глубоко и многим (тема, локаль, юзер) |
| связь явная, видна в шаблоне | промежуточные компоненты не должны знать о данных |
| компонент переиспользуется в разных местах | компоненты тесно работают в паре (Form ↔ Field) |
Главный минус inject — неявность: глядя на компонент, не сразу видно, откуда он берёт inject('theme') и кто это предоставил. Пропсы в этом смысле честнее — связь объявлена в сигнатуре. Поэтому provide/inject хорош для «фонового контекста», а для обычной передачи данных между близкими компонентами оставайтесь на пропсах. Для глобального состояния всего приложения (а не локального контекста поддерева) обычно берут Pinia, а не provide на корне.
Частые ошибки
Предоставлять примитив и ждать реактивности. provide('count', count.value) отдаёт число — оно застынет. Чтобы связь была живой, предоставляйте сам ref/reactive, а не его текущее значение.
Менять предоставленное состояние из потомка напрямую. Тогда источник правды размывается по дереву. Отдавайте состояние как readonly, а изменения — через предоставленный метод, чтобы мутации шли из одного места.
Строковые ключи без префиксов. Два разных предка с ключом 'data' легко конфликтуют. В больших проектах используйте Symbol/InjectionKey — они уникальны и заодно дают типизацию.
Звать inject вне setup. inject работает только синхронно во время инициализации компонента (в setup или <script setup>). Вызов из колбэка, обработчика события или асинхронно — после await — не сработает.
Итоги
- provide/inject протаскивает данные от предка к потомку любой глубины, минуя промежуточные компоненты, — лекарство от props drilling.
- Предок зовёт
provide(key, value), потомок —inject(key, дефолт); поиск идёт строго вверх по дереву, ближний предок перекрывает дальнего. InjectionKey<T>(типизированный Symbol) связывает provide и inject по типу и исключает коллизии ключей.- Реактивность даёт не сам механизм, а то, что вы кладёте: предоставляйте
ref/reactive, отдавайте состояниеreadonlyи меняйте через предоставленный метод. - Берите inject для фонового контекста (тема, локаль, юзер); для близких компонентов — пропсы, для глобального состояния — Pinia.