Скажу честно, когда я работал с Vue, мне доводилось использовать provide
и inject
буквально пару раз - и то просто чтобы обойти ограничения архитектуры проекта. Однако, столкнувшись с Angular, я увидел, что DI в фронтенде - это не только костыль или приблуда для любителей оверинженеринга, но и вполне себе рабочий паттерн, который позволяет создавать гибкие компоненты с удобным API. Так ли это в случае с Vue? Чтобы проверить гипотезу, я попытался отрефакторить с помощью provide/inject
один из проблемных компонентов, который некогда вышел из-под моего пера моей клавиатуры
Сам компонент довольно прост. Назовем его Field
. На вход он принимает два флага: loading
и readonly
. Соответственно, в случае когда loading=true
, отображается анимация загрузки. А когда readonly=true
, то отображаемый текст становится доступным только для чтения
<template>
<div>
<input
v-if="!props.loading && !props.readonly"
:value="props.modelValue"
@input="emit('update:modelValue', props.modelValue)"
/>
<div v-else-if="props.loading">
<span class="placeholder">Loading...</span>
</div>
<div v-else-if="props.readonly">
<label class="readonly-label">{{ modelValue }}</label>
</div>
</div>
</template>
Изначально этот компонент использовался в форме, где в общем-то не было много полей и прокидывание флагов несколько раз не то чтобы мозолило глаза. Думаю, поэтому это чудо и прошло код-ревью...
<form>
<Field v-model="field1" :loading="loading" :readonly="readonly" />
<Field v-model="field2" :loading="loading" :readonly="readonly" />
</form>
Но затем Field стал использоваться повсеместно и к тому же обрастать новыми возможностями: вместо простого input'а внутри уже был прокинут слот для кастомного контрола, в свойствах появился ещё ряд всяческих флагов для новых примочек, да и применялся сей компонент уже на довольно сложных формах с дюжинами полей. Ниже один из примеров того, что можно было увидеть в нашей кодовой базе
<form>
<div>
<Field :label="label1" :readonly="readonly" :loading="loading">
<input v-model="field1" type="number" />
</Field>
</div>
<div>
<Field :label="label2" :readonly="readonly" :loading="loading" nullable>
<input v-model="field2" type="text" />
</Field>
<Field :label="label3" :readonly="readonly" :loading="loading" nullable>
<input v-model="field3" type="text" />
</Field>
<Field :label="label4" :readonly="readonly" :loading="loading" nullable>
<input v-model="field4" type="text" />
</Field>
</div>
</form>

