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

Vue Router и адрес карточки участника

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

Свяжем список и подробную карточку адресами. Пользователь сможет открыть /members/m-2 и понять, что речь идёт о Павле, а ссылка из списка приведёт на тот же экран. Результат урока — Vue Router, параметры маршрута и обработка неизвестного участника. Коллекция пока локальная; адрес задаёт выбор записи, но не подтверждает её существование на сервере и не назначает право просмотра.

Для учебного приложения используем hash history: путь находится после символа # и не требует от статического сервера отдельного ответа на каждый маршрут. Это конкретный выбор передачи готового фронтенда. В режиме обычной history сервер должен возвращать HTML приложения для подходящих клиентских путей и сохранять настоящие ответы для ресурсов API. Удобство адреса не отменяет эту серверную обязанность.

Таблица маршрутов и подключение

Ниже полные новые src/router.js и замена src/main.js. Vue Router 5.4.0 зафиксирован в базовом package.json; используем обычные createRouter, RouterLink и RouterView без экспериментальных возможностей.

import { createRouter, createWebHashHistory } from 'vue-router'
import TeamList from './views/TeamList.vue'
import MemberDetail from './views/MemberDetail.vue'
export const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    { path: '/', redirect: '/members' },
    { path: '/members', name: 'members', component: TeamList },
    { path: '/members/:id', name: 'member', component: MemberDetail },
    { path: '/:pathMatch(.*)*', redirect: '/members' },
  ],
})
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router.js'
import './style.css'
createApp(App).use(router).mount('#app')

Плагин подключается до mount. Имя member позволяет формировать ссылку через именованный маршрут, передавая id как параметр. Руководство Router по параметрам объясняет динамические сегменты и повторное использование компонента. Для нас важно, что переход между двумя id может сохранить экземпляр экрана, поэтому выбор записи должен зависеть от реактивного route.

Корневой компонент теперь содержит общую навигацию и место экрана. Это полный src/App.vue.

<template>
  <main>
    <h1>Команда</h1>
    <nav aria-label="Кабинет"><RouterLink :to="{ name: 'members' }">Сотрудники</RouterLink></nav>
    <RouterView />
  </main>
</template>

Экран списка и адрес записи

Добавим полный src/views/TeamList.vue. До следующего урока этот экран вызывает локальный useTeam; важно видеть это ограничение уже при чтении кода.

<script setup>
import { useTeam } from '../composables/useTeam.js'
import MemberCard from '../components/MemberCard.vue'
import MemberForm from '../components/MemberForm.vue'
import TeamPanel from '../components/TeamPanel.vue'
const { members, query, visibleMembers, toggle, create } = useTeam()
</script>
<template>
  <TeamPanel title="Сотрудники">
    <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>
  </TeamPanel>
</template>

Подробный экран пока читает исходный набор. Ниже полный src/views/MemberDetail.vue. computed обращается к route.params.id каждый раз, когда требуется новый выбор; предварительная копия параметра в обычную строку потеряла бы эту зависимость.

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { seedMembers } from '../data/members.js'
const route = useRoute()
const member = computed(() => seedMembers.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>
  </article>
  <p v-else role="alert">Участник не найден в исходном наборе.</p>
</template>

Ожидается, что ссылка Павла откроет его имя и роль. Прямой адрес неизвестного m-99 покажет отсутствие записи. Здесь есть намеренно видимая незавершённость: добавленный на экране списка участник ещё отсутствует в seedMembers подробного экрана. Следующий урок устранит два источника и предоставит обоим маршрутам одну коллекцию. Сейчас не называйте эту связь полноценным редактируемым кабинетом.

Адрес и состояние не совпадают

Router управляет выбранным экраном и параметрами. Он не хранит автоматически коллекцию, которую создал компонент списка. Когда TeamList уничтожается при переходе, его локальные данные могут исчезнуть. Сохраняемая строка поиска восстановится, потому что прошлый урок определил storage, но список созданных сотрудников не получит такого эффекта. Это различие помогает не приписывать маршрутизатору обязанности отдельного хранилища.

RouterLink формирует переход с учётом router. Обычная ссылка href на полный путь может инициировать другое поведение документа, особенно при выбранной hash history. Именованный маршрут также уменьшает число мест, где приходится вручную склеивать строку /members/ с идентификатором. Это удобство не означает автоматической проверки id: получатель всё равно обязан найти запись и обработать отсутствие.

catch-all в нашем примере переводит неизвестные пути к списку. Для реального продукта отдельный экран отсутствующей страницы часто информативнее. Перенаправление здесь относится к неизвестному маршруту, тогда как известный маршрут /members/:id с отсутствующим сотрудником показывает собственное сообщение. Два случая имеют разные причины: в одном неизвестен экран, в другом известен экран, но не найдены данные.

Полезная ошибка — написать const id = route.params.id и вычислять member только по этой обычной строке. При переходе с Павла на Анну компонент может остаться тем же, а локальная копия id не изменится. Реактивное чтение внутри computed сохраняет зависимость. Аналогичная ситуация возникнет при сетевой загрузке карточки: запрос должен наблюдать актуальный параметр, а старый результат не должен подменять нового выбранного участника.

Граница права просмотра

Наличие ссылки и название маршрута не являются авторизацией. Если позже сервер запрещает чтение сотрудника, клиент обязан показать соответствующий отказ и не получать частные поля через другой endpoint. Скрытие ссылки уменьшает доступные действия интерфейса, но не защищает данные. В этой серии весь набор вымышленный и открытый для местного учебного клиента; будущий API-договор явно определит текущего пользователя и допустимые ответы.

Для самостоятельного разбора сравните переход на известный m-2, неизвестный m-99 и случайный /reports. Опишите, какой маршрут совпадёт и откуда экран возьмёт данные. Затем добавьте участника только в локальном списке и объясните его отсутствие в подробной карточке без обвинения маршрутизатора. Следующее состояние свяжет два экрана с общим браузерным источником, сохранив понятную границу между адресом записи и её реальными данными.

Ссылка как действие чтения

Переход к карточке выполняется ссылкой, потому что у него есть устойчивый адрес и он меняет место просмотра. Кнопка доступности остаётся кнопкой, потому что изменяет состояние. Такая разница полезна и для клавиатуры, и для привычных действий браузера: пользователь ожидает открыть ссылку в отдельной вкладке или скопировать её адрес. Если сделать карточку только обработчиком click на div, эти возможности придётся воспроизводить вручную и легко потерять.

Адрес содержит id, а не имя. Переименование Павла не должно разрушить существующую ссылку, тогда как путь с именем потребовал бы правила обновления и возможных дублей. Это не запрещает человекочитаемые адреса вообще; оно объясняет выбор данного кабинета. Устойчивая личность записи уже используется ключом списка и теперь получает продолжение в маршруте.

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

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