Vue3 прекрасный фреймворк, писать на котором одно удовольствие. Кто не пробовал - попробуйте, а кто юзает - наслаждайтесь. Сегодня хотелось бы написать о такой возможности, как передача данных в дочерние компоненты, с помощью provide-inject механизма. Конечно, всё познаётся в сравнении, поэтому затронем так же props.

И то, и другое - способ передачи данных вниз по дереву компонентов.

Почти в 100% приложений встречаются подобные кейсы, и не единожды
Почти в 100% приложений встречаются подобные кейсы, и не единожды

Передать данные из родителя в "потомок дочернего компонента" можно несколькими способами:
1. С помощью props.
Во входящий props дочернего компонента помещается объект, который мы хотим передать из родителя.

ParentComponent.vue

<template>
  <child-component :data="someData" v-for="someData in allData" :key="someData.id"/>
</template>
ChildComponent.vue

<script setup lang="ts">
  
  interface DataTypes {...}
  
  const props = defineProps<{
    data: DataTypes
  }>()

</script>
<template>
  <div>{{ props.data }}</div>
</template>

Простой и понятный код - в ходе итерации по массиву данных в компоненте ParentComponent.vue мы создаём однотипные элементы шаблона html (<child-component>) и передаём в каждый шаблон свой элемент для отрисовки. Соответственно в каждом child-component мы этот элемент принимаем как props и отрисовываем в html.

Но что, если у ChildComponent есть свой потомок?

ChildComponent.vue

<script setup lang="ts">
  
  interface DataTypes {...}
  
  const props = defineProps<{
    data: DataTypes
  }>()

</script>
<template>
  <div>{{ props.data }}</div>
  <child-of-child-component :item="props.data">
</template>

В 14 строке я добавил дочерний компонент для нашего ChildComponent.vue и передал ему в качестве props объект, так же полученный пропсом из ParentComponent.vue.
Получилась своеобразная лесенка из данных.

Данная архитектура вполне приемлема и не должна вызвать проблем ни в поддержке, ни в читабельности.

Но... что, если ступеней будет больше? Насколько понятным станет код? А безопасность? Допустим, мы захотим изменить компонент, который в середине лестницы. Тогда все дочерние компоненты могут просто сломаться. Нам придётся учитывать при перепроектировании компонента эти условия, что само по себе уже есть ограничение и нарушение принципа одной ответственности - наш компонент не просто инкапсулирует какую то логику внутри себя, как в коробочке, но еще и передаёт данные в следующий компонент, не являясь исходным "отправителем" этой информации.

Допустим, в конечном компоненте нам нужно каким то образом повлиять на props ? Мы сделаем emit события вверх по цепочке компонента, перехватим это событие в родителе.. и повторим этот способ столько раз, сколько ступеней в нашей лестнице.

Развивать тему emit я не буду, но для полноты картины я посчитал нужным сделать эти пометки.

Итак, проблемы такой архитектуры.

  • Нарушение принципа одной ответственности. Я считаю это главным минусом. При перепроектировании или изменении компонента мы вынуждены учитывать дополнительные факторы, могущие повлиять на другие компоненты

  • Читабельность, безопасность и очевидность данных. Чтобы отследить источник данных, придётся несколько раз перемещаться по компонентам, параллельно отслеживая возможные emit на изменение этих данных.

Давайте рассмотрим частичную альтернативу этому:

2. С помощью provide-inject

Этот метод позволяет передать данные из родителя в любого по цепочке потомка, минуя промежуточные компоненты.

Передаём данные из родителя:

ParentComponent.vue

<script setup lang="ts">
  provide(key,someData)
</script>

И принимаем в том потомке, где они необходимы, минуя промежуточные:

ChildOfChildComponent.vue

<script setup lang="ts">
  const item = inject(key)
</script>

Не отходя далеко от кассы, поясню некоторые моменты, затем приведу код в приличный вид, дабы продолжить.

key — идентификатор передаваемых данных. В документации рекомендуется использовать для обозначения ключей тип Symbol. И я с этим полностью согласен — этот тип данных генерирует полностью уникальное значение, соответственно проблем со случайным «совпадением» ключей не будет.