Определенно этот компонент просит рефакторинга. Самое очевидное, что возможно сделать - сунуть все флаги в конфиг и прокидывать уже его. Это решит проблему с визуальной захламленностью и может быть даже предотвратит пару-тройку багов по невнимательности. Но... что если я скажу, что можно облегчить себе жизнь и вовсе не передавать каждый раз флаги в Field ни в каком виде?
<FieldSet :readonly="readonly" :loading="loading">
<Field :label="label1">
<input v-model="field1" type="text" />
</Field>
<Field :label="label2">
<input v-model="field3" type="text" />
</Field>
<Field :label="label3">
<input v-model="field4" type="text" />
</Field>
<Field :label="label4">
<input v-model="field4" type="text" />
</Field>
</FieldSet>
Больше никакого мусора! А вся магия заключается лишь в том, что новый компонент FieldSet
получает из свойств все необходимые параметры и провайдит их в дерево дочерних элементов:
const props = defineProps({
loading: Boolean,
readonly: Boolean,
});
provide('loading', props.loading);
provide('readonly', props.readonly);
После чего Field
, являющийся как раз-таки дочерним элементом для FieldSet
, получит необходимые параметры через инъекцию зависимостей:
const readonly = inject('readonly');
const loading = inject('loading');
Лишь только прочитав официальную документацию, складывается впечатление о provide
и inject
как о фиче для замены множественного прокидывания пропсов глубоко внутрь. Эдакий костыль, когда мы должны перехватить некий параметр из компонента глубоко внутри дерева, но не хотим создавать кучу пропсов во всей цепочке из компонентов выше (это если честно больше смахивает на антипаттерн в обоих случаях). Кто мог подумать что реальное применение DI окажется несколько иным?
Неужели скрытая жемчужина?
В примере выше был использован DI для передачи контекста вглубь компонента. Это был довольно примитивный пример, потому что в нашем случае все параметры буквально лежали на поверхности (мы лишь только немного их "утопили"). На практике это куда более мощный паттерн, который позволяет инкапсулировать логику работы "составного" компонента без захламления клиентского кода. Давайте рассмотрим реальные примеры
Инъекция зависимостей активно используется в библиотеке Vuetify. Например, при указании темы в корневом компоненте. Дочерние элементы узнают о стилях, которые они должны применить, через механизм provide/inject
. Это существенно разгружает API. Стоит только представить, как выглядел бы код, будь разработчик вынужден отдельно указывать тему для каждого Vuetify-компонента. Чем-то напоминает наш кейс
<v-app :theme="theme">
<v-btn text="theme инъецирована в этот компонент" />
</v-app>
Ещё один интересный пример использования DI я обнаружил в другой библиотеке - NativeUI. Здесь механизм provide/inject
используется для того, чтобы шэрить состояние главного компонента с дочерними. При этом наружу ничего не торчит. И для клиентского кода всё, что происходит внутри - чистая магия, с приятным API опять же
<n-tabs type="segment" animated>
<n-tab-pane name="oasis" tab="Oasis">
Wonderwall
</n-tab-pane>
<n-tab-pane name="the beatles" tab="the Beatles">
Hey Jude
</n-tab-pane>
</n-tabs>
И вот казалось бы, что "DI в Vue confirmed" - тему можно закрывать. Но есть один недостаток, который лично меня очень сильно смущает. Для того, чтобы внедрить зависимость, требуется либо вызывать provide
из клиентского кода, либо писать компоненты-обертки, которые могут захламлять код. Нет удобного способа внедрить зависимость как в случае с директивами в Angular: в Vue есть похожий инструмент, но он не позволяет использовать DI. Это сильно срезает количество кейсов, где provide/inject
выглядело бы выигрышно
Кроме того, если Angular пытается в ООП, то Vue целиком зиждется на функционально-реактивном подходе. Это значит, что в первом случае скорее всего будут инъецироваться сервисы или их моки, а во втором - DI будет использоваться непосредственно для передачи значений. Эту тенденцию можно наблюдать во всех примерах выше. Из чего напрашивается заключение, что DI-паттерны всё же не очень хорошо себя чувствуют в контексте Vue
Ну и не стоит забывать, что DI - сам по себе относительно сложный инструмент. Я имею в виду, что он ведет к усложнению кода. Даже там, где царит парадигма ООП, неразумное применение DI может завести разработку всего приложения в тупик
Выводы
Многие недовольны тем, что provide/inject
делают передачу данных неявной, вешая на DI в Vue клеймо антипаттерна. Ирония в том, что существуют ситуации, когда мы нарочно хотим скрыть поток данных. И в этих кейсах provide/inject
работают как надо: позволяют выстраивать очень удобные интерфейсы, спрятав ненужную сложность внутрь DI контейнера. Это распространенный паттерн при построении библиотек компонентов. Думаю, стоит как минимум держать на вооружении возможность Vue инъецировать зависимости. Возможно, однажды это спасет чистоту вашей кодовой базы
Правда, другие кейсы с использованием DI в Vue сложно придумать, ввиду риска ненарочного оверинжеринга. Скорее всего большинство задач можно легко решить и через другие инструменты, вроде пропсов или слотов (или использования стора в конце концов), которые будут куда привычней в применении. Возможно, я недостаточно изучил матчасть при написании статьи, и существуют и другие ситуации, когда provide/inject
демонстрируют себя во всей красе - было бы интересно почитать в комментариях