Как стать автором
Обновить

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

Время на прочтение18 мин
Количество просмотров51K

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

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

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

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

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

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

  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. Для нашего вопроса уж точно.

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

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

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

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
<script setup>
import { ref } from 'vue';
import Form from './Form.vue';

const msg = ref('Hello World!');

const change = () => {
  msg.value = Math.random().toString(36).slice(2, 7);
}
</script>

<template>
  <h1>App</h1>
  <div>msg: {{ msg }}</div>
  <div>
    <button @click="change">
      Изменить
    </button>
  </div>
  <hr />
  <Form v-model="msg" />
</template>

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

// Form.vue
<script setup>
import { ref } from 'vue';

defineProps({
  modelValue: {
    type: String,
  }
});
  
defineEmits(['update:modelValue']);
</script>

<template>
  <h2>Form</h2>
  <input 
  	:value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  >
</template>

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

Writable computed

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

Песочница

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

// Form.vue
<script setup>
import { ref, computed } from 'vue';

const props = defineProps({
  modelValue: {
    type: String,
  }
});
  
const emit = defineEmits(['update:modelValue']);
  
const localComputed = computed({
  get() {
    return props.modelValue;
  },
  set(newValue) {
    emit('update:modelValue', newValue)
  }
});
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="localComputed"
  >
</template>

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

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

Песочница

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

// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: String,
  }
});
  
const emit = defineEmits(['update:modelValue']);
  
const local = ref(props.modelValue);
  
watch(local, (newValue) => {
  emit('update:modelValue', newValue);
});
  
watch(() => props.modelValue, (newValue) => {
  local.value = newValue;
});
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="local"
  >
</template>

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

Provide/Inject

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

Песочница

// App.vue
<script setup>
import { ref, provide } from 'vue';
import Child from './Child.vue';
  
const msg = ref('Hello World!');
const updateMsg = (newValue) => msg.value = newValue;
provide('msgKey', {
  msg,
  updateMsg,
});
</script>

<template>
  <h1>{{ msg }}</h1>
  <Child />
</template>
//Child.vue
<script setup>
import ChildDeep from './ChildDeep.vue';
</script>

<template>
  <ChildDeep />
</template>
// ChildDeep.vue
<script setup>
import { inject} from 'vue';
  
const { msg, updateMsg } = inject('msgKey');
</script>

<template>
  <input :value="msg" @input="updateMsg($event.target.value)">
</template>

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

Песочница

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

// App.vue
<script>
import ChildComp from './ChildComp.vue';

export default {
  name: "App",
  components: {
    ChildComp,
  },
  provide() {
    return { listItems: this.items }
  },

  data() {
    return {
      items: [
        { id: 1, text: "Item 1" },
        { id: 2, text: "Item 2" },
        { id: 3, text: "Item 3" },
      ],
    };
  },
  methods: {
    addItem() {
      this.items = [
        ...this.items,
        {
          id: Date.now(),
          text: `New item ${Date.now()}`,
        },
      ];
    },
  },
};
</script>

<template>
  <h1>App</h1>
  <button @click="addItem">Add item</button>
  <pre>{{ items }}</pre>
  <hr />
  <ChildComp />
</template>
// ChildComp.vue
<script setup>
import { inject } from 'vue';
  
const items = inject('listItems');
</script>

<template>
  <h1>Child component</h1>
  <pre>{{ items }}</pre>
</template>

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

Песочница

// App.vue
<script setup>
import { ref, provide } from 'vue';
import ChildComp from './ChildComp.vue';
  
const items = ref([
  { id: 1, text: "Item 1" },
  { id: 2, text: "Item 2" },
  { id: 3, text: "Item 3" },
]);

function addItem() {
  items.value = [
    ...items.value,
    {
      id: Date.now(),
      text: `New item ${Date.now()}`,
    },
  ];
};

provide('listItems', items);
</script>

<template>
  <h1>App</h1>
  <button @click="addItem">Add item</button>
  <pre>{{ items }}</pre>
  <hr />
  <ChildComp />
</template>

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

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

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

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

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

Песочница

