Есть много способов сделать это: Vue 3 и взаимодействие компонентов

Vue 3 принёс в жизнь разработчиков возможность организации более гибкой структуры приложений. Всё чаще я стал замечать, что разные команды, а порой и разработчики внутри одной, используют целый зоопарк сомнительных подходов для организации взаимодействия между компонентами. Применяются какие-то крайности, либо всё в state manager, либо в composable (composition API), либо мутация props внутри дочерних компонентов!

Хотелось бы поднять эту тему и рассмотреть варианты взаимодействия компонентов доступные нам во Vue 3.

324aa822d612637c34fa25be43dd6dde.png

Немного теории

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

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

Способов взаимодействия компонентов не так много:

  1. props/emits

  2. provide/inject

  3. composable (composition API)

  4. state manager (Vuex/Pinia)

props/emits — прямое взаимодействие между компонентами! Такой подход обеспечивает прямой поток данных и строгое их изменение в определенном компоненте (при правильном применении).

Может быть неудобен при большой вложенности («props drilling») и в каких-то нетиповых ситуациях.

provide/inject — при правильном подходе позволит избежать «props drilling» и сохранить изменения непосредственно в родительском компоненте.

Можно легко ошибиться и потерять реактивность/мутировать данные внутри дочерних компонентов/запутаться, кто из дочерних вызывает функцию для мутирования. Так же «привязывает» дочерние компоненты к родительскому.

composable — добавляет гибкости и позволяет использовать одну логику с состоянием в нескольких компонентах.

Здесь так же легко потерять «виновника» изменений и «соблазниться» удобством вынесения всего в composable файлы.

Vuex/Pinia — практически аналогичен с composable. Для нашего вопроса уж точно.

Здесь важно понимать, что любая неочевидная связь приносит с собой возможные скрытые эффекты, о которых будет знать и помнить (пока) тот разработчик, который это всё разрабатывал. Разработчику, которому посчастливится разобрать компоненты, которые связаны только по смыслe и что-то где-то внутри себя меняют, придётся потратить гораздо больше времени, чем с прямой связью между компонентами.

Так или иначе, все это лишь моё субъективное мнение, которое не является неоспоримой истиной. Отмечу, что неоспоримым остаётся тот факт, что применение того или иного подхода должно быть задокументировано на каждом проекте, а разработчики должны отдавать себе отчет в применении этих подходов.

С картинками нагляднее.

props/emits — дочерний компонент не должен изменять (мутировать) props напрямую, он должен сообщать родительскому компоненту, что такой-то props следует изменить на такое значение. Сообщает он об этом, используя emit (). Да «props drilling» здесь присутствует, но такую вложенность я обычно игнорирую.

Взаимодействие компонентов напрямую через Props/EmitsВзаимодействие компонентов напрямую через Props/Emits

provide/inject — картина будет совершенно другой.

Взаимодействие компонентов напрямую через Provide/InjectВзаимодействие компонентов напрямую через Provide/Inject

Как и в случае с props/emits данные не изменяются в дочернем компоненте! Для изменения данных используется функция, переданная через provide вместе с значением. Эта функция уже вызывается внутри дочернего компонента.

Vuex/Pinia (state managers).

Взаимодействие компонентов напрямую через Vuex/PiniaВзаимодействие компонентов напрямую через Vuex/Pinia

На самом деле, хранить всё состояние в state manager — слишком избыточно. Давайте на секунду представим, что мы всё, что только можем, выносим. Получаем структуру компонентов, где каждый дочерний/родительский компонент общается через «что-то» третье, имея возможность изменять данные напрямую в этом «чём-то». Скорее всего, рано или поздно, у нас будет много неиспользуемых данных в этом «чем-то», а в добавок начнут всплывать скрытые эффекты по всему приложению.

Для себя я выработал стратегию, что в state manager стоит выносить только общее состояние между несколькими компонентами. От прямой передачи через Props/Emits отказываться не стоит.

Взаимодействие компонентов напрямую через Vuex/Pinia + Props/EmitsВзаимодействие компонентов напрямую через Vuex/Pinia + Props/Emits

А что же с Composable (Composition API)? Подход с вынесением состояния и функционала в отдельные файлы и использование use… функций позволяет отказаться от использования Vuex/Pinia.

Правда, здесь есть одно но. Vuex/Pinia — это инструменты, которые имеют хорошую документацию и поддержку сообщества. Использование Vuex/Pinia позволяет сократить проектные знания, так как использование этих технологий в любом проекте будет практически одинаковым. С собственной реализацией Composable такого не будет. То есть, каждый новый разработчик на проекте будет вынужден изучать вашу конкретную реализацию общих функций и тем более переменных, которые могут быть глобальными и локальными.

