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

Вложенные маршруты и параметры заявки

Оглавление курса · Скачать все состояния проекта.

Учебная задача: Построить список, карточку и пути с произвольным числом сегментов.

Входное состояние: Главная страница и корневая компоновка. Выходное состояние: Список, динамическая карточка и дерево руководства. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-02 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.

Исходники подготовлены редакционным чтением. Пакеты, сервер, сборка, миграции, примеры и внешнее размещение не выполнялись. Ответы страницы и изменения данных ниже являются ожидаемыми, example_execution: not_run.

Адрес должен помогать диспетчеру понимать, что открыто. /tickets означает список, /tickets/1 — конкретную заявку. Внутри карточки позже могут появиться комментарии, поэтому рассмотрим также путь /tickets/1/notes/7. Такие адреса образуют дерево. В App Router дерево каталогов описывает его структуру, а файл страницы задаёт содержимое в конечной точке.

Создадим две вымышленные заявки в lib/tickets.ts. У каждой есть число id, заголовок, описание, статус и демонстрационный владелец. Это массив исходного модуля, а не база и не персональная выборка. Он позволяет наблюдать связь адреса с данными без дополнительной инфраструктуры. Изменение массива меняет учебный набор при следующем использовании исходника; браузер сам его не записывает.

Статический сегмент и динамическое значение

Каталог tickets обозначает буквальный сегмент URL. Каталог [id] обозначает место для значения. Внутри страницы параметр имеет строковый вид, поскольку адрес передаёт текст. Мы сопоставляем его с String(item.id) в массиве. Не считаем любую строку существующим ключом: если нужного элемента нет, вызываем notFound(). Так неизвестная заявка не превращается в пустую карточку с заголовком undefined.

В современных версиях Next params приходит как Promise. Поэтому функция страницы объявлена асинхронной и сначала выполняет await params. Это договор маршрутизатора, а не искусственная задержка данных. После получения объекта можно извлечь id и искать запись. Копирование старого синхронного обращения к параметрам создаст расхождение с выбранной версией, даже если идея маршрута осталась прежней.

Страница списка создаёт Link для каждого элемента с ключом ticket.id. Ключ React помогает согласовывать элементы списка при изменениях; строка href выбирает адрес карточки. Эти значения могут содержать одно число, но выполняют разные обязанности. Ключ не становится частью URL автоматически, а изменение URL не заменяет устойчивого ключа в списке. Явная запись обоих выражений облегчает понимание связи.

Вложенная динамическая страница получает два параметра: id и noteId. Сейчас она показывает только разбор адреса и прямо сообщает, что комментарии не сохраняются. Такой учебный шаг не следует описывать как реализованную систему комментариев. Зато структура показывает, как общий идентификатор заявки и локальный идентификатор комментария достигают одного обработчика. Для настоящих данных понадобятся поиск комментария внутри разрешённой заявки и отдельные права.

Необязательный набор сегментов

Руководство использует [[...parts]]. Двойные скобки допускают отсутствие частей: /guide открывает начало. Дополнительные сегменты становятся массивом строк, поэтому /guide/forms/new можно представить как две части пути. Обычный [...parts] требовал бы хотя бы один сегмент. Выбирайте форму по смыслу начальной страницы, а не по привычке копировать имя каталога.

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

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

Разберите ожидаемые адреса

Для /tickets/1 ожидается заголовок первой заявки, для /tickets/999 — состояние отсутствия, а для /guide — обозначение начала руководства. Путь с двумя частями руководства должен дать массив, а не одну строку с косой чертой. Эти результаты описывают договор подготовленного снимка; фактический сервер здесь не запускался. Вы можете объяснить их по дереву файлов и поиску в массиве.

Посмотрите на адрес с ведущим нулём, например /tickets/01. Текущее сравнение строк не найдёт элемент с ключом 1. Позже, при переходе к числовой базе, добавим явный разбор целого положительного ключа и выберем правила допустимого ввода. Такая небольшая деталь показывает, что имя динамического каталога не проверяет предметную корректность значения. Маршрутизатор выбирает страницу, а страница определяет смысл параметра.

Для самостоятельного разбора нарисуйте дерево из пяти файлов страниц и подпишите ожидаемый URL каждого. Затем добавьте мысленно маршрут списка комментариев без noteId: он потребует собственного page.tsx на соответствующем уровне. Существование глубокой страницы не означает автоматического существования всех промежуточных страниц. Это важное правило пригодится при построении меню и ссылок.

Проверьте смысл границ

Попробуйте добавить третью фикстуру с id 3. Страница списка автоматически создаст ссылку благодаря обходу массива, а динамическая карточка сможет найти её по адресу. Отдельный каталог 3 не потребуется, поскольку значение обслуживает [id]. Если же добавить только ссылку без объекта, карточка останется отсутствующей. Маршрут и данные должны согласовать один идентификатор.

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

Полные файлы этого шага

Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.

lib/tickets.ts

export type TicketStatus = "open" | "closed";
export type Ticket = { id: number; title: string; body: string; status: TicketStatus; ownerId: string };
export const sampleTickets: Ticket[] = [
  { id: 1, title: "Настроить рабочее место", body: "Подготовить принтер для приёмной.", status: "open", ownerId: "demo-author" },
  { id: 2, title: "Проверить резервную копию", body: "Согласовать время ручной проверки.", status: "closed", ownerId: "demo-author" },
];

app/tickets/page.tsx

import Link from "next/link";
import { sampleTickets } from "@/lib/tickets";
export default function Tickets() {
  return <main><h1>Заявки</h1>{sampleTickets.map(ticket => <article key={ticket.id}><Link href={`/tickets/${ticket.id}`}>{ticket.title}</Link><p>{ticket.status}</p></article>)}</main>;
}

app/tickets/[id]/page.tsx

import { notFound } from "next/navigation";
import { sampleTickets } from "@/lib/tickets";
export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const ticket = sampleTickets.find(item => String(item.id) === id);
  if (!ticket) notFound();
  return <main><h1>{ticket.title}</h1><p>{ticket.body}</p><p>Статус: {ticket.status}</p></main>;
}

app/tickets/[id]/notes/[noteId]/page.tsx

export default async function NotePage({ params }: { params: Promise<{ id: string; noteId: string }> }) {
  const { id, noteId } = await params;
  return <main><h1>Адрес комментария</h1><p>Заявка {id}, комментарий {noteId}. Данные комментариев пока не сохраняются.</p></main>;
}

app/guide/[[...parts]]/page.tsx

export default async function Guide({ params }: { params: Promise<{ parts?: string[] }> }) {
  const { parts } = await params;
  return <main><h1>Руководство диспетчера</h1><p>Раздел: {parts?.join(" / ") || "начало"}</p></main>;
}

Документация и исходники

Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.