От себя добавлю — называйте ключи так, чтобы было сразу понятно откуда приходят данные! Так намного читабельнее и очевиднее!

Давайте же вынесем ключ в файл injectKey.js

 injectKey.js

export const DATA_FROM_ParentComponent_VUE = Symbol()

Соответственно перепишем наш provide-inject:

ParentComponent.vue

<script setup lang="ts">
  provide(DATA_FROM_ParentComponent_VUE, data)
</script>
ChildOfChildComponent.vue

<script setup lang="ts">
  const item = inject(DATA_FROM_ParentComponent_VUE)
</script>

Отлично, теперь данные можно отобразить в шаблоне!

Но, что если мы захотим изменить полученные путём инжектирования данные? Как нам быть? Неужели опять строить лесенку из emit ?

Спешу вас обрадовать - есть гораздо более интересный способ это сделать! Секрет в том, что можно запровайдить реактивную переменную!

Так как данные, полученные путём inject не являются по факту пропсами, мы можем их менять прямо в том компоненте, где мы их получили. Дополню наш код:

ParentComponent.vue

<script setup lang="ts">
  const data: Ref<number> = ref(2)
  provide(DATA_FROM_ParentComponent_VUE, data)
</script>
ChildOfChildComponent.vue

<script setup lang="ts">
  const data: Ref<number>|undefined = inject(DATA_FROM_ParentComponent_VUE)
  function doubleData(){
    if(data){
      data.value *= 2
    }
  }
</script>
<template>
    <div @click="doubleItem">{{ data }}</div>
</template>

При таком использовании в родительском компоненте по названию ключа можно понять, куда именно передаются данные и где они, возможно, меняются.

Но что, если мы хотим ЯВНО в родителе указать функцию, которая должна выполняться с переданными данными? Хорошо, в этом случае можно в родителе объявить дополнительный provide !

ParentComponent.vue

<script setup lang="ts">
  const data: Ref<number> = ref(2)
  provide(DATA_FROM_ParentComponent_VUE, data)
  provide(CHANGE_DATA_FROM_ParentComponent_VUE, () => {
    data.value *= 2
})
</script>

В этом случае, в ЛЮБОМ дочернем компоненте, можно заинжектить функцию:

ChildOfChildComponent.vue / ChildComponent.vue

<script setup lang="ts">
  const double: (() => number)|undefined = inject(CHANGE_DATA_FROM_ParentComponent_VUE)
</script>
<template>
    <div @click="double">...</div>
</template>

Никаких emit, прямое изменение значения переменной прямо в дочернем компоненте

Подытожу: provide-inject является достаточно продуманным и хорошим вариантом обработки и передачи информации по дереву дочерних компонентов, при условии, когда альтернативные варианты невозможны, либо крайне затруднительны. Однако я бы не советовал увлекаться этим механизмом, и заменять им все подрят пропсы или эмиты. Этот механизм хорош в единичных случаях, когда НЕТ АЛЬТЕРНАТИВЫ. Просто представьте файл с ключами длинной в страницу, и вы поймёте о чем я.

