Свой файловый маршрутизатор

Next.js≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Глава читала дерево папок как таблицу правил: page делает сегмент адресом, layout оборачивает всё, что ниже, скобки прячут папку от адреса, [id] отдаёт значение в params. Теперь напишем того, кто это дерево читает: обычный объект вместо папок и одна функция, превращающая адрес в стопку обёрток, страницу и параметры. Сорок строк — и станет видно, почему в App Router нет файла с описанием маршрутов и почему один из четырёх кирпичей ломает обычный цикл.

Разделы темы:·

Роутер вы уже писали — в статье «Зачем фреймворк поверх React». Список маршрутов, сравнение строк целиком, pushState и подписка на popstate; сломался он на второй же строке вывода, потому что /tasks/42 в списке не нашёлся. Статья оставила таблицу того, что пришлось бы дописать. Дописываем: к этому месту главы у вас есть все четыре кирпича.

Начинаем с дерева. Файловой системы в песочнице нет, но она и не нужна: папка — это узел, имя папки — ключ, а особые файлы — поля этого узла. В настоящем Next.js page — компонент; здесь это функция, возвращающая строку, чтобы вложенность обёрток была видна прямо в выводе.

Строка вместо разметки — не экономия на буквах, а способ проверять себя: в консоли сразу видно, что во что вложено. Замените строки на JSX — сопоставление адреса от этого не изменится ни на строку.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три адреса нашлись, два ответили 404 — и это два разных 404. По /tasks узел нашёлся, но page в нём нет: папка есть, страницы нет. То самое поведение из статьи про App Router, только теперь видно, какая именно строка его создаёт: тернарный оператор в конце resolve. А /tasks/42 не нашёл даже узла: папки с именем 42 в дереве нет и быть не может. Долг из «Зачем фреймворк» пока не закрыт.

И сразу обратите внимание, чего в этом коде нет. Нет списка адресов. Ни /about, ни /tasks/archive нигде не написаны — они выводятся из формы объекта в момент вопроса. Вся разница между конфигом маршрутов и файловым маршрутизатором уже здесь, в трёх десятках строк.

В статье про App Router вы собирали цепочку файлов: app/layout.tsx → app/dashboard/layout.tsx → страница. Тогда это был список имён, и его печатали. Теперь в списке лежат функции, и его надо применить.

Копится стопка по дороге вниз: встретили узел с layout — положили. Корневая раскладка ложится первой, ближайшая к странице — последней. Остаётся свернуть стопку вокруг разметки страницы, и вот тут интуиция подводит. Прогноз: reduce идёт по стопке слева направо, от корневой к ближней — какая раскладка окажется снаружи?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

reduce дал [кабинет [сайт Архив]]: корневая раскладка оказалась внутри, под раскладкой кабинета. По шагам всё логично — первой применяется layouts[0], то есть корневая, и она оборачивает страницу вплотную; всё, что применяется позже, ложится снаружи предыдущего. Порядок применения и порядок вложенности противоположны, и это ровно то место, где ошибаются.

Значит, сворачивать надо с конца. reduceRight — тот же reduce из главы про методы массивов, только идущий справа налево: первой применяется ближайшая к странице раскладка, последней — корневая, и она оказывается снаружи всех. Ни одного условия, ни одной проверки уровня: порядок вложенности целиком задан порядком, в котором раскладки попали в стопку.

Проверить себя можно и не запуская: снаружи обязано оказаться то, что в дереве папок лежит выше. Если корневая шапка вдруг очутилась внутри бокового меню раздела — стопку свернули не с той стороны.

Третий кирпич — папка, чьё имя переменная. В дереве это обычный ключ, просто записанный в скобках: "[id]". Работы прибавляется в двух местах: выбрать такого ребёнка, когда буквального совпадения нет, и запомнить сам сегмент — ему ехать в params.

Выбор ребёнка выносим в отдельную функцию, и не ради красоты. Именно она — то место, где решается спор между new и [id] из статьи про динамические сегменты. Правило там звучало как «чем меньше скобок, тем шаблон точнее»; здесь оно превращается в порядок двух проверок и больше ни во что. Чтобы этот порядок было легко переставить, сам выбор уезжает в resolve аргументом — обычный колбэк из первой части.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два первых адреса ответили как надо, третий — 404: узел tasks в дереве есть, а page в нём нет. И заметьте, что именно дало правильный ответ на /tasks/new: не таблица приоритетов и не особая логика, а порядок двух if внутри literalFirst. Никакого встроенного правила у маршрутизатора нет — правило и есть этот порядок.

Одна перестановка — и переменная папка забирает всё, до чего дотянется. Дерево и обход остались прежними, меняется только функция выбора.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибки нет ни одной: маршрут нашёлся, страница отрисовалась, параметр разобрался — просто открылась не та страница, и params.id равен "new". Тихая подмена страницы не всплывает ни в сборке, ни в типах, ни на ревью; поэтому приоритет буквального сегмента в Next.js — не украшение, а защита от этого самого случая.

Остался четвёртый кирпич, и устроен он иначе, чем три предыдущих. page, layout и [id] помещаются внутрь шага цикла: пришёл сегмент — разобрались с ним и пошли дальше. Папка в скобках сегмента не тратит — значит, шаг по адресу и шаг по дереву перестают совпадать.

Дальше это дерево уезжает в подложку примеров, чтобы не переписывать его целиком в каждом. Вот оно, а заодно — проверка, что скобки в ключе никого не смущают.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Витрина переехала в (marketing), и главная страница теперь лежит внутри группы: узел с page находится на два ключа вглубь от корня, а адрес у него по-прежнему /. Кабинет остался обычной папкой и дал сегмент tasks.

Цикл из прошлых разделов на этом дереве не упадёт — он просто ничего не найдёт. Прогноз: три адреса, что ответит каждый?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

/tasks/42 цел, а /pricing и / отвечают 404. С /pricing всё ясно: ребёнка с таким именем у корня нет, есть (marketing) и tasks. С корнем ещё нагляднее — сегментов ноль, цикл не выполнился ни разу, а у самого корня page нет и не должно быть.

Починить это ещё одной веткой в pick не выйдет, и вот почему. Спуск в группу сегмента не тратит, поэтому на каждом шаге кандидатов теперь несколько: буквальный ребёнок, любая из групп, переменная папка. Хуже того, кандидат уводит в тупик: вошли в группу, а нужной страницы там нет — надо вернуться к развилке и попробовать соседнюю. Цикл с одной переменной node назад не умеет, он уже забыл, где сворачивал.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Все четыре кирпича на месте. Список tries — наша запись приоритета из статьи про динамические сегменты: сначала буквальная папка, потом группы, потом переменная. А цикл под ним — та самая способность передумать: walk вернул null, и проба идёт дальше вместо немедленного 404. Ради этой способности всё и переписывалось.

Настоящий Next по дереву на каждый запрос не ходит: он читает его один раз при сборке, выбрасывает группы из путей и сортирует получившуюся плоскую таблицу по точности — буквальный сегмент раньше [id]. Наш перебор в глубину этого не повторяет, и разница видна на двух группах.

Положим переменную папку в одну группу, а буквальную — в другую. Для Next это два разных адреса, /[id] и /about, и конфликта между ними нет. Прогноз: что ответит наша миниатюра на /about?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Товар about». Группа (shop) стоит первой, перебор нырнул в неё и нашёл [id], которому подходит что угодно, — до буквального about во второй группе очередь не дошла. Next ответит «О проекте»: он сравнивает не узлы по дороге, а адреса после выбрасывания групп, и /about точнее /[id], в какой бы группе ни лежал. Чтобы миниатюра вела себя так же, ей пришлось бы сначала собрать все находки и только потом выбрать самую точную — это и есть та сортировка, которую Next делает один раз при сборке.

Имя ключаТратит сегментЧто кладёт в находку
aboutданичего: обычный шаг вниз
[id]дапару id → значение сегмента в params
(marketing)нетсвою раскладку — если она у узла есть
поле layout—функцию в стопку обёрток
поле page—саму страницу; без неё находки не будет

Осталось оформить находку в ответ. Сопоставление вернуло три вещи — стопку обёрток, страницу и параметры, — и это ровно то, чем Next кормит отрисовку: цепочку раскладок, компонент страницы и params. Отрисовка отделена от поиска намеренно: искать можно без React, а рисовать — не зная, как искали.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Шесть адресов, и каждый отвечает так же, как ответил бы Next. /tasks — честный 404 при живой раскладке над ним. /tasks/42 — тот самый адрес, на котором сломался роутер из «Зачем фреймворк»: теперь он не только находится, но и приносит разобранный params. /tasks/42/edit — снова 404, потому что переменная папка занимает ровно один сегмент, как и любая другая.

Сверим это с той самой таблицей долгов. Половина строк закрылась сорока строками кода.

Что придётся дописать самомуЧем это закрылось
Адреса с переменной частьюключ в скобках: имя — ключ params, сегмент — значение
Незнакомый адрес и свой экран 404walk вернул null — значит, статус 404, а не пустая страница со статусом 200
Общая шапка, которая не пересоздаётся при переходестопка раскладок; а переиспользование общей части — уже забота React, не роутера
Прокрутка, фокус, объявление экранане закрылось: это не про поиск страницы, а про переход в браузере — следующая статья
Разрезание бандла по экранамне закрылось: это работа сборщика, и своими силами тут не обойтись
Данные до отрисовки, а не послене закрылось: об этом глава про серверные компоненты

И то и другое честно. Сопоставление адреса действительно небольшая задача — её и правда пишут за вечер. А незакрытые строки требуют не алгоритма, а хозяина сборки и сервера сразу; ровно об этом и была статья «Зачем фреймворк».

Остался последний вопрос, ради которого всё затевалось. Где в нашем маршрутизаторе список адресов? Его нет — есть дерево, из которого список выводится в любой момент.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Четыре адреса, и /tasks среди них нет: узел без page в карту не попал. Такую же карту Next строит при сборке и по тем же правилам — скобки сегмента не добавляют, [id] остаётся в записи как обозначение переменной части, узел без страницы пропускается. Из этой карты, кстати, и берутся сообщения о конфликте маршрутов: два узла, давших одинаковую строку.

Отсюда и ответ на вопрос из вступления. «Второго источника правды у адреса не появляется» — это было сказано ещё в статье про App Router; теперь под этой фразой лежит код. Конфиг маршрутов — источник как раз второй: он живёт отдельно от файлов, а значит, может с ними разойтись, и рано или поздно расходится. У дерева расходиться не с чем: переименовали папку — поменялся адрес, удалили page.tsx — адрес исчез. Не потому что фреймворк следит за файлами, а потому что адреса нигде и не было, кроме как в них.

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

  • Дерево папок описывается обычным объектом: имя папки — ключ, особые файлы — поля узла, и никакого списка маршрутов рядом не заводится.
  • page — единственное поле, создающее адрес: узел без него отвечает 404, сколько бы раскладок над ним ни стояло.
  • Раскладки копятся в стопку по дороге вниз, а надеваются с конца: reduceRight, иначе корневая обёртка окажется внутри ближней к странице.
  • Ключ в скобках подходит под любой сегмент, а его имя становится ключом в params; приоритет буквальной папки — это просто порядок двух проверок в коде.
  • Папка-группа сегмента не тратит, поэтому цикл по сегментам приходится заменить рекурсией с перебором кандидатов и возвратом к развилке.
  • Карта адресов не хранится нигде — она выводится обходом дерева, и потому разойтись с файлами ей не с чем: в этом весь смысл маршрутизации по файлам.

1/4Узел tasks в дереве есть, у него есть layout, а page нет. Что вернёт маршрутизатор по адресу /tasks?

2/4Стопка собралась по дороге вниз: [корневая, кабинет]. Что напечатает layouts.reduce((inner, layout) => layout(inner), "Архив")?

3/4В узле tasks лежат ключи "[id]" и "new". Функция выбора ребёнка сначала ищет ключ в скобках и только потом буквальный. Что произойдёт по адресу /tasks/new?

4/4Почему обход дерева с группами не оставить обычным циклом по сегментам адреса?

Шаблон внутри своей раскладки

В дереве появилось поле template — та самая обёртка из статьи про вложенные раскладки, которая пересоздаётся на каждом переходе. В стопку она пока не попадает. Добавьте её так, чтобы шаблон оказался внутри своей раскладки, а не снаружи: нужный вывод — [сайт [кабинет (шаблон Архив)]].

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Место в стопке — это и есть место во вложенности. Шаблон кладут сразу за раскладкой того же узла: тогда при свёртке с конца он применится раньше неё и окажется внутри.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок двух строк — это вся разница между «шаблон внутри раскладки» и «раскладка внутри шаблона». В настоящем Next она значит вот что: раскладка переживает переход, а всё, что под шаблоном, пересоздаётся вместе с ним — переставь строки местами, и пересоздаваться начнёт как раз раскладка.

Приватная папка

Папка, чьё имя начинается с подчёркивания, из маршрутизации выпадает целиком — про это правило говорилось в статье про App Router. Сейчас маршрутизатор о нём не знает и честно открывает черновик. Добавьте правило.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание, что одной строки хватило и на вложенные случаи: /help/_draft/deep тоже отвечает 404, потому что пройти мимо приватной папки к её детям физически негде — путь идёт через неё. В статье про App Router то же правило пришлось проверять по всем частям пути: там на входе был плоский список файлов, а не дерево.

Одна оговорка: правило Next говорит об имени папки, а не об адресе. Здесь это одно и то же — каждый ключ дерева совпадает со своим сегментом. Но в маршрутизаторе с переменной папкой проверять надо ключ ребёнка, а не сегмент: адрес /tasks/_x в настоящем Next достанется [id] со значением "_x", а не 404.

Найти конфликт маршрутов

Две группы завели по папке about, и обе претендуют на адрес /about. В статье про вложенные раскладки такой конфликт уже ловили — по готовому списку путей. У миниатюры списка нет, есть дерево, и карту адресов из него выводит routes. Допишите conflicts: она должна вернуть адреса, на которые претендует больше одной страницы.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Карта адресов уже собирается — остаётся найти в ней повторы. Множество из главы про Map и Set подходит лучше массива: проверка «уже встречалось» нужна на каждом шаге.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Главное в этой задаче — момент проверки. Конфликт виден при обходе дерева, то есть на сборке: спрашивать об этом в момент запроса поздно, да и незачем — отвечать всё равно придётся чем-то одним. Поэтому Next и падает со сборкой, а не выбирает страницу молча.