Принципиально, схема не будет отличаться от той, что выше. Да и поленился я её рисовать.

Взаимодействие через Props/Emits (примитивы)

Прямой props и emits

Самая простая задача — это взаимодействие родительского компонента через v-model. Здесь нет ничего сложного. Достаточно написать несколько строк кода внутри дочернего компонента и взаимодействие с ним станет доступным через v-model.

Песочница

В родительском компоненте (App.vue) добавим переменную и кнопку, чтобы изменять значение принудительно. Передадим переменную в Form.vue через v-model:

// App.vue


В компоненте Form.vue укажем props и emits:

// Form.vue


Вот и всё, мы научились использовать v-model на собственных компонентах.

Writable computed

Чтобы использовать v-model на  можно использовать writable computed (get/set).

Песочница

App.vue не изменится, а вот Form.vue необходимо изменить

// Form.vue


Мы сохранили реактивность данных в обе стороны. То есть, если значение изменится в родительском компоненте (App.vue), то оно будет изменено и внутри дочернего компонента (Form.vue).

Такого результата можно добиться, сохранив props в локальную переменную (через ref ()) и добавить 2 watch.

Песочница

Добавим watchers в Form.vue:

// Form.vue


Последний вариант кажется избыточным, но остаётся возможным. ‼️Отмечу, что он работает только из-за того, что это примитивный тип данных. Обратите внимание на watchers, чувствуете рекурсию и переполнение callstack? Его не происходит только потому, что watch сравнил значение, которое не изменилось, но новое присваивание произошло.

Provide/Inject

При использовании provide/inject важно понимать с чем вы его используете — с реактивной или нет переменной, не забывая, что изменять значение нужно именно в provider. Всё это более подробно описано в документации. В примере в качестве ключа я передаю строковый литерал, но в производственном коде рекомендуется передавать Symbol, вынесенный в отдельный файл.

Песочница

// App.vue


//Child.vue


// ChildDeep.vue


Если вы по какой-либо причине используете Options Api во Vue 3 и/или не внимательно читали документацию, но хотите использовать provide/inject, то однозначно есть смысл прочитать несколько разделов.

Песочница

Внимание! Нет реактивности!

// App.vue


// ChildComp.vue


Внесем некоторые правки в App.vue и реактивность появится.

Песочница

// App.vue


Взаимодействие через Vuex/Pinia в этой статье мы рассматривать не будем. Большую часть можно посмотреть в их документациях.

Остановимся более подробно на моменте, когда у нас будет не 1 input, а, скажем, 10 или 15. А что если таких Form.vue на странице будет 2–3? Вот здесь обычно и возникают сложности. Передавать 1, 2,5,10 v-model можно, но будет выглядеть ужасно! Попробуем взаимодействовать с объектами в v-model.

Взаимодействие через Props/Emits (объекты)

Частный случай с итерацией по массиву

При наличии массива объектов, каждый из которых содержит в себе данные для одной формы, на ум приходит идея с итерацией и v-model. Попробуем это реализовать.

Песочница

// App.vue


// Form.vue


Это очень простой пример. Его цель, показать тот момент, что нельзя в v-model передать весь текущий объект в итерации. Получается, что так бы мы пилили сук, на котором сидим. Остаётся передавать v-model на каждое поле или выполнять итерацию через что-то другое.

Прямой props и emit

Попробуем реализовать v-model с Object через прямой props и emit (смотри первый пример). Однако, здесь мы сталкиваемся с проблемой избыточного кода в template, так как изменяется одно поле, а в emit вторым параметрам передается весь объект. Решим такую проблему с помощью функции, в качестве аргументов принимающую ключ и значение.

Песочница

Изменим App.vue:

// App.vue


Изменим Form.vue:

// Form.vue


Что-то мне не нравится. Ах да, хотелось бы использовать v-model на ! Мы уже умеем делать это через writable computed. Стоит попробовать.

Writable computed

Внимание! Плохой код! Не повторять увиденное!

Песочница

Изменим Form.vue:

// Form.vue


Проверяем… Работает! А что, если мы не будем вызывать emit ()…

// Form.vue
~~~
set(newValue) {
  // emit('update:modelValue', newValue);
}
~~~

Эх, оно и так работает! Магии здесь никакой нет. Это старый добрый JavaScript с cсылочными типами данных (Object). То есть, мы просто мутируем объект напрямую.

Перепишем computed…

Песочница

Внимание! Код лучше, но плохой!

// Form.vue


Эврика, теперь все работает ожидаемо.

Хотя код далек от идеала. Помните, что у нас 2, 3, 5 таких (и не только), где мы меняем данные! Ок, нет проблем, повторим computed!

