Статьи серии
Чтобы в коде проще было ориентироваться и поддерживать его мы разделяем его на небольшие части, которые легко укладываются в голове и на экране. Во Vue, в основном, такие части будут представлены компонентами. Мы дробим интерфейс пользователя на ряд компонентов, которые используют друг-друга образуя дерево (иерархию).
Содержание
- Проблематика
- Использование provide/inject
- Глобальная шина событий
- Свое реактивное хранилище
- Хранилище Pinia
- Итоги
Проблематика
Одна страница разбита на полтора десятка составляющих. В коде каждого из них легко ориентироваться. Но компоненты редко бывают автономными. Они получают данные от других компонентов, тех, что выше в иерархии. Они внутри себя используют другие компоненты. И как при этом передавать данные между ними?
Стандартный поход передачи данных – использовать свойства для передачи данных вложенным элементам и события для передачи данных наружным. Это удобно, когда уровня всего 2. Если уровней много, такой подход повлечет много шаблонной работы. При этом ряд промежуточных компонентов получит данные, которые им не нужны, которые нужно только передать дальше по цепочке к получателю. Такое никуда не годится.
Однако мы не может отказаться от разбивки на мелкие компоненты. Если мы будем создавать громадные компоненты, которые отвечают за всё, то у нас будут компоненты на сотни или тысячи строк. От разделения кода мы не может отказаться, поэтому надо пересмотреть то, как мы передаем данные.
Рассмотрим какие есть подходы для удобной передачи данных.
Лучше всего будет продемонстрировать их на примере. Пусть у нас будет 3 компонента вложенных друг в друга:
- LevelOne.vue – компонент верхнего уровня
- LevelTwo.vue – промежуточный
- LevelThree.vue – компонент нижнего уровня
В качестве данных будет выступать переменная counter. Нам бы хотелось, чтобы эти данным можно было получать и менять на любом уровне .
Использование provide/inject
Достаточно простым в реализации способом передать какие-либо реактивные данные на несколько уровней является подход с provide/inject
. На вершине иерархии компонентов нужно вызывать provide
и передать ему имя, которое будет связано с реактивной переменной. А на любом из нижних уровней после этого можно вызвать inject
, указав имя и получив доступ к этим реактивным данным. При их модификации изменения будут доступны во всех частях дерева компонентов с уровнями ниже и на уровне вызова provide
. Код примера
<template>
<div class="level level-1">
<span class="value">Level 1. Value: {{ counter }}</span>
<button @click="counter++">+</button>
<button @click="counter--">-</button>
<level-two></level-two>
</div>
</template>
<script setup>
import LevelTwo from './LevelTwo.vue'
import { ref, provide } from 'vue'
// Локальная переменная для компонента
const counter = ref(0)
// Она будет доступной на уровнях ниже под именем globalCounter
provide('globalCounter', counter)
</script>
<style scoped>
.level-1 {
background-color: yellow;
margin: 0;
}
</style>
На промежуточных уровнях можно не обращаться к данным. Но там они тоже доступны:
<template>
<div class="level level-2">
<span class="value">Level 2. Value: {{ ourCounter }}</span>
<level-three></level-three>
</div>
</template>
<script setup>
import LevelThree from './LevelThree.vue'
import { inject } from 'vue'
// Внедряем в компонент переменную и даем локальное имя
const ourCounter = inject('globalCounter')
</script>
<style scoped>
.level-2 {
background-color: greenyellow;
}
</style>
Также как и на нижних уровнях (в любом компоненте):
<template>
<div class="level level-3">
<span class="value">Level 3. Value: {{ topCounter }}</span>
<!-- Управляем реактивными данными используя локальное имя -->
<button @click="topCounter++">+</button>
<button @click="topCounter--">-</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
// Внедряем в компонент переменную и даем локальное имя
const topCounter = inject('globalCounter')
</script>
<style scoped>
.level-3 {
background-color: white;
}
</style>
Преимущество этого метода – простота. Не нужны какие-либо дополнительные библиотеки. Vue умеет работать с этим, что называется, “из коробки”. Многие пакеты Vue используют этот подход внутри.
Но есть и минусы. Привязка значения происходит к строке. Можно, вполне, ошибиться при наборе имени. Нет возможности посмотреть список таких значений где-нибудь в панели разработчика. Нет возможности передать данные в компонент этого же уровням (а не ниже).
Глобальная шина событий
Если в обычной ситуации мы генерируем событие, которое может быть обработано лишь одним уровнем выше данного компонента, то при помощи глобальной шины сообщений мы порождаем события, которые могут иметь обработчики в любом месте приложения. Любой компонент, в котором мы прикрепим обработчик события шины, сможет реагировать на событие.
Это тоже довольно простой способ передачи данных между несколькими уровнями дерева компонентов. Есть функция для отправки события и есть функция для привязки обработчика.
Для добавления такой возможности в ваше Vue приложение есть отдельные пакеты. Например, mitt. Код примера
<template>
<div class="level level-1">
<span class="value">Level 1. Value: {{ counter }}</span>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<level-two></level-two>
</div>
</template>
<script setup>
import LevelTwo from './LevelTwo.vue'
import { ref, onMounted } from 'vue'
import emitter from './emitter.js'
// Локальная переменная для компонента
const counter = ref(0)
function fire() {
// Отправка события глобальной шине
emitter.emit('counter:update', { value: counter.value })
}
function increment() {
counter.value++
fire()
}
function decrement() {
counter.value--
fire()
}
function updateCounter(data) {
// Обработчик событий от других компонентов
counter.value = data.value
}
onMounted(() => {
// Прикрепляем обработчик события
emitter.on('counter:update', updateCounter)
})
</script>
<style scoped>
.level-1 {
background-color: yellow;
margin: 0;
}
</style>
На промежуточном уровне данные тоже доступны:
<template>
<div class="level level-2">
<span class="value">Level 2. Value: {{ counter }}</span>
<level-three></level-three>
</div>
</template>
<script setup>
import LevelThree from './LevelThree.vue'
import { ref, onMounted } from 'vue'
import emitter from './emitter.js'
const counter = ref(0)
function updateCounter(data) {
counter.value = data.value
}
onMounted(() => {
emitter.on('counter:update', updateCounter)
})
</script>
<style scoped>
.level-2 {
background-color: greenyellow;
}
</style>
И наконец, на самом глубоком уровне:
<template>
<div class="level level-3">
<span class="value">Level 3. Value: {{ counter }}</span>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import emitter from './emitter.js'
const counter = ref(0)
function updateCounter(data) {
counter.value = data.value
}
function fire() {
emitter.emit('counter:update', { value: counter.value })
}
function increment() {
counter.value++
fire()
}
function decrement() {
counter.value--
fire()
}
onMounted(() => {
emitter.on('counter:update', updateCounter)
})
</script>
<style scoped>
.level-3 {
background-color: white;
}
</style>
Из примера видно, что простота была кажущаяся. Если нам нужно менять и получать данные в нескольких местах, то придется делать обработку и отправку события в каждом из этих мест. А это повлечет создание достаточно много шаблонного кода.
При этом происходит копирование данных. Событием вы известите ряд частей вашей системы о том, что какие-то данные изменились. Но дальше каждая из этих частей создаст себе копию данных, с которой будет работать. То есть не будет единого источника правды. Это может привести к рассогласованию позже.
При широком использовании такого подхода видов событий в приложении станет много. Все труднее будет давать им названия, которые укладывались бы в единый стиль.
Еще один минус, что это – не стандарт. Вы можете использовать разные пакеты для шины событий.
Свое реактивное хранилище
Используя подходы Composition API мы могли бы вынести некоторые реактивные данные в отдельную функцию типа useSomething
. Импортируя реактивные данные из этой функции в любой нужный компонент мы могли бы обращаться к ним как данным текущего компонента. Код примера
import { ref } from 'vue'
/**
* Содержимое хранилища. Здесь может быть больше данных
*/
const counter = ref(0)
// Функция для подключения хранилища внутри компонентов
function useState() {
return { counter }
}
export default useState
Первый уровень теперь выглядит сильно компактнее:
<template>
<div class="level level-1">
<span class="value">Level 1. Value: {{ counter }}</span>
<!-- Здесь мы обращаемся прямо к данным хранилища -->
<button @click="counter++">+</button>
<button @click="counter--">-</button>
<level-two></level-two>
</div>
</template>
<script setup>
import LevelTwo from './LevelTwo.vue'
import useState from './state.js'
// Подключаем хранилище и будем работать с данными как с локальными
const { counter } = useState()
</script>
<style scoped>
.level-1 {
background-color: yellow;
margin: 0;
}
</style>
На втором уровне будем, как и ранее, только читать:
<template>
<div class="level level-2">
<span class="value">Level 2. Value: {{ counter }}</span>
<level-three></level-three>
</div>
</template>
<script setup>
import LevelThree from './LevelThree.vue'
import useState from './state.js'
const { counter } = useState()
</script>
<style scoped>
.level-2 {
background-color: greenyellow;
}
</style>
На самом глубоком уровне мы сохраним способность менять данные. И при изменении они будут сразу же доступны на всех уровнях:
<template>
<div class="level level-3">
<span class="value">Level 3. Value: {{ counter }}</span>
<!-- Управляем реактивными данными используя локальное имя -->
<button @click="counter++">+</button>
<button @click="counter--">-</button>
</div>
</template>
<script setup>
import useState from './state.js'
const { counter } = useState()
</script>
<style scoped>
.level-3 {
background-color: white;
}
</style>
Код смотрится гораздо компактнее, чем для предыдущего способа. Есть единый источник истины – хранилище. Используется реактивность Vue. Достаточно хороший вариант в целом.
Здесь основной минус в том, что этот подход не стандартный. От проекта к проекту реализация такого хранилища может отличаться. В панели разработчика браузера не будет специальной поддержки этого хранилища.
Хранилище Pinia
Хранилище Pinia – наиболее стандартизированный и современный инструмент для этих целей. Об этом пакете рассказывалось в одной из предыдущих статей (Vuex или Pinia – что выбрать?). Это то, во что эволюционировал Vuex. Если у последнего это было общее хранилище, которое мы могли лишь разбивать на модули, то у Pinia подход иной. Можно создавать сколько угодно отдельных хранилищ.
Можно создать отдельное хранилище для данных какой-либо страницы. Или группы блоков. Это хранилище будет реактивным. Оно также может иметь вычисляемые данные (getters
) и функции (actions
) для удобного изменения данных. Код примера
import { defineStore } from 'pinia'
/**
* Описываем хранилище вместе с методами (actions)
*/
export const useCounterStore = defineStore('counter', {
state: () => ({ counter: 0 }),
getters: {
doubleCount: (state) => state.counter * 2
},
actions: {
increment() {
this.counter++
},
decrement() {
this.counter--
}
}
})
<template>
<div class="level level-1">
<span class="value">Level 1. Value: {{ store.counter }}</span>
<!-- Обращаемся к методам хранилища -->
<button @click="store.increment">+</button>
<button @click="store.decrement">-</button>
<level-two></level-two>
</div>
</template>
<script setup>
import LevelTwo from './LevelTwo.vue'
import { useCounterStore } from './state.js'
/**
* Подключаем хранилище в каомпонент
* и будем с ним работать напряму и через методы
*/
const store = useCounterStore()
</script>
<style scoped>
.level-1 {
background-color: yellow;
margin: 0;
}
</style>
<template>
<div class="level level-2">
<span class="value">Level 2. Value: {{ store.counter }}</span>
<level-three></level-three>
</div>
</template>
<script setup>
import LevelThree from './LevelThree.vue'
import { useCounterStore } from './state.js'
const store = useCounterStore()
</script>
<style scoped>
.level-2 {
background-color: greenyellow;
}
</style>
<template>
<div class="level level-3">
<span class="value">
Level 3. Value: {{ store.counter }}.
<!-- Используем getter, как computed -->
Double: {{ store.doubleCount }}
</span>
<button @click="store.increment">+</button>
<button @click="store.decrement">-</button>
</div>
</template>
<script setup>
import { useCounterStore } from './state.js'
const store = useCounterStore()
</script>
<style scoped>
.level-3 {
background-color: white;
}
</style>
Код почти такой же компактный, как в прошлом способе. Хранилище описывается документированным способом. Отладочная панель поддерживает работу с Pinia, так как такой подход широко распространен. Также этот пакет имеет набор различных расширений. Например, можно связать хранение с localStorage.
Итоги
Таким образом, Pinia – наилучшим образом подходит для передачи данных между несколькими уровнями в иерархии компонентов на странице. Однако это еще один пакет, еще одна зависимость. Такой подход может быть избыточным.
Если вы создаете свою небольшую библиотеку, то, возможно, не стоит спешить добавлять в зависимости к нему какие-либо пакеты, так как могут возникнуть конфликты у пользователей библиотеки. Поэтому provide/inject
, ровно как и создание своего хранилища, могут пригодиться.
Отправка событий через глобальную шину имеет описанные выше недостатки. Перед использованием этого способа лучше убедиться, что наиболее оптимальный в данном случае вариант.
Как можно видеть, для Vue имеется достаточно способов передачи данных между компонентами находящихся далеко друг от друга. Мы можем и разбивать код на мелкие части и сохранять простоту обмена данными.
В следующих статьях о Vue мы продолжим разбирать подходы, которые помогают писать код с большим удобством.
Код всех примеров – в репозитории