Раскладки и Slot: обёртка без сегмента
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Вкладки «Тренировка» и «Справочник» нужны двум экранам «Подхода», а карточке упражнения не нужны. Маршрутизатор из прошлой статьи знает только адрес и файл, поэтому вкладки пришлось бы рисовать в каждом экране — и при каждом переходе они строились бы заново. В expo-router общую обёртку задаёт файл _layout.tsx, а чтобы папка с ним не добавила в адрес сегмент, её имя берут в скобки. Идея та же, что у групп App Router из части IV, но устроено иначе: раскладка не получает children, а рисует <Slot />. Добавим в свой маршрутизатор третий шаг — цепочку раскладок и Slot — и разберём, что expo-router позволяет группе сверх части IV: например, стоять прямо в адресе.
В статье «Экраны на useState» вкладки стояли в App над веткой условия — и потому на карточке их не было. Маршрутизатор по адресу так не может: у Root есть адрес, таблица и файл экрана, а о том, что двум экранам нужна общая полоска вкладок, ему знать неоткуда. Остаётся рисовать вкладки внутри каждого экрана. Экраны Catalog, Workout и Card и маршрутизатор routesFrom с match лежат в подложке — те же, что в прошлой статье.
Полоска вкладок отмечается в эффекте: появилась — ушла (эффект с уборкой — как в части IV). Прогноз: человек переключается на «Справочник», а оттуда открывает жим лёжа. Сколько раз вкладки появятся на экране и сколько раз уйдут?
Переключение вкладки снесло полоску вкладок и построило её заново, хотя на экране она та же. ./index.tsx и ./catalog.tsx — разные компоненты, и Root на месте одного рисует другой; поддерево меняется целиком — по правилу из статьи «Когда состояние сохраняется, а когда сбрасывается» части IV. На телефоне за этим стоят нативные виды: полоска вкладок, её надписи и подсветка уничтожаются и создаются снова на каждом нажатии.
Вкладки — свойство не экрана, а раздела: всё, что в разделе, получает их, а всё, что вне, — нет. Маршрутизатору нужен способ сказать «эти файлы живут под общей обёрткой». В expo-router этот способ — файл.
Файл _layout.tsx оборачивает всё, что лежит в его папке и ниже. Значит, тренировку и справочник надо положить в одну папку, а раскладку с вкладками — рядом с ними. Но папка — это сегмент адреса: из tabs/catalog.tsx вышел бы /tabs/catalog. Поэтому папку называют в скобках, (tabs), — это группа. Всё это этаж Expo: _layout, группы и Slot принадлежат expo-router, в песочницу он не собирается, и мы, как и в прошлой статье, собираем то же поведение сами.
Прогноз: подложка — routesFrom и match из прошлой статьи, о группах они ничего не знают. Какие адреса окажутся в таблице и что найдётся по /catalog и /?
Оба _layout в таблицу не попали и так: toHref ещё в прошлой статье отвечала null на имена с подчёркиванием. А вот группа проехала в адрес как обычная папка, и ни тренировка, ни справочник по своим адресам больше не находятся.
Чинится это двумя чтениями одного пути. Адрес читается без групп, цепочка раскладок — по всем папкам, группы тоже. Это та же мысль, что chainOf в статье «Вложенные раскладки, группы и шаблоны» части IV. Разница во входе: у Next были папки со страницей page.tsx, здесь — плоский список ключей, где экран — сам файл, а раскладка — _layout.tsx в одной из папок на пути к нему.
Прогноз: какую цепочку получит карточка упражнения?
Карточке досталась только корневая раскладка: в её папке exercise своего _layout нет, а (tabs) на её пути не лежит. Тренировка и справочник получили обе. Группа (tabs) встала в цепочку, а из адреса выпала — ради этого расхождения группы и существуют.
В статье «Свой файловый маршрутизатор» перебор дерева в глубину споткнулся на двух группах: (shop)/[id] стоял первым и забирал адрес /about у буквального (info)/about. У маршрутизатора этой главы дерева нет: группы выброшены из адресов ещё при сборке таблицы, а таблица один раз отсортирована — буквальные выше переменных. Подложка — маршрутизатор этой статьи, уже с новой toHref. Прогноз: что найдётся по /about?
/about ушёл буквальному файлу, в какой бы группе тот ни лежал, а /42 — переменному. Настоящий expo-router на этих двух ключах ответил так же: прогон его getRoutes и getStateFromPath отдал /about файлу (info)/about, а /42 — (shop)/[id] с id, равным "42".
Группой бывает только папка. Файл (tabs).tsx expo-router не примет: getRoutes падает с «Invalid route ./(tabs).tsx. Routes cannot end with '(group)' syntax».
В App Router раскладка получала children: фреймворк сам клал страницу внутрь. В expo-router файл _layout.tsx экспортирует компонент без пропсов, и тот рисует либо <Slot />, либо навигатор — <Stack />, <Tabs />. Что встанет на это место, раскладке не передают — она берёт это сама. Вот Slot изнутри:
Slot — сам навигатор. Он собирает дочерние маршруты своей раскладки (contextKey — её папка), держит их состояние роутером стека и рисует ровно один — state.routes[state.index]. Поэтому раскладка и не получает children: сколько экранов рисовать под ней и какие, решает её навигатор. Slot рисует один. Stack — несколько друг над другом. Tabs рисует полоску вкладок и держит смонтированной каждую вкладку, в которую уже заходили: в BottomTabView из react-navigation, которую expo-router 57 везёт внутри себя, для этого заведён список loaded, а вкладку, где ещё не были, при lazy по умолчанию не рисуют вовсе. Готовые навигаторы — снова этаж Expo; в песочнице их нет.
В миниатюре Slot проще: контекст (как в части IV), в котором лежит следующее звено цепочки. Root сворачивает цепочку с конца, как стопку раскладок в «Своём файловом маршрутизаторе»: экран — в самый глубокий Slot, самое внутреннее звено — в Slot над ним. В подложке — экраны, вкладки TabBar из первого примера без строк в консоли и маршрутизатор этой статьи: у каждого маршрута теперь есть поле layouts.
Прогноз: человек переключается на «Справочник», открывает жим лёжа и возвращается. Когда раскладка (tabs) появится на экране и когда уйдёт?
Переход со / на /catalog раскладка не заметила: цепочка та же, сменилось только последнее звено, и React оставил раскладку на её месте. Вкладки больше не строятся заново. А карточка лежит вне группы: на её адресе в Slot корневой раскладки встал экран, раскладка (tabs) ушла вместе со своими нативными видами, а на обратном пути построилась новая.
Экраны под Slot по-прежнему сменяют друг друга целиком: тренировку, уходя, размонтировали, и справочник, если вернуться к нему, начнёт с прокруткой в нуле — как в статье «Экраны на useState». Настоящий Slot ведёт себя так же: он рисует только state.routes[state.index]. На телефоне раскладку вкладок написали бы через <Tabs />, и та сохранила бы обе вкладки. Но карточка лежит вне группы, и её появление уносит раскладку вместе со всеми вкладками. Держать нижний экран живым под верхним — работа стека, и это следующая статья.
Корневой _layout.tsx можно не писать: getRoutes тогда подставит сгенерированную раскладку — узел с выдуманным именем файла expo-router/build/views/Navigator.js, чей компонент — DefaultNavigator. На Android он рисует голый SlotNavigator, на остальных платформах — тот же SlotNavigator внутри SafeAreaView. Так записано в getRoutes.js и в DefaultNavigator из build/views/Navigator.js.
Раскладки не только оборачивают, но и владеют экранами: у навигатора каждой раскладки свой список дочерних маршрутов. Настоящий getRoutes строит из ключей дерево раскладок, и правило записано в комментарии к нему: файл из папки без _layout поднимается к ближайшей раскладке выше, а имя получает относительно её папки. Прогон на ключах приложения автора:
| Файл | Ближайшая раскладка | Имя у неё |
|---|---|---|
index.tsx | _layout.tsx | index |
(auth)/login.tsx | _layout.tsx | (auth)/login |
(main)/_layout.tsx | _layout.tsx | (main) |
(main)/profile.tsx | (main)/_layout.tsx | profile |
notes/[id].tsx | _layout.tsx | notes/[id] |
Группа (auth) — без раскладки, она только раскладывает файлы входа по одной папке, поэтому её экраны достаются корню с именами (auth)/login и соседними. Группа (main) — со своей раскладкой, и корень видит её одним ребёнком (main), а что внутри, решает уже её навигатор. Ровно эти имена стоят в коде автора:
Настройки экрана профиля поэтому живут не здесь, а в (main)/_layout.tsx, у Tabs.Screen name="profile": корневой Stack профиля не видит. И ещё одна привычка автора из того же файла: в app/ лежат только однострочные реэкспорты, а тела раскладок — в src/. Комментарий в root-layout.tsx объясняет почему: в app/ нельзя держать файлы-спутники — роутер принял бы их за маршруты. Это не перестраховка. Выражение, с которым expo-router вызывает require.context, берёт из app/ каждый файл .ts и .tsx, и прогон getRoutes на ключах ./index.tsx и ./index.types.ts вернул два маршрута — index и index.types. Суффикс .types не платформа, поэтому он остаётся частью имени. Вычислить имена детей самому — вторая задача.
В раскладке вкладок автора стоит переход, на который наш match ответил бы «не найден»:
В адресе /(auth)/login группа написана явно, и expo-router его находит. Выражение, которым он сверяет адрес с маршрутом, собирается по сегментам, и для группы там отдельная ветка:
Сегмент-группа становится необязательной частью выражения: (?:\(auth\)\/)?. Поэтому один файл отвечает и на адрес без группы, и на адрес с ней, а чужая группа не подходит. Прогон настоящих getRoutes и getStateFromPath из expo-router 57.0.16 на ключах приложения автора и «Подхода»:
| Адрес | Что открыл expo-router |
|---|---|
/login | (auth)/login.tsx |
/(auth)/login | (auth)/login.tsx |
/profile | (main)/profile.tsx |
/(main)/profile | (main)/profile.tsx |
/(auth)/profile | экран «не найдено»: у профиля группа (main) |
/(tabs)/catalog | (tabs)/catalog.tsx «Подхода» |
/(tabs)/exercise/bench | экран «не найдено»: карточка «Подхода» лежит вне (tabs) |
Зачем писать группу, если адрес находится и без неё? Затем, что expo-router разрешает то, на чём Next из части IV падал при сборке: два файла на один адрес в разных группах. (a)/about.tsx и (b)/about.tsx для него — разные маршруты с именами (a)/about и (b)/about, и getRoutes собирает их без ошибки. А кому достанется голый /about, решает не правило, а порядок ключей: с ключами в порядке (a), (b) прогон отдал его (a)/about, с обратным — (b)/about. К тому же функция сортировки в getStateFromPath-forks.js учитывает группу, в которой человек сейчас. Надёжно попасть в нужный файл можно только адресом с группой: /(a)/about и /(b)/about открыли свои файлы при любом порядке. Научить этому свой match — первая задача.
Та же двойственность есть и у хуков, которыми экран спрашивает «где я». usePathname() отвечает адресом без групп — /catalog. useSegments() — путём файла по сегментам, с группами: ["(tabs)", "catalog"]. В исходнике build/global-state/getRouteInfoFromState.js это видно прямо: сегменты складываются из имён маршрутов, последний index снимается, а для адреса из них выбрасывается всё, что в скобках. По первому сегменту раскладка узнаёт, в каком разделе человек, — например, segments[0] === "(auth)" при проверке входа. Собрать оба ответа — третья задача.
Всё, что собрано в главе к этому месту, одним куском: адрес без групп, цепочка раскладок по папкам, выбор файла платформы, отсортированная таблица, подбор и Slot с функцией renderRoute, которая сворачивает цепочку вокруг экрана. Этот кусок уезжает в подложку статьи про стек. Контекст для Slot в подложке придётся создавать при первой отрисовке, а не сразу: подложка выполняется раньше, чем загружен React.
Экраном таймера стал timer.web.tsx: Platform.OS в песочнице — "web", как и в прошлой статье. На телефоне тот же код взял бы timer.tsx.
- Общую обёртку в expo-router задаёт
_layout.tsx: он оборачивает всё в своей папке и ниже, а сам адресом не становится. - Группа — папка в скобках. Путь файла читается дважды: адрес — без групп, цепочка раскладок — по всем папкам, группы тоже. Группа даёт обёртку, но не даёт сегмента.
- Раскладка не получает
children, а рисует<Slot />или навигатор: сколько экранов под ней, решает он. НастоящийSlotрисует один текущий маршрут,Tabsдержит посещённые вкладки; в миниатюреSlot— контекст со следующим звеном цепочки. - Переход внутри группы меняет только экран под
Slot, раскладка остаётся; выход из группы уносит раскладку вместе с её нативными видами. Нижний экран по-прежнему размонтируется — его сохранит стек. - Файл из папки без раскладки поднимается к ближайшей раскладке и называется относительно её папки: у корневого
Stackэто(auth)/login, но(main)целиком. - Группу можно написать в адресе: своя группа — необязательный сегмент, чужая даёт «не найдено». Только так надёжно попасть в один из двух файлов на один адрес.
useSegmentsотвечает путём с группами,usePathname— адресом без них.
1/4В папке app лежат (tabs)/_layout.tsx и (tabs)/catalog.tsx, а в корне — _layout.tsx. Какой адрес у справочника и что его обернёт?
2/4Тренировка и справочник лежат в группе (tabs), у группы своя раскладка со <Slot />. Человек переходит со / на /catalog. Что станет с раскладкой и с экраном тренировки?
3/4Почему раскладка в expo-router рисует <Slot /> или <Stack />, а не получает children?
4/4В раскладке стоит <Redirect href="/(auth)/login" />, файл экрана — app/(auth)/login.tsx. Куда приведёт переход?
Группа в адресе необязательна
Научите match тому, что делает expo-router: адрес может называть группу своего файла, а может не называть. /(tabs)/catalog и /(tabs) должны найти свои файлы, а /(auth)/catalog и /(tabs)/exercise/bench — нет: у справочника группа другая, а у карточки её нет вовсе.
В route.parts групп уже нет, поэтому сегменты придётся взять из пути файла: там они на месте. Сравнение превращается в перебор с возвратом, как walk в «Своём файловом маршрутизаторе» части IV: на группе пробуем обе дороги — сначала с ней, потом без неё. Параметры возвращаются из удачной дороги, а не копятся по пути: иначе в них осталось бы значение из ветки, которая не подошла.
Вывод становится таким: /catalog и /(tabs)/catalog — справочник, /(tabs) — тренировка, а /(auth)/catalog и /(tabs)/exercise/bench по-прежнему не найдены. Это та же картина, что в прогоне настоящего expo-router. Соблазн проще — вырезать все группы из адреса до подбора — даёт неверный ответ: /(auth)/catalog превратился бы в /catalog и нашёлся, а expo-router отвечает на него экраном «не найдено».
Имена экранов относительно раскладки
Навигатору раскладки нужен список его детей, и expo-router строит его правилом из раздела «Экран поднимается к ближайшей раскладке»: ребёнок — это файл или вложенная раскладка, чья ближайшая раскладка — эта, а имя берётся относительно её папки. Напишите childrenOf. layoutsOf из статьи лежит в подложке: последний элемент её ответа — и есть ближайшая раскладка.
Каждый ключ спрашиваем, чья он: берём его цепочку и смотрим на последнее звено. Раскладку спрашиваем от её папки, а не от неё самой.
Корень получает ["(auth)/forgot-password", "(auth)/login", "(auth)/register", "(main)", "index", "notes/[id]"], раскладка (main) — ["index", "profile"]. Это те же шесть имён, что в Stack.Screen корневой раскладки автора, и те же два, что у его Tabs.Screen. Порядок у настоящего getRoutes другой, index там первым: в flattenDirectoryTreeToRoutes он сначала берёт файлы самой папки и только потом обходит подпапки, а наш childrenOf идёт по ключам в том порядке, в каком они пришли.
useSegments и usePathname
Два хука expo-router отвечают на вопрос «где я» по-разному: useSegments() — путём файла по сегментам, с группами, usePathname() — тем, что видно в адресе. Напишите обе функции: index в конце пути сегментом не считается, группы в адрес не попадают, а на месте скобок в адресе стоит значение параметра.
Сегменты — это путь файла без ./, расширения и замыкающего index; адрес — те же сегменты без групп и с подставленными значениями.
Вывод: ["(tabs)"] → /, ["(tabs)", "catalog"] → /catalog, ["exercise", "[id]"] → /exercise/bench. Так же устроен getRouteInfoFromState.js в expo-router: сегменты складываются из имён маршрутов, последний index снимается, а при сборке адреса отфильтровываются сегменты в скобках. Заметьте третью строку: в сегментах остаётся [id], а не bench — сегменты описывают файл, а не адрес. Поэтому раскладка, которой нужно знать раздел, спрашивает useSegments, а экран, которому нужно значение, — параметры.