Вложенные раскладки, группы и шаблоны
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Одна общая обёртка на весь сайт — редкий случай: у кабинета своё меню, у витрины своя шапка. В App Router это решается деревом: раскладку кладут на любой уровень, а папку, заведённую только ради общей обёртки, прячут от адреса скобками. Рядом лежит третий файл — template.tsx, который выглядит как раскладка, а ведёт себя ровно наоборот.
Цепочку от корневой раскладки до страницы вы уже собирали в прошлой статье. Интересное начинается на переходе: что происходит с общей частью цепочки, когда меняется только её хвост. В части II ответ был печальный — перерисовка из состояния сносила разметку целиком, вместе с открытым меню и позицией прокрутки, и их приходилось запоминать руками.
Раскладка от этого избавлена, и не благодаря Next, а благодаря React: позиция компонента в дереве при переходе не изменилась — значит, состояние остаётся при нём. Это то самое правило из статьи «Когда состояние сохраняется, а когда сбрасывается», просто применённое к маршрутам.
Маршрутов в песочнице нет, поэтому адрес здесь живёт в состоянии. Всё остальное настоящее: судьбу поддерева при смене страницы решает React, а не наша выдумка.
Меню осталось открытым — хотя страница внутри раскладки поменялась. Обратите внимание и на вывод: «раскладка: рендер» напечаталось трижды. Перерисовка и пересоздание — разные вещи: компонент перерисовался, но остался тем же экземпляром, и open у него тот же. Настоящий Next при переходе общую часть цепочки даже не перерисовывает: он запрашивает у сервера только изменившийся хвост, а раскладки берёт готовыми — это называется частичной отрисовкой.
Раскладок в цепочке может быть сколько угодно: app/layout.tsx, app/dashboard/layout.tsx, app/dashboard/settings/layout.tsx. Каждая обернёт всё, что лежит ниже её папки, и каждая переживёт переходы внутри своего поддерева. Уровень выбирается не по красоте дерева, а по времени жизни: чем дольше вещь должна жить, тем выше её раскладка.
layout.tsx не требует соседнего page.tsx. Папка dashboard может содержать только раскладку: обёртка для вложенных маршрутов появится, а сам адрес /dashboard отдаст 404 — страницы-то нет.
Так делают, когда раздел не имеет собственного экрана: /settings без содержимого, зато /settings/profile и /settings/billing — с общим боковым меню из раскладки.
Правило «папка — это сегмент» упирается в стену на первом же реальном сайте. Витрине нужны адреса / и /pricing с одной шапкой, кабинету — /dashboard с другой. Раскладку можно положить только на папку, а любая папка добавляет сегмент: получить общую обёртку для двух страниц, не заведя над ними /marketing, вроде бы нельзя.
Скобки снимают ровно это ограничение. Папка с именем в круглых скобках — группа маршрутов: она участвует в дереве файлов, но не участвует в адресе.
Файлы лежат в разных группах, а адреса выглядят так, будто никаких групп нет. Смысл группы виден в соседнем файле: app/(marketing)/layout.tsx обернёт витрину, app/(app)/layout.tsx — кабинет, и ни одна из этих раскладок не попадёт в чужое поддерево.
| Файл | Адрес | Чья раскладка обернёт |
|---|---|---|
app/(marketing)/page.tsx | / | корневая + (marketing) |
app/(marketing)/pricing/page.tsx | /pricing | корневая + (marketing) |
app/(app)/dashboard/page.tsx | /dashboard | корневая + (app) |
app/_widgets/nav.tsx | адреса нет | это не маршрут, а просто файл рядом |
Раз группы из адреса выпадают, два файла из разных групп легко сойдутся на одном адресе. Фреймворк такое дерево не соберёт — и это хорошая новость: конфликт находится при сборке, а не в проде.
В терминале круглые скобки — знак самой оболочки: папку заводят как mkdir "(marketing)", иначе zsh ответит невнятной ошибкой разбора.
Группы используют и без раскладки — просто чтобы разложить полсотни маршрутов по смыслу: (auth), (legal), (admin). Адреса от этого не меняются, а дерево перестаёт быть свалкой.
Иногда витрина и приложение различаются не шапкой, а всем: своя типографика, свой набор шрифтов, свой <body>. Тогда корневую раскладку заводят в каждой группе, а файл app/layout.tsx убирают совсем — app/(marketing)/layout.tsx и app/(app)/layout.tsx становятся корневыми, и каждая возвращает свои <html> и <body>.
Цена известна заранее: у двух корневых раскладок нет общего предка, поэтому переход из витрины в кабинет — это полная перезагрузка страницы, а не мгновенная смена хвоста цепочки. И домашнюю страницу придётся положить внутрь одной из групп: app/page.tsx без корневой раскладки остаться не может.
Сохранение состояния — главное достоинство раскладки и иногда главная помеха. Анимация появления проигрывается один раз и больше не повторяется. Форма обратной связи хранит текст, набранный на прошлой странице. Счётчик просмотров в эффекте срабатывает при первом входе в раздел и молчит все следующие переходы.
Для этих случаев есть template.tsx. Файл выглядит точно так же — компонент с пропом children, — но Next выдаёт ему новый ключ на каждый переход. А что React делает с изменившимся ключом, вы знаете: сносит старое поддерево и монтирует новое.
Ниже одна и та же обёртка стоит дважды: сверху обычная, снизу — с ключом. Прежде чем нажать «Run», решите, сколько строк и каких напечатает эффект после единственного «перехода».
Верхняя обёртка о переходе не узнала: её эффект отработал один раз при монтировании. Нижняя пережила полный цикл — уборка старого экземпляра, монтирование нового. Никакой магии в template.tsx нет, это тот же приём с ключом, только ключ подставляет фреймворк.
| layout.tsx | template.tsx | |
|---|---|---|
| Состояние при переходе | сохраняется | сбрасывается |
useEffect с пустыми зависимостями | один раз на весь раздел | на каждом переходе заново |
| Разметка | остаётся в DOM | пересоздаётся |
| Место в цепочке | снаружи | внутри своей раскладки |
| Обязателен в корне | да | нет, файл целиком необязателен |
Оба файла спокойно живут в одной папке: сначала layout.tsx, внутри него template.tsx, внутри шаблона — page.tsx. Так и делают, когда меню раздела должно пережить переход, а содержимое — появиться с анимацией.
Шаблон нужен реже, чем кажется. Начинайте с раскладки; заводите template.tsx, только когда поймали конкретную беду — не проигравшуюся анимацию или не сбросившуюся форму.
Первое, что хочется сделать в раскладке, — подсветить активный пункт меню. И тут выясняется, что адрес раскладке никто не передал: у неё есть children, а у динамических сегментов ещё и params — всё. Ни pathname, ни searchParams в списке пропсов нет; строку запроса получает только page.tsx.
Это следствие предыдущего раздела, а не забывчивость авторов. Раскладка не пересоздаётся при переходе — значит, она не может зависеть от адреса: иначе её пришлось бы перерисовывать на каждый переход, и вся выгода общей части цепочки исчезла бы.
Придумать себе недостающий проп компилятор не мешает — он проверяет то, что написано, а не то, что передаст фреймворк. Зато вызов без пропа он ловит сразу.
Компилятор указал на вызов: «Свойство "pathname" отсутствует в типе "{ children: Element; }" и является обязательным в типе "{ children: ReactNode; pathname: string; }"». А теперь важное: в настоящем Next такого вызова в вашем коде нет вовсе — раскладку вызывает фреймворк, и передаёт он ей только children. Ошибке взяться неоткуда, и предупреждать компилятору не о чем: он проверяет ваши вызовы, а не чужие.
А в выводе — та же история с другой стороны: тип обещал string, а напечаталось undefined. Типы живут до запуска, значение приходит после — и придуманный проп так и остаётся пустым.
Правильный ход — вынести кусочек, которому нужен адрес, в маленький клиентский компонент и позвать в нём usePathname. Раскладка остаётся неподвижной, а перерисовывается только подсветка. Как это делается и почему такой компонент почти ничего не стоит — в статье про переходы.
Тип у children всегда один: React.ReactNode. Он допускает элемент, строку, число, массив, null — то есть всё, что React умеет отрисовать. Соблазн написать React.ReactElement, «раз внутрь приезжает страница», обходится дорого: под этот тип не подойдёт ни массив, ни строка, а фреймворк передаст в раскладку то, что сочтёт нужным.
Второй частый вопрос — обёртка Readonly вокруг типа пропсов: { children }: Readonly<{ children: React.ReactNode }>. Так писали стартовые шаблоны Next прошлых мажоров, и это чистая гигиена — пропсы и без того менять нельзя. На проверки это влияет только одним: попытка присвоить что-то в props.children станет ошибкой компиляции. Нынешний шаблон create-next-app пишет иначе — { children }: LayoutProps<"/">: этот тип Next генерирует сам по дереву маршрутов, и в нём тот же children плюс params этого адреса.
Три инструмента этой статьи отвечают на разные вопросы, и путать их дорого. Сведём ответы в одну таблицу.
| Что нужно | Куда положить |
|---|---|
| Шапка и подвал на весь сайт | app/layout.tsx |
| Свой каркас для раздела, и раздел виден в адресе | layout.tsx в папке раздела |
| Свой каркас без лишнего сегмента в адресе | группа (имя) со своим layout.tsx |
| Состояние, которое переживает переход | раскладка (или ещё выше) |
| Сброс состояния на каждом переходе | template.tsx |
Совсем другой <body> для части сайта | своя корневая раскладка в группе |
| Просто разложить маршруты по смыслу | группа без раскладки |
Проверить себя можно, не запуская Next: цепочка целиком выводится из путей файлов. Соберём её вместе с шаблонами — заодно видно, что шаблон стоит внутри своей раскладки.
Скобки в первой цепочке есть, а в адресе /pricing их нет — вся идея групп умещается в это расхождение. Дерево файлов отвечает сразу на два вопроса, и отвечает по-разному: адрес читается без групп, а цепочка обёрток — с ними.
И последнее по порядку, но не по важности: лишний уровень раскладки — это лишняя вложенность разметки и лишняя граница, через которую придётся что-то пробрасывать. Раскладку заводят под конкретную общую часть, а не «на всякий случай, вдруг пригодится».
- Раскладку кладут на любой уровень дерева:
layout.tsxоборачивает всё, что ниже его папки, и переживает переходы внутри своего поддерева. - Состояние раскладки сохраняется не по воле Next, а по обычному правилу React: позиция компонента в дереве не изменилась.
- Папка в круглых скобках — группа: она даёт общую раскладку и порядок в дереве, но в адрес не попадает.
- Два файла из разных групп могут сойтись на одном адресе — это ошибка сборки, а не молчаливый выбор фреймворка.
template.tsx— та же обёртка с новым ключом на каждый переход: состояние сбрасывается, эффекты запускаются заново.- Раскладке не передают ни
pathname, ниsearchParams: всё, что зависит от адреса, живёт в маленьком клиентском компоненте внутри неё.
1/4В проекте лежит файл app/(shop)/cart/page.tsx. По какому адресу откроется эта страница?
2/4В раскладке app/dashboard/layout.tsx есть useState с открытым меню. Пользователь открыл меню и перешёл с /dashboard/reports на /dashboard/settings. Что случится с меню?
3/4Форма отзыва в разделе должна очищаться при каждом переходе между страницами раздела, а боковое меню — сохранять прокрутку. Как разложить?
4/4В дереве оказались два файла: app/(marketing)/about/page.tsx и app/(company)/about/page.tsx. Что произойдёт?
Адреса из дерева с группами
Допишите urlOf: функция получает путь к page.tsx и возвращает адрес, который даст этот файл. Папки в круглых скобках в адрес не попадают. Заодно посмотрите на два последних файла — они претендуют на один адрес.
Из пути выбрасываем первый элемент (app), последний (имя файла) и все папки-группы. Остаток и есть адрес.
Для app/(shop)/page.tsx сегментов не остаётся вовсе, и получается "/" + "" — корневой адрес /. Файлы app/(admin)/reports/page.tsx и app/(shop)/reports/page.tsx дают один и тот же /reports: настоящий Next такое дерево не соберёт и сообщит о конфликте маршрутов. Чинится это переименованием сегмента, а не группы — имя группы на адрес не влияет.
Сбросить обёртку на переходе
Баннер должен вести себя как template.tsx: пересоздаваться при каждой смене адреса, чтобы его эффект отрабатывал заново. Сейчас он ведёт себя как раскладка. Исправьте одну строку — трогать сам Banner не нужно.
Новый ключ на каждый адрес — и React пересоздаёт поддерево вместо того, чтобы его переиспользовать.
После правки «баннер смонтирован» печатается трижды: при первом монтировании и после каждого «перехода». Ровно это Next делает с template.tsx — файл вы пишете как обычную обёртку, а ключ подставляет фреймворк. Если добавить в эффект уборку, станет видно и вторую половину цикла: размонтирование старого экземпляра.
Разложить сайт по папкам
Требования такие: адреса / и /pricing — витрина с одной шапкой; /dashboard и /dashboard/settings — кабинет с боковым меню, которое сохраняет прокрутку при переходах; содержимое кабинета появляется с анимацией на каждом переходе; общий тег <body> у сайта один. Выпишите дерево файлов.
Подсказка: страниц ровно четыре, всё остальное — обёртки, и ни одного лишнего сегмента в адресах быть не должно.
Восемь файлов: четыре страницы, три раскладки и один шаблон. Главное в этом дереве: группы (marketing) и (app) дают двум разделам разные каркасы, не добавив в адреса ни одного сегмента; прокрутка меню живёт в раскладке кабинета и переходы переживает; анимация — в шаблоне, который на каждом переходе пересоздаётся.