// Form.vue
~~~
const localComputed = computed({
  get() {
        return props.modelValue.msg
    },
    set(newValue) {
        emit('update:modelValue', { ...props.modelValue, msg: newValue });
    }
});

const localComputedTwo = computed({
  get() {
        return props.modelValue.msgTwo
    },
    set(newValue) {
        emit('update:modelValue', { ...props.modelValue, msgTwo: newValue });
    }
});
~~~

Так работает, мало того, сохраняется реактивность props для компонента. Но это всего 2 , а для 10 напишем 10 computed? Хм, плохая идея.

Прежде чем перейти к более оптимальному решению, стоит отметить, что внимательный читатель должен был заметить, что переменные, объявленные в App.vue, объявляется при помощи ref (), а не reactive (). Стоит выяснить, есть ли между ними разница при решении через writable computed.

Песочница

Изменим немного кода в App.vue:

// App.vue


И вот здесь всё сломалось. Вот одно из главных отличий между ref и reactive. Хотя и здесь есть обходной путь. Можно прослушивать событие @update:model-value и через Object.assign изменять переменную obj. Мой подход прост — если мне нужен Object, который я буду переписывать, то ref, иначе reactive. Хотя я встречал проекты, где принципиально отказываются от reactive из-за возможности забыть .value. Немного похоже на излишнюю осторожность, но имеет место быть.

Watch

В предыдущей главе мы остановились на не самом оптимальном решении с writable computed. Не оптимальное оно по причине повторения кода для каждого input в нашей маленькой форме.

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

Как и в случае с computed всё это не будет работать с reactive в качестве props.

Песочница

Создадим локальную переменную.

Внимание! Плохой код! Не повторять увиденное!

// Form.vue


Проверяем… Это работает! Реактивность сохранилась, но мы же не вызываем emit ()?! Да и vue/eslint не будет «ругаться» на мутацию props. Всё дело в том, что localObj ссылается на тот же объект и на самом деле выполняется именно прямая мутация входных параметров!

С toRef/toRefs будет так же.

Решим проблему при помощи копирования, глубокого или поверхностного — зависит от ситуация. В данном случаем нам хватит поверхностного.

Песочница

// Form.vue


Этого мало, работать не будет. Осталось лишь повесить watch и в нём вызывать emit ().

Внимание! Плохой код! Не повторять увиденное!

// Form.vue
~~~
watch(localObj.value, (newValue) => {
    emit('update:modelValue', newValue);
});
~~~

Такой код будет работать, но он скрывает ошибку.

Попробуйте изменить данные в родителе, нажав на кнопку. Ничего не изменилось? Хорошо, это ожидаемо, так как мы не отслеживаем изменения props.

Введите что-то в . В родительском компоненте данные изменились? Да, изменились.

Снова нажимаем на кнопку, чтобы изменить данные в родителе… И они теперь поменялись и в дочернем!

Так, стоп! Что произошло?! Да всё очень просто. Наш watch отследил первое изменение в localObj и передал его наверх, а v-model в родителе перезаписал значение. Так как это объект, то присваивание произошло по ссылке, как результат — скрытая связь между родительским компонентом и дочерним.

Небольшие изменения исправят ситуацию.

// Form.vue
~~~
watch(localObj.value, (newValue) => {
    emit('update:modelValue', { ...newValue});
});
~~~

Одну проблему мы решили, теперь изменения в дочернем компоненте вызывают emit и в родительском изменяются данные. Однако, мы потеряли реактивность для props. Нет нет, сам props по прежнему реактивный и изменяется после emit (), но вот мы этого не видим. Причина проста, script отработал один раз, создав копию для входных параметров, и сохранил эту копию в переменную localObj. Дальнейшие изменения props никак не влияют на localObj.

Прежде чем пойти дальше, стоит немного остановиться на работе watch.

Песочница

// Form.vue
~~~
watch(localObj.value, (newValue, oldaValue) => {
  console.log(newValue === oldaValue); // true
  emit('update:modelValue', { ...newValue});
});
~~~

При такой записи newValue всегда будет равно oldValue. Но, если они равны, то как срабатывает watch без опции deep: true?

Когда в watch первым аргументом передаётся реактивная переменная, созданная с помощью ref (), то происходит неявное разворачивание, то есть работает ровно так же, как и в блоке template.

Наша переменная localObj хранит в себе ссылку на proxy (созданный с помощью reactive ()). Если в watch передать localObj, то произойдет разворачивание и так как ссылка на proxy не изменяется, то и watch не сработает.

watch(localObj, (newValue, oldaValue) => {
  // никогда не сработает
  emit('update:modelValue', { ...newValue});
});

Но мы передаем localObj.value, где так же хранится объект, который не изменяется, тем более мы видим, что newValue === oldValue. Что здесь происходит? Да всё просто, передав localObj.value мы передали reactive объект (proxy), который автоматически переключил watch в состояние глубокого отслеживания!

Это на самом деле одно и тоже!

// ref -> reactive
watch(localObj.value, (newValue, oldaValue) => {
  console.log(newValue === oldaValue); // true
  emit('update:modelValue', { ...newValue});
});

// ref deep
watch(localObj, (newValue, oldaValue) => {
  console.log(newValue === oldaValue); // true
  emit('update:modelValue', { ...newValue});
}, { deep: true });

// function getter
watch(() => localObj.value, (newValue, oldaValue) => {
  console.log(newValue === oldaValue); // true
  emit('update:modelValue', { ...newValue});
}, { deep: true });

С последним будьте внимательны, если из функции вернуть localObj, то в newValue будет приходить неразвернутый объект.

Вернемся к оставшейся проблеме и посмотрим, сможем ли мы её решить. На текущий момент мы получаем данные от родительского компонента, копируем их в переменную в дочернем компоненте, используем эту переменную в v-model на  и при изменениях наш watch вызывает emit () и данные в родителе меняются. Не хватает только отслеживания изменений для props. Ведь вполне может быть ситуация, что данные в родители кто-то/что-то изменит?! Тогда наш дочерний компонент их не получит и никак не отреагирует (точнее сам компонент их получит, а вот локальная переменная — нет).

Первое, что приходит на ум — это добавить watch для props.modelValue. Попробуем?!

Песочница

Так как props являются реактивным объектом (reactive), но сами ключи нет, нам следует передать в watch функцию getter, чтобы watch реагировал на изменения

// Form.vue
~~~
watch(() => props.modelValue, (newValue) => {
  console.log('props.modelValue', newValue);
});
~~~

И вот в этот момент уже должна быть очевидна проблема. Посмотрите внимательно, мы внутри дочернего компонент изменяем данные, которые передаём родительскому компоненту и хотим реагировать на их изменения внутри дочернего. Таким watch мы должны попасть в рекурсию и переполнить callstack. Попробуем?

// Form.vue
~~~
watch(() => props.modelValue, (newValue) => {
  localObj.value = { ...newValue }; // помним про ссылку на объект и поэтому копируем
});
~~~

Пробуем… Но оно не работает, мы не проваливаемся в рекурсию, да и данные в родителе изменяются ровно один раз! Это законно? Да, давайте посмотрим внимательнее. Первый watch наблюдает за localObj.value, который автоматически стал глубоким. Переписав его, мы «сломали» watch. Убедимся в этом, добавив следующий код:

// Form.vue
~~~
watch(localObj.value, (newValue) => {
    emit('update:modelValue', { ...newValue});
});

setTimeout(() => {
  localObj.value = { msg: 'changed' };
}, 5000);

watch(() => props.modelValue, (newValue) => {
  // localObj.value = { ...newValue };
});
~~~

И как результат наблюдаем такое же поведение.

Ситуация исправится, если мы будем следить за функцией getter

// Form.vue


Да! Вот она! Долгожданная ошибка!

[Vue warn]: Maximum recursive updates exceeded in component . This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.

Но и это не все. Если нажать на кнопку, которая изменяет данные в родителе, то мы не увидим изменений в ! Проблема в том, что мы следим за изменением всего props.modelValue, а не его содержимым. Вот так заработает:

// Form.vue
~~~
watch(() => props.modelValue, (newValue) => {
  localObj.value = { ...newValue };
}, { deep: true });
~~~

Стоит отметить, что вместо первого watch мы вполне можем использовать watchEffect, результат будет такой же — ожидаемая рекурсия и переполнение callstack.

Всё это безобразие не решает нашей проблемы с реагированием на изменения входных данных. Возможно «костыльно», но одно из самых простых и прямых решений — это добавление ещё одного props, в качестве триггера, когда данные нужно обновить!

Песочница

// App.vue


// Form.vue


Выглядит это всё совсем не изящно и просит изменений! Попробуем это всё безобразие изменить.

Composable подход

Vue 3 позволяет нам многое. Мы можем отказаться от пресловутых mixins, которые вносят много «тайной магии» в наш код и затрудняют его чтение, а так же выносить целые блоки логики, используемой в нескольких компонента или даже в других блоках!

Работа с Composable тянет на отдельную статью, которые уже есть (Документация, VueSchool). Мы же рассматриваем совершенно другие вещи, поэтому как-нибудь в другой раз

© Habrahabr.ru