Думаю, это всё что я хотел рассказать о provide-inject. Спасибо за внимание.

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


  1. FluffyArt
    28.10.2023 18:11
    +3

    Если требуется передать свойство, которое ещё и надо иметь возможность изменить, имхо, лучше его обернуть в ридонли и рядом прокинуть метод для изменения этого самого свойства


    1. lordwebkit
      28.10.2023 18:11
      -1

      Это же вью. В его сути реактивщина на уровне мутаций. В реакте да мы создаём useState где есть отдельный метод на изменение


  1. Alexandroppolus
    28.10.2023 18:11

    В документации рекомендуется использовать для обозначения ключей тип Symbol

    А как же тайпчекинг? В Реакте такой "ключ" создается через createContext и содержит в себе типизацию, например в случае const ctx = createContext<string>(''), во первых, нельзя воткнуть не строку в провайдер ctx, во вторых useContext(ctx) типизирует результат строкой.

    Не совсем понятно, как это сделать с симболом.


    1. modelair
      28.10.2023 18:11
      +1

      interface AppKeyData {
        name: string
      }
      const AppKey = Symbol('AppKey') as InjectionKey<UnwrapNestedRefs<AppKeyData>>
      


    1. zede
      28.10.2023 18:11

      Обычно я не рекомендую напрямую юзать provide / inject а сделать композабл (ака хук в реакте) и в нем типизировать значения относительно provide / inject. Если хочется, то сделать ~99%-ую компию по API контексту из реакта можно в строк 30-50


  1. ionicman
    28.10.2023 18:11
    +2

    Главная проблема в том, что подход "пропсы вниз - сообщения вверх" придуманы не просто так, а чтобы не распутывать потом клубок "кто-же где-же таки изменил эту переменную?!"

    По-этому, ИМХО (и собственно так в официальной документации) - инжектить любую переменную нужно через readonly вместе с методом, который может эту переменную изменять, и в компоненте не изменять данную переменную напрямую, а использовать метод. Это реально уберегает от очень многих головняков (хотя бы потому, что для простой отладки будет достаточно добавить в данный метод печать трейса, чтобы понять "кто-же где-же").

    Исключения, естественно, есть - очень тесно связанные компоненты и не более одного уровня (т.е. родитель-ребенок). Например табы (родительский холдер и его дети, собственно, сами табы). Но и там, чтобы не плодить "везде делаем так, а вот здесь можно не так" и поддерживать некоторую гомогенность системы, я бы все сделал через readonly + метод.


  1. Metotron0
    28.10.2023 18:11

    Во втором вуе они тоже есть, только там переменные теряют реактивность.

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


    1. Heggi
      28.10.2023 18:11

      Я прокидываю экземпляр класса с реактивными переменными, функциями и т.п. (у меня на каждую форму создается свой store, который и инжектится в дочерних компонентах)


  1. gmtd
    28.10.2023 18:11
    +1

    Лучше в 95% случаев использовать композаблы:

    1. Нет ограничений на требование предок-потомок

    2. Дополнительный уровень иерархичности:
      import { isLoggedIn } from "auth";
      auth - scope, аутентификация
      isLoggedIn - конкретная функциональность
      Сразу понятна семантика и файл, где искать isLoggedIn

      const data: = inject(isLoggedIn)
      Откуда inject??? В каком скоупе? Где его искать?

    3. Намного структурированней, чище и понятней код


    1. MaxRyazan Автор
      28.10.2023 18:11
      +1

      как раз для "откуда inject???" я специально выделил - НАЗЫВАЙТЕ КЛЮЧИ так, чтобы было понятно что и откуда.
      И я подчеркиваю, и не раз, что этот механизм не является ЗАМЕНОЙ пропсам, я лишь написал ГАЙД как его использовать с минимальными потерями читаемости и безопасности


      1. gmtd
        28.10.2023 18:11
        +1

        Ну-ка назовите в данном случае ключ чтобы было понятно что это, откуда и зачем


        1. MaxRyazan Автор
          28.10.2023 18:11
          -1

          попробуйте прочитать статью, там ясно написано как называть ключи) Из какого компонента мы провайдим, так и называть DATA_FROM_somecomponent_VUE


          1. gmtd
            28.10.2023 18:11

            То есть надо называть `IS_LOGGED_IN_FROM_App_VUE`, правильно?

            const isLoggedIn: = inject(IS_LOGGED_IN_FROM_App_VUE)

            вместо


            import { isLoggedIn } from "./services/auth";


            1. MaxRyazan Автор
              28.10.2023 18:11

              Совершенно не понимаю к чему вы ведете? Я не призываю пользоваться provide-inject. Я показал, как его МОЖНО использовать. Если у вас другое мнение, вперед, открываем сверху справа есть иконка карандаша, запилите свою статью о том, как пользоваться композаблами. Вы пытаетесь в ГАЙД засунуть холивар о том, что лучше юзать - зачем?