Раскладки и Slot: обёртка без сегмента

навигация≈ 18 минут

Здесь будет встроен видео-разбор темы (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). Прогноз: человек переключается на «Справочник», а оттуда открывает жим лёжа. Сколько раз вкладки появятся на экране и сколько раз уйдут?

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

Переключение вкладки снесло полоску вкладок и построило её заново, хотя на экране она та же. ./index.tsx и ./catalog.tsx — разные компоненты, и Root на месте одного рисует другой; поддерево меняется целиком — по правилу из статьи «Когда состояние сохраняется, а когда сбрасывается» части IV. На телефоне за этим стоят нативные виды: полоска вкладок, её надписи и подсветка уничтожаются и создаются снова на каждом нажатии.

Вкладки — свойство не экрана, а раздела: всё, что в разделе, получает их, а всё, что вне, — нет. Маршрутизатору нужен способ сказать «эти файлы живут под общей обёрткой». В expo-router этот способ — файл.

Файл _layout.tsx оборачивает всё, что лежит в его папке и ниже. Значит, тренировку и справочник надо положить в одну папку, а раскладку с вкладками — рядом с ними. Но папка — это сегмент адреса: из tabs/catalog.tsx вышел бы /tabs/catalog. Поэтому папку называют в скобках, (tabs), — это группа. Всё это этаж Expo: _layout, группы и Slot принадлежат expo-router, в песочницу он не собирается, и мы, как и в прошлой статье, собираем то же поведение сами.

Прогноз: подложка — routesFrom и match из прошлой статьи, о группах они ничего не знают. Какие адреса окажутся в таблице и что найдётся по /catalog и /?

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

Оба _layout в таблицу не попали и так: toHref ещё в прошлой статье отвечала null на имена с подчёркиванием. А вот группа проехала в адрес как обычная папка, и ни тренировка, ни справочник по своим адресам больше не находятся.

Чинится это двумя чтениями одного пути. Адрес читается без групп, цепочка раскладок — по всем папкам, группы тоже. Это та же мысль, что chainOf в статье «Вложенные раскладки, группы и шаблоны» части IV. Разница во входе: у Next были папки со страницей page.tsx, здесь — плоский список ключей, где экран — сам файл, а раскладка — _layout.tsx в одной из папок на пути к нему.

Прогноз: какую цепочку получит карточка упражнения?

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

Карточке досталась только корневая раскладка: в её папке exercise своего _layout нет, а (tabs) на её пути не лежит. Тренировка и справочник получили обе. Группа (tabs) встала в цепочку, а из адреса выпала — ради этого расхождения группы и существуют.

В статье «Свой файловый маршрутизатор» перебор дерева в глубину споткнулся на двух группах: (shop)/[id] стоял первым и забирал адрес /about у буквального (info)/about. У маршрутизатора этой главы дерева нет: группы выброшены из адресов ещё при сборке таблицы, а таблица один раз отсортирована — буквальные выше переменных. Подложка — маршрутизатор этой статьи, уже с новой toHref. Прогноз: что найдётся по /about?

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

/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 изнутри:

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

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) появится на экране и когда уйдёт?

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

Переход со / на /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.tsxindex
(auth)/login.tsx_layout.tsx(auth)/login
(main)/_layout.tsx_layout.tsx(main)
(main)/profile.tsx(main)/_layout.tsxprofile
notes/[id].tsx_layout.tsxnotes/[id]

Группа (auth) — без раскладки, она только раскладывает файлы входа по одной папке, поэтому её экраны достаются корню с именами (auth)/login и соседними. Группа (main) — со своей раскладкой, и корень видит её одним ребёнком (main), а что внутри, решает уже её навигатор. Ровно эти имена стоят в коде автора:

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

Настройки экрана профиля поэтому живут не здесь, а в (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 ответил бы «не найден»:

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

В адресе /(auth)/login группа написана явно, и expo-router его находит. Выражение, которым он сверяет адрес с маршрутом, собирается по сегментам, и для группы там отдельная ветка:

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

Сегмент-группа становится необязательной частью выражения: (?:\(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.

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

Экраном таймера стал 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 — нет: у справочника группа другая, а у карточки её нет вовсе.

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

В route.parts групп уже нет, поэтому сегменты придётся взять из пути файла: там они на месте. Сравнение превращается в перебор с возвратом, как walk в «Своём файловом маршрутизаторе» части IV: на группе пробуем обе дороги — сначала с ней, потом без неё. Параметры возвращаются из удачной дороги, а не копятся по пути: иначе в них осталось бы значение из ветки, которая не подошла.

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

Вывод становится таким: /catalog и /(tabs)/catalog — справочник, /(tabs) — тренировка, а /(auth)/catalog и /(tabs)/exercise/bench по-прежнему не найдены. Это та же картина, что в прогоне настоящего expo-router. Соблазн проще — вырезать все группы из адреса до подбора — даёт неверный ответ: /(auth)/catalog превратился бы в /catalog и нашёлся, а expo-router отвечает на него экраном «не найдено».

Имена экранов относительно раскладки

Навигатору раскладки нужен список его детей, и expo-router строит его правилом из раздела «Экран поднимается к ближайшей раскладке»: ребёнок — это файл или вложенная раскладка, чья ближайшая раскладка — эта, а имя берётся относительно её папки. Напишите childrenOf. layoutsOf из статьи лежит в подложке: последний элемент её ответа — и есть ближайшая раскладка.

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

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

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

Корень получает ["(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 в конце пути сегментом не считается, группы в адрес не попадают, а на месте скобок в адресе стоит значение параметра.

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

Сегменты — это путь файла без ./, расширения и замыкающего index; адрес — те же сегменты без групп и с подставленными значениями.

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

Вывод: ["(tabs)"] → /, ["(tabs)", "catalog"] → /catalog, ["exercise", "[id]"] → /exercise/bench. Так же устроен getRouteInfoFromState.js в expo-router: сегменты складываются из имён маршрутов, последний index снимается, а при сборке адреса отфильтровываются сегменты в скобках. Заметьте третью строку: в сегментах остаётся [id], а не bench — сегменты описывают файл, а не адрес. Поэтому раскладка, которой нужно знать раздел, спрашивает useSegments, а экран, которому нужно значение, — параметры.