Перейти к содержанию

Общее состояние между маршрутами

Оглавление серии «Vue 3: кабинет сотрудников «Команда»»

После появления маршрутов обнаружились два разных источника сотрудников: локальная команда списка и seedMembers карточки. Введём общий браузерный модуль состояния. Результат урока — одна коллекция, которую читают оба экрана, и операции изменения, сосредоточенные в одном месте. Добавленный Максим теперь должен открываться по своей ссылке в пределах того же экземпляра приложения.

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

Один экземпляр на уровне модуля

Создадим полный src/state/team.js. members объявлен вне функции useSharedTeam, поэтому повторный импорт и вызов возвращают одну коллекцию. Наружу отдаём readonly-представление, а изменения выполняются через create и toggle.

import { readonly, ref } from 'vue'
import { seedMembers } from '../data/members.js'
const members = ref(seedMembers.map(member => ({ ...member })))
function toggle(id) {
  const member = members.value.find(item => item.id === id)
  if (member) member.available = !member.available
}
function create(draft) {
  members.value.push({ id: crypto.randomUUID(), ...draft, available: true, version: 1 })
}
export function useSharedTeam() {
  return { members: readonly(members), toggle, create }
}

Руководство Vue об общем состоянии описывает вынесение реактивного источника и операций. Наш вариант подходит именно клиентской серии без SSR. На сервере singleton уровня модуля мог бы переживать отдельный запрос и смешивать данные пользователей, поэтому такую схему нельзя автоматически переносить в серверный рендеринг. Граница среды является частью решения, а не мелким замечанием о запуске.

Полностью заменим src/views/TeamList.vue. Фильтр остаётся локальным представлением экрана, сотрудники становятся общими. Сохраняем только query в прежний ключ, а остальные свойства composable ранних шагов больше не используются этим экраном.

<script setup>
import { computed, ref, watch } from 'vue'
import { useSharedTeam } from '../state/team.js'
import MemberCard from '../components/MemberCard.vue'
import MemberForm from '../components/MemberForm.vue'
const { members, toggle, create } = useSharedTeam()
let initial = ''
try {
  const value = JSON.parse(localStorage.getItem('team-query:v1') ?? '""')
  if (typeof value === 'string' && value.length <= 200) initial = value
} catch { /* optional setting */ }
const query = ref(initial)
watch(query, value => {
  try { localStorage.setItem('team-query:v1', JSON.stringify(value)) } catch { /* optional setting */ }
})
const visibleMembers = computed(() => members.value.filter(member =>
  `${member.name} ${member.role}`.toLocaleLowerCase('ru')
    .includes(query.value.trim().toLocaleLowerCase('ru'))
))
</script>
<template>
  <section>
    <h2>Сотрудники</h2>
    <MemberForm @create="create" />
    <label>Поиск <input v-model="query" type="search"></label>
    <p>Показано {{ visibleMembers.length }} из {{ members.length }}</p>
    <p v-if="!visibleMembers.length">Подходящих участников нет.</p>
    <div v-for="member in visibleMembers" :key="member.id">
      <MemberCard :member="member" @toggle="toggle" />
      <RouterLink :to="{ name: 'member', params: { id: member.id } }">Карточка {{ member.name }}</RouterLink>
    </div>
  </section>
</template>

Полная замена MemberDetail.vue также использует общий модуль. Имя свойства members совпадает с источником списка, а выбор записи продолжает зависеть от route.

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSharedTeam } from '../state/team.js'
const route = useRoute()
const { members, toggle } = useSharedTeam()
const member = computed(() => members.value.find(item => item.id === route.params.id))
</script>
<template>
  <article v-if="member">
    <h2>{{ member.name }}</h2>
    <p>{{ member.role }}</p>
    <p>{{ member.available ? 'Доступен' : 'Занят' }}</p>
    <button type="button" @click="toggle(member.id)">Изменить доступность</button>
  </article>
  <p v-else role="alert">Участник не найден в текущей команде.</p>
</template>

Ожидается, что изменение доступности в карточке останется видимым после возврата к списку. Добавленный участник также доступен обоим экранам, пока приложение не пересоздано. Удалённое или неизвестное значение id получает сообщение об отсутствии. Всё это следует из одного массива и не требует передачи всей записи через параметры URL.

Какие данные стоит объединять

members нужен нескольким экранам и отражает предметные записи, поэтому становится общим. query определяет текущий способ просмотра и остаётся локальным, с отдельным восстановлением настройки. Поля формы вообще принадлежат её экземпляру. Если объединить все значения без различия, переход на другую карточку мог бы неожиданно менять чужой черновик. Общее хранилище не является целью само по себе; оно должно соответствовать общности конкретных данных.

readonly помогает запретить прямое присваивание через возвращённый источник. Компонентам не следует делать members.value.push или менять member.available самостоятельно. Это не серверная защита и не ограничение доверенного пользователя браузера. Оно помогает автору приложения сохранить архитектурный договор: операции проходят через общий модуль. Реальные права на изменение по-прежнему должен проверять сервер, когда появится удалённая запись.

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

Отказ от второго источника

Полезный неправильный случай — подключить useSharedTeam только к списку, оставив seedMembers в карточке. Добавление продолжит казаться успешным, но ссылка нового человека покажет отсутствие. Проверка названий импортов обнаружит проблему быстрее, чем поиски в router. Аналогичная ошибка возможна при смешении useTeam и useSharedTeam: одинаковое поле members не делает две коллекции одной и той же.

Старые файлы useTeam и TeamPanel остаются в последовательном архиве, но последний экран больше не импортирует их. Это допустимые учебные следы изменения структуры, а не скрытая потребность будущего состояния. При подготовке отдельного рабочего проекта их можно удалить после проверки импортов. Для воспроизведения серии удаление не требуется, поэтому README не предлагает опасную автоматическую очистку папок по предположению.

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

Перед следующим шагом проследите четыре места: объявление members, операция create, выбор записи карточкой и вычисление списка. Все чтения должны вести к одному модулю, а запись — к его функциям. Затем объясните, почему query не обязан храниться там же. Такое рассуждение задаёт основу транспорта: следующий урок введёт проверку JSON и договор API, не позволяя случайному ответу сервера незаметно стать моделью всей команды.

Момент создания общего источника

Модуль загружается при импорте, и его ref создаётся один раз для данного дерева модулей. Это означает, что данные могут существовать дольше одного экрана. Но выгрузка всей страницы завершает этот экземпляр, а повторная загрузка получает новый. Такая граница объясняет, почему возвращение через навигацию сохраняет Максима, а открытие новой вкладки не обязано его видеть. Если требуется долговременная команда, её нужно читать с сервера, а не расширять время жизни переменной обещаниями.

readonly не замораживает внутреннее состояние так, чтобы собственные операции больше не могли писать. Методы замыкаются над исходным mutable ref, а потребители получают другое представление. Поэтому разделение работает без копирования всей коллекции на каждом чтении. Копия вернула бы снимок, который пришлось бы снова синхронизировать.

Перед добавлением нового метода задайте его предметный смысл. Например, remove по id должен решить, что делать с открытой карточкой и её черновиком. Добавление массива действий без таких правил не делает хранилище полноценным. В текущем договоре есть только создание и доступность, и каждый экран знает, как показать отсутствие выбранного участника.

Для самостоятельного воспроизведения результата «Получать одну коллекцию из двух экранов» выберите lesson-11 в архиве исходников. Статус выполнения этого состояния: not_run.