// App.vue
<script setup>
import { ref } from 'vue';
import Form from './Form.vue';
  
const arr = ref([
  { id: 1, msg: 'msg' },
  { id: 2, msg: 'msg' },
]);
</script>

<template>
  <pre>{{ arr }}</pre>
  <Form v-for="item of arr" :key="item.id" v-model:msgText="item.msg" />
</template>
// Form.vue
<script setup>
defineProps({
  msgText: String,
});

defineEmits(['update:msgText']);
</script>

<template>
  <pre>{{ arr }}</pre>
  <input :value="msgText" @input="$emit('update:msgText', $event.target.value)">
</template>

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

Прямой props и emit

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

Песочница

Изменим App.vue:

// App.vue
<script setup>
import { ref } from 'vue';
import Form from './Form.vue';

const obj = ref({ msg: 'Hello World!' });

const change = () => {
  obj.value.msg = Math.random().toString(36).slice(2, 7);
}
</script>

<template>
  <h1>App</h1>
  <div>obj: {{ obj }}</div>
  <div>
    <button @click="change">
      Изменить
    </button>
  </div>
  <hr />
  <Form v-model="obj" />
</template>

Изменим Form.vue:

// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: Object,
  }
});
  
const emit = defineEmits(['update:modelValue']);

const change = (key, newValue) => {
  emit('update:modelValue', { ...props.modelValue, [key]: newValue });
};
</script>

<template>
  <h2>Form</h2>
  <input 
  	:value="modelValue.msg"
    @input="change('msg', $event.target.value)"
  >
</template>

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

Writable computed

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

Песочница

Изменим Form.vue:

// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: Object,
  }
});
  
const emit = defineEmits(['update:modelValue']);

const localComputed = computed({
  get() {
    return props.modelValue
  },
  set(newValue) {
    emit('update:modelValue', newValue);
  }
});
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="localComputed.msg"
  >
</template>

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

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

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

Перепишем computed…

Песочница

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

// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: Object,
  }
});
  
const emit = defineEmits(['update:modelValue']);

const localComputed = computed({
  get() {
    return props.modelValue.msg
  },
  set(newValue) {
    emit('update:modelValue', { ...props.modelValue, msg: newValue });
  }
});
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="localComputed"
  >
</template>

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

Хотя код далек от идеала. Помните, что у нас 2, 3, 5 таких <input> (и не только), где мы меняем данные! Ок, нет проблем, повторим 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 <input>, а для 10 напишем 10 computed? Хм, плохая идея.

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

Песочница

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

// App.vue
<script setup>
import { ref, reactive } from 'vue';
import Form from './Form.vue';

const obj = reactive({ msg: 'Hello World!' });

const change = () => {
  obj.msg = Math.random().toString(36).slice(2, 7);
}
</script>

<template>
  <h1>App</h1>
  <div>obj: {{ obj }}</div>
  <div>
    <button @click="change">
      Изменить
    </button>
  </div>
  <hr />
  <Form v-model="obj" />
</template>

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

Watch

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

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

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

Песочница

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

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

// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: Object,
  }
});
  
const emit = defineEmits(['update:modelValue']);

const localObj = ref(props.modelValue); // toRef(props, 'modelValue');
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="localObj.msg"
  >
</template>

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

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

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

Песочница

// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: Object,
  }
});
  
const emit = defineEmits(['update:modelValue']);

const localObj = ref({ ...props.modelValue });
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="localObj.msg"
  >
</template>

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

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

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

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

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

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

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

Так, стоп! Что произошло?! Да всё очень просто. Наш 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 на <input> и при изменениях наш 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
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: Object,
  }
});
  
const emit = defineEmits(['update:modelValue']);

const localObj = ref({ ...props.modelValue });

watch(() => localObj.value, (newValue) => {
  emit('update:modelValue', { ...newValue});
}, { deep: true }); 

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

<template>
  <h2>Form</h2>
  <input 
  	v-model="localObj.msg"
  >
</template>

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

[Vue warn]: Maximum recursive updates exceeded in component <Repl>. 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.

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

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

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

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

Песочница

