Собираем маршруты: путь файла — это адрес
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье у экранов «Подхода» не нашлось имён, и открыть карточку упражнения снаружи — из уведомления «отдых кончился» — было нечем. Имя экрана — адрес вида /exercise/squat. В expo-router его не пишут в конфиге: адрес даёт путь файла, как папка в App Router из части IV. Только на телефоне папки app нет — есть бандл и список путей, который вшил в него сборщик. Из этого списка и соберём первые два шага своего маршрутизатора: путь файла превратим в адрес, а адрес — обратно в файл и params, включая сегмент [id]. По дороге встретится то, чего в Next.js не было: суффиксы платформ .ios и .web в именах экранов.
Свой файловый маршрутизатор из части IV ходил по дереву папок: папка — узел, особые файлы — поля узла. Next.js это может себе позволить: дерево читается при сборке на машине разработчика и на сервере. На телефон же уезжает бандл JavaScript, и никакой папки app там нет — спросить у файловой системы, какие экраны существуют, некому.
Поэтому expo-router спрашивает не файловую систему, а сборщик. Точка входа expo-router/entry через entry-classic.js доходит до build/qualified-entry.js, и там корневой компонент ExpoRoot получает пропом context объект ctx из модуля expo-router/_ctx. В самом модуле одна инструкция — вызов require.context. Это не функция рантайма, а указание Metro: при сборке обойти папку, положить в бандл подходящие файлы и оставить на месте вызова функцию с методом keys(). Вот модуль целиком, вариант для веба:
Первый аргумент — папка app, второй — «с подпапками», третий — фильтр путей: файлы API и корневые служебные файлы — +html, +middleware, в веб-варианте ещё +native-intent — в список не попадают. Всё остальное с расширением .js, .jsx, .ts или .tsx становится ключом — путём от папки app, с ./ в начале и расширением в конце. Заметьте имя файла: _ctx.web.js. Рядом лежат _ctx.ios.js и _ctx.android.js, и Metro, собирая бандл под платформу, берёт файл с её суффиксом — к этому приёму мы вернёмся в следующем разделе.
Так выглядит папка app настоящего приложения автора — не «Подхода», а рабочего проекта на Expo SDK 57:
Здесь уже видно, чем expo-router отличается от App Router из части IV: экран — это сам файл, notes/[id].tsx, а не notes/[id]/page.tsx, и адрес папки даёт index.tsx, а не page.tsx. Адресом становится не каждый файл: _layout.tsx — обёртка, +html.tsx — служебный файл веб-сборки, а папки в скобках — группы, о них следующая статья. Остальные расхождения с частью IV — хуки параметров, служебные имена с плюсом — соберёт статья «expo-router: те же файлы, но со стеком».
В песочнице нет ни Metro, ни самого expo-router: это этаж Expo, и его пакеты сюда не собираются — об этом говорила прошлая статья. Так что require.context здесь не выполнится. Но после сборки на входе маршрутизатора всё равно лежит просто массив строк — его и возьмём. Первое правило превращает ключ в адрес: срезать ./ и расширение, служебные файлы пропустить, а index заменить адресом папки. Прогноз: что напечатает каждая из шести строк?
Корневой index.tsx дал /, а не пустую строку: выражение съело index целиком, и остался только приставленный спереди слеш. workout/index.tsx стал адресом своей папки. [id] остался в адресе как есть: это не значение, а место под значение, подставлять его будет подбор. А _layout и +not-found ответили null: файлы есть, но экранов по адресу у них нет.
Шаблон (^|\/)index$ — не перестраховка. С простым index$ файл reindex.tsx превратился бы в /re: выражение не знает, где кончается имя.
Статья «Одно приложение, три платформы» разводила код файлами .ios, .android и .web: сборщик сам брал файл своей платформы. В папке app тот же приём работает для целых экранов. timer.tsx, timer.ios.tsx и timer.web.tsx — это один адрес /timer, и каждая платформа выбирает себе файл. Правила записаны в функции getFileMeta из expo-router/build/getRoutesCore.js: файл чужой платформы пропускается целиком, файл своей точнее файла без суффикса.
Прогноз: какой файл таймера достанется iOS, Android и песочнице?
Android взял общий файл: своего у него нет, а .ios и .web для него чужие. Третья строка — честный ответ песочницы: Platform.OS здесь "web", и экраном таймера в предпросмотре стал бы timer.web.tsx. Код, написанный в timer.ios.tsx, песочница не увидит никогда — так же, как Android.
Настоящий expo-router считает то же самое баллами точности: 2 — своя платформа, 0 — файл без суффикса, −1 — чужая, такой файл выбрасывается. Есть и промежуточный балл 1 для .native — его добавит третья задача. Прогон настоящего getRoutes из expo-router 57.0.16 на тех же четырёх ключах даёт то же самое: iOS получает ./timer.ios.tsx, Android — ./timer.tsx, веб — ./timer.web.tsx.
А если общего файла нет? Экран статистики сделали только для iOS — stats.ios.tsx, без пары. Функция pick из прошлого примера лежит в подложке, а сверху — проверка из getMostSpecific того же модуля expo-router, с тем же текстом ошибки. Прогноз: что будет на iOS и что на Android?
Одна и та же папка, два разных исхода. На iOS сборка маршрутов падает с ошибкой, а на Android проходит молча, только экрана статистики в ней нет: stats.ios.tsx для Android чужой, выброшен ещё до проверки, и ссылка на /stats приведёт на экран «не найдено». Настоящий expo-router ведёт себя так же — прогон getRoutes дал ту же ошибку для iOS и тихую сборку без /stats для Android и веба. Разработчик с айфоном увидит ошибку и добавит общий файл, разработчик с Android не увидит ничего. Отсюда правило: у платформенного экрана всегда есть общий брат, хотя бы заглушка.
Сравните с резолвером из статьи «Одно приложение, три платформы». Там на вход приходил запрос — ./rest-sound, — и resolvePlatform искал под него один файл: звук, сделанный только в .ios и .android, на обоих телефонах находился, а останавливалась сборка веба, где подходящего файла нет. У маршрутизатора запроса нет. Ему отдают весь список, и он сам решает, какие адреса существуют, поэтому правило перевёрнуто: ошибку получает платформа, у которой файл есть, а та, у которой его нет, об экране просто не узнаёт. По той же причине resolvePlatform не поехал в подложку этой статьи — он отвечает на другой вопрос.
Два общих файла на один адрес — timer.tsx и timer.jsx — expo-router в разработке тоже не прощает: ошибка «The route files … conflict on the route "/timer"». В продакшн-сборке проверка выключена, и молча берётся первый найденный.
Теперь обратный путь. Адрес приходит строкой — из нажатия, из уведомления, из ссылки в письме, — и по нему надо найти файл экрана и достать значения для скобок. Таблица маршрутов здесь плоская: ключи и так пришли списком, складывать из них дерево незачем. Адрес маршрута режем на сегменты заранее, адрес перехода — в момент перехода, и сравниваем сегмент за сегментом.
Прогноз: что найдётся по каждому из пяти адресов?
/workout/ нашёлся: пустые куски после разрезания отброшены, и хвостовой слеш ничего не меняет. /exercise и /exercise/squat/history — нет: сегмент в скобках принимает что угодно, но ровно один кусок, и число сегментов обязано совпасть. Для «всего остального хвоста» есть отдельный вид скобок, [...rest], — это первая задача.
И обратите внимание на тип: params.id — строка, даже если в адресе было число. Разбирать и проверять значение — дело экрана, а не маршрутизатора; так было и в части IV. Одно отличие от Next.js всё же есть: там params приходил промисом, а expo-router отдаёт параметры экрану сразу, хуком. Каким именно из двух — разберёт статья «expo-router: те же файлы, но со стеком».
Добавим в «Подход» экран нового упражнения — exercise/new.tsx рядом с exercise/[id].tsx. Адрес /exercise/new подходит под оба файла: под первый буквально, под второй с id, равным "new". В части IV этот спор решал порядок двух проверок при спуске по дереву: сначала буквальная папка, потом переменная. Здесь дерева нет, а match берёт первое совпадение в таблице. Значит, всё решает порядок строк — а он пока такой, в каком пришли ключи.
Прогноз: ключи отсортированы по алфавиту. Что найдёт match по /exercise/new в неотсортированной таблице и в отсортированной?
В неотсортированной таблице /exercise/new открыл карточку упражнения с id, равным "new": ни ошибки, ни предупреждения, просто не тот экран. Виноват алфавит: [ в таблице символов стоит раньше строчных букв, и [id] оказался в списке первым. После сортировки буквальный маршрут встал выше, а /exercise/squat по-прежнему достался карточке — new.tsx на него не подходит.
Миниатюра сортирует таблицу один раз, когда её собирает: порядок у неё зависит только от файлов. Правило у expo-router то же. Его функция сравнения лежит в build/fork/getStateFromPath-forks.js, и одно из первых её правил записано прямо комментарием — статичные маршруты всегда выше динамических. Дальше идут уточнения: у кого больше статичных сегментов, тот выше, а хвост [...rest] — в самом конце. А вот момент сортировки другой. Среди уточнений есть «при равенстве выше маршрут из той группы, где человек сейчас», поэтому порядок зависит ещё и от текущего экрана. Кеш в build/fork/getStateFromPath.js потому закомментирован — с пометкой, что конфиги меняются вместе с состоянием, — и таблица пересортировывается при каждом разборе адреса. Наше «меньше скобок — выше» — упрощение того же правила: буквальный маршрут оно ставит выше динамического, а маршруты с равным числом скобок оставляет в том порядке, в каком пришли ключи. expo-router сравнивает и их — сегмент за сегментом, слева направо: из a/[b] и [a]/b адрес /a/b у него всегда достаётся первому, а в миниатюре — тому, что раньше в списке ключей. Для «Подхода» этого хватает: таких пар в нём нет.
Это та самая сортировка, которой не хватило перебору в глубину в статье «Свой файловый маршрутизатор»: там переменная папка из одной группы перехватывала буквальную из другой, и лечение было — собрать все находки и выбрать самую точную. Плоская таблица с одним проходом сортировки делает это даром. Как туда вписываются группы в скобках, покажет следующая статья.
Осталось поставить таблицу на место if из прошлой статьи. Миниатюра этой статьи — выбор файла платформы, адреса, отсортированная таблица и подбор — уехала в подложку двумя функциями: routesFrom(keys, platform) и match(routes, url); целиком она в свёртке ниже. Экраны Catalog, Workout и Card — те же, что в прошлой статье. А модель require.context — обычный объект: путь файла → компонент, который файл экспортирует по умолчанию.
В состоянии корня теперь лежит не имя экрана, а адрес — строка (сама ячейка состояния — как в части IV). Экран получает params и функцию go, а объект router позволяет перейти по адресу снаружи React — так это сделает обработчик нажатия на уведомление. Прогноз: какие пять строк напечатает корень?
Все пять переходов — одна и та же операция: адрес → match → файл → компонент. Строке тренировки и уведомлению не нужно знать, как устроено приложение, — только адрес. Закрылась последняя строка таблицы требований из прошлой статьи: у каждого экрана есть имя, и по имени на него можно попасть снаружи. Незнакомый адрес не роняет приложение, а даёт экран «не найдено» — в expo-router это файл +not-found.tsx, а если его нет, системный экран.
Остальные строки той таблицы на месте. «‹ Назад» в карточке зашита на go("/") и снова не знает, откуда пришли; справочник по-прежнему размонтируется при уходе — в состоянии лежит один адрес, а не список. А ещё у каждого экрана своя шапка там, где нужна общая: вкладки «Тренировка» и «Справочник» пришлось бы рисовать в каждом файле. Этим займутся следующие две статьи — сначала раскладки _layout, потом стек с историей.
Всё, что собрано в статье, одним куском: путь файла → адрес, выбор файла платформы с проверкой общего брата, таблица, отсортированная один раз, и подбор с параметрами. Этот кусок уезжает в подложку следующей статьи — к нему добавятся раскладки.
- На телефоне папки
appнет: expo-router получает список путей от Metro, который обходит её при сборке поrequire.context. Вход маршрутизатора — плоский массив строк вида./exercise/[id].tsx. - Путь файла становится адресом: срезаются
./и расширение,indexдаёт адрес своей папки, а_layoutи имена с плюсом адресом не становятся. - Суффикс платформы в адрес не входит. Своя платформа точнее общего файла, чужая выбрасывается, а платформенный экран без общего брата роняет сборку маршрутов только на своей платформе — на остальных маршрута просто нет.
- Подбор режет адрес на сегменты: буква должна совпасть, сегмент в скобках принимает любой кусок, но ровно один, и отдаёт его строкой в
params. - Таблицу сортируют до подбора: статичные маршруты выше динамических. Без этого
/exercise/newмолча откроет[id]сid, равным"new". - Адрес заменил ветку условия: переход изнутри и снаружи — одна операция. Истории, общей обёртки и сохранения нижнего экрана пока нет — это следующие две статьи.
1/4Откуда expo-router на телефоне знает, какие экраны лежат в папке app?
2/4В папке app лежат timer.tsx, timer.ios.tsx и timer.web.tsx. Какой файл станет экраном /timer на Android?
3/4Экран статистики есть только в файле stats.ios.tsx, общего stats.tsx нет. Что произойдёт?
4/4Рядом лежат exercise/[id].tsx и exercise/new.tsx. Таблица маршрутов не отсортирована, и [id] в ней первым. Что откроет /exercise/new?
Хвост адреса: [...rest]
Экран справки должен отвечать на любой адрес под /help/: /help/rest, /help/rest/timer и так далее. В expo-router для этого файл называют [...topic].tsx, и параметр приезжает массивом сегментов. Научите match такому сегменту. Сейчас /help/rest даёт параметр с именем ...topic, а /help/rest/timer не находится вовсе.
Хвост может стоять только последним сегментом, поэтому проверка длины раздваивается: у обычного маршрута сегментов должно быть ровно столько же, у маршрута с хвостом — не меньше.
Теперь /help/rest даёт { topic: ["rest"] }, а /help/rest/timer — { topic: ["rest", "timer"] }. /help по-прежнему достаётся index.tsx: хвосту нужен хотя бы один сегмент. Массив, а не строка через слеш, — так делает и expo-router: в getStateFromPath-forks.js значение хвоста режется по /. И не забудьте про сортировку: хвост подходит под что угодно длиннее себя, поэтому в таблице он должен стоять ниже любого маршрута с обычными скобками — в dynamic ему нужен вес больше, чем может набрать любой маршрут с обычными скобками.
Адрес из шаблона и параметров
Переходы в «Подходе» пока склеивают адрес руками: "/exercise/" + id. Напишите обратную операцию к подбору — toUrl, которая подставляет параметры в шаблон маршрута. Проверкой служит match из статьи: то, что подставили, должно вернуться тем же значением.
Каждый сегмент в скобках заменяется значением, и значение кодируется — иначе пробел или слеш внутри него сломали бы адрес.
Адрес становится /exercise/%D0%B6%D0%B8%D0%BC%20%D0%BB%D1%91%D0%B6%D0%B0, а match возвращает id: "жим лёжа": decodeURIComponent в подборе снимает то, что наложил encodeURIComponent. Без кодирования «жим лёжа» доехал бы с пробелом, а значение со слешем разрезалось бы на два сегмента и адрес не нашёлся бы вовсе. В expo-router такой переход записывают объектом — router.push({ pathname: "/exercise/[id]", params: { id } }), — и подстановку он делает сам.
.native: точнее общего, но не своего
Кроме .ios, .android и .web expo-router понимает суффикс .native — «любой телефон». В getFileMeta у него балл 1: точнее общего файла, но слабее файла своей платформы, а на вебе он чужой. Научите pick этому суффиксу. Сейчас pick его не узнаёт, считает timer.native.tsx отдельным экраном и отдаёт его на всех платформах, даже на вебе.
Выбор «перезаписать, если суффикс есть» превращается в сравнение баллов, и баллы приходится хранить рядом с файлом.
Вывод становится таким: iOS — ./timer.ios.tsx, Android — ./timer.native.tsx, веб — ./timer.tsx. Ровно так ответил и настоящий getRoutes из expo-router 57: на Android с timer.tsx и timer.native.tsx экраном становится .native, на вебе — общий файл.