Скажу честно, когда я работал с 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>

Изначально этот компонент использовался в форме, где в общем-то не было много полей, и прокидывание флагов несколько раз не то чтобы мозолило глаза. Думаю, поэтому это чудо и прошло код-ревью. Но вскоре Field стал реюзаться где только ни попадя (не без моей инициативы), а также обрастать дополнительными пропсами, и его проблемность стала очевидной. Ниже один из примеров того, как это примерно выглядело

<form>
  <Field v-model="field1" :readonly="readonly" :loading="loading" nullable collapsable />
  <Field v-model="field2" :readonly="readonly" :loading="loading" nullable collapsable />
  <!-- ... -->
  <Field v-model="field10" :readonly="readonly" :loading="loading" nullable collapsable />
  <Field v-model="field11" :readonly="readonly" :loading="loading" nullable collapsable />
</form>
Из раза в раз прокидывать loading и readonly в двадцати разных компонентах по двадцать раз на каждый - занятие не самое приятное, даже если вооружиться ИИ. Ещё и чреватое ошибками
Из раза в раз прокидывать loading и readonly в двадцати разных компонентах по двадцать раз на каждый - занятие не самое приятное, даже если вооружиться ИИ. Ещё и чреватое ошибками

Магия provide/inject

Определенно Field просит рефакторинга. Самое очевидное, что возможно сделать - сунуть все флаги в конфиг и прокидывать уже его. Это решит проблему с визуальной захламленностью и может быть даже предотвратит пару-тройку багов по невнимательности. Но... что если я скажу, что можно облегчить себе жизнь и вовсе не передавать каждый раз флаги в Field ни в каком виде?

<FieldSet :readonly="readonly" :loading="loading" nullable collapsable>
  <Field v-model="field1" />
  <Field v-model="field2" />
  <!-- ... -->
  <Field v-model="field10" />
  <Field v-model="field11" />
</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 - присвоение темы

Инъекция зависимостей активно используется в библиотеке Vuetify. Например, при указании темы в корневом компоненте. Дочерние элементы узнают о стилях, которые они должны применить, через механизм provide/inject. Это существенно разгружает API. Стоит только представить, как выглядел бы код, будь разработчик вынужден отдельно указывать тему для каждого Vuetify-компонента. Чем-то напоминает наш кейс

<v-app :theme="theme">
  <v-btn text="theme инъецирована в этот компонент" />
</v-app>

NativeUI - компонент вкладок

Ещё один интересный пример использования 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 не прижился в большинстве проектов?

И вот казалось бы, что "DI в Vue confirmed" - тему можно закрывать. Но есть один недостаток, который лично меня очень сильно смущает. Для того, чтобы внедрить зависимость, требуется либо вызывать provide из клиентского кода, либо писать компоненты-обертки, которые могут захламлять код. Нет удобного способа внедрить зависимость как, допустим, в случае с директивами в Angular: в Vue есть похожий инструмент, но он не позволяет использовать DI. Это сильно срезает количество кейсов, где provide/inject выглядело бы выигрышно

Кроме того, если Angular пытается в ООП, то Vue целиком зиждется на функционально-реактивном подходе. Это значит, что в первом случае скорее всего будут инъецироваться сервисы или их моки, а во втором - DI будет использоваться непосредственно для передачи значений. Эту тенденцию можно наблюдать во всех примерах выше. Из чего напрашивается заключение, что DI-паттерны всё же не очень хорошо себя чувствуют в контексте Vue

Ну и не стоит забывать, что DI - сам по себе относительно сложный инструмент. Я имею в виду, что он ведет к усложнению кода. Даже там, где царит парадигма ООП, неразумное применение DI может завести разработку всего приложения в тупик

Выводы

Многие недовольны тем, что provide/inject делают передачу данных неявной, вешая на DI в Vue клеймо антипаттерна. Ирония в том, что существуют ситуации, когда мы нарочно хотим скрыть поток данных. И в этих кейсах provide/inject работают как надо: позволяют выстраивать очень удобные интерфейсы, спрятав ненужную сложность внутрь DI контейнера. Это распространенный паттерн при построении библиотек компонентов. Думаю, стоит как минимум держать на вооружении возможность Vue инъецировать зависимости. Возможно, однажды это спасет чистоту вашей кодовой базы

Когда provide/inject хороши

  • Когда требуется прокинуть пропс для множества компонентов

    Пример: присвоение темы в Vuetify

    Почему круто: избавляет от огромного количества повторяющегося кода; снижает риск ошибки по невнимательности

  • Когда нужно инкапсулировать взаимодействие компонентов между собой

    Пример: панель вкладок в NativeUI

    Почему круто: упрощает интерфейс компонента; не дает "испортить" его стейт

Правда, другие кейсы с использованием DI в Vue сложно придумать, ввиду риска создания хрупкого решения. Скорее всего с большинством задач можно легко справиться и через другие инструменты, вроде пропсов или слотов (или использования стора в конце концов), которые будут куда безопаснее в применении. Возможно, я недостаточно изучил матчасть при написании статьи, и существуют и другие ситуации, когда provide/inject демонстрируют себя во всей красе - было бы интересно почитать в комментариях

Когда provide/inject могут оказаться не в тему

  • Нужно просто доставить данные до конкретного элемента

    Почему: риск написания хрупкого решения

    Как решить: подумайте, а обязательно ли провайдящий элемент будет корнем для получающего? Один ли такой компонент на странице? Возможно, лучше явно указать props'ы, или это вовсе кейс для pinia

  • Нужно "связать" несколько компонентов

    Почему: риск написания негибкого решения

    Как решить: подумайте, действительно ли нужна инкапсуляция в этом случае или клиентский код всё же может хотеть знать о прокидываемых данных?

Комментарии (0)