// App.vue
<script setup>
import { ref } from 'vue';
import Form from './Form.vue';

const obj = ref({ msg: 'Hello World!' });
const updated = ref(false);
  
const change = () => {
  obj.value.msg = Math.random().toString(36).slice(2, 7);
  updated.value = true;
}
</script>

<template>
  <h1>App</h1>
  <div>obj: {{ obj }}</div>
  <div>updated: {{ updated }}</div>
  <div>
    <button @click="change">
      Изменить
    </button>
  </div>
  <hr />
  <Form v-model="obj" v-model:need-update="updated" />
</template>
// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';

const props = defineProps({
  modelValue: {
    type: Object,
  },
  needUpdate: {
    type: Boolean,
  }
});
  
const emit = defineEmits(['update:modelValue', 'update:needUpdate']);

const localObj = ref({ ...props.modelValue });

watch(() => localObj.value, (newValue) => {
  emit('update:modelValue', { ...newValue});
}, { deep: true }); 

watch(() => props.needUpdate, (newValue) => {
  if (newValue) {
    emit('update:needUpdate', !newValue);
  	localObj.value = { ...props.modelValue };
  }
});
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="localObj.msg"
  >
</template>

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

Composable подход

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

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

Прежде чем использовать подход, показанный ниже, следует понимать риски, которые он с собой несет. Такое использование “внешних” переменных приводит к тому, что компоненты могут менять их состояние напрямую. Связанность компонентов становится только “смысловой” и совершенно не читается в коде! Я предпочитаю написать несколько комментариев для коллег, при подобных решениях.

Песочница

Добавим один файл:

// Composable.js
import { ref } from 'vue';

export const obj = ref({ msg: 'Hello World!' });

Изменим App.vue:

<script setup>
import { ref } from 'vue';
import Form from './Form.vue';
import { obj } from './Composable.js';

  
const change = () => {
  obj.value.msg = Math.random().toString(36).slice(2, 7);
}
</script>

<template>
  <h1>App</h1>
  <div>obj: {{ obj }}</div>
  <div>
    <button @click="change">
      Изменить
    </button>
  </div>
  <hr />
  <Form />
</template>

Изменим Form.vue:

// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';
import { obj } from './Composable.js';
</script>

<template>
  <h2>Form</h2>
  <input 
  	v-model="obj.msg"
  >
</template>

Работает. Но посмотрите внимательно на App.vue. Что мы здесь видим? Импорт чего-то, изменение его свойств и компонент Form.vue, который будто завис в воздухе.

С компонентом нам нечего делать, а вот с импортом чего-то мы можем немного “поколдовать”.

Песочница

// Composable.js
import { ref } from 'vue';

const obj = ref({ msg: 'Hello World!' });

export const useObjData = () => {
  const changeObj = () => {
    obj.value.msg = Math.random().toString(36).slice(2, 7);
  }
  
  return {
    obj,
    changeObj,
  }
};
// App.vue
<script setup>
import { ref } from 'vue';
import Form from './Form.vue';
import { useObjData } from './Composable.js';

const { obj, changeObj } = useObjData();

</script>

<template>
  <h1>App</h1>
  <div>obj: {{ obj }}</div>
  <div>
    <button @click="changeObj">
      Изменить
    </button>
  </div>
  <hr />
  <Form />
</template>
// Form.vue
<script setup>
import { ref, computed, watch } from 'vue';
import { useObjData } from './Composable.js';

const { obj } = useObjData();
</script>

<template>
  <h2>Form</h2>
  <input v-model="obj.msg">
</template>

Изменили мы совсем немного, но читабельность увеличилась! А React-разработчики увидят здесь что-то знакомое.

В завершении

Ни один подход не является серебряной пулей! Каждый из подходов, включая не рассмотренные Vuex/Pinia должен применяться в зависимости от задачи. Не стоит совершать ошибку, вынося весь код в utils.js, Vuex/Pinia или composable (use… функции).

Отличными примерами, в которых я так или иначе “ковырялся”, могут быть исходники Quasar и Vuetify.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+13
Комментарии20

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн