expo-router: те же файлы, но со стеком

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

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

Миниатюра главы умеет всё, чего требовала статья «Экраны на useState»: адрес вместо ветки условия, раскладки, историю, системную «назад» и живой нижний экран. Теперь разложим те же экраны «Подхода» по файлам настоящего expo-router. Файлы почти те же, но после App Router из части IV здесь три неожиданности. Параметры экран получает не пропом-промисом, а одним из двух хуков, и в стеке эти хуки отвечают по-разному. Служебные файлы опознаются по плюсу в начале имени, и один из них — экран. А серверных компонентов нет вовсе: асинхронный экран React не примет. expo-router — этаж Expo, в песочницу он не собирается, поэтому его файлы здесь — листинги, проверенные компилятором на настоящих типах, а каждое отличие воспроизводит миниатюра.

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

В миниатюре экраны лежат в объекте app: ключ — путь файла, значение — компонент. В проекте на Expo этот объект никто не пишет. Ключи — настоящие файлы в папке app, а сам объект при сборке собирает Metro по require.context — это показала статья «Собираем маршруты». Поэтому переезд почти механический: каждый ключ становится файлом, каждое значение — его export default.

Файл в appКлюч в миниатюреЧто меняется при переезде
_layout.tsx"./_layout.tsx"на место своего Stack встаёт <Stack /> из expo-router
(tabs)/_layout.tsx"./(tabs)/_layout.tsx"на место TabBar и Slot встаёт <Tabs /> — вложенный навигатор со своей историей
(tabs)/index.tsx, (tabs)/catalog.tsxте жеnav.push становится router.push или <Link href>
exercise/[id].tsx"./exercise/[id].tsx"параметры — хуком, а не пропом; «‹ Назад» рисует шапка стека
+not-found.tsxнетэкран для незнакомого адреса — третий раздел

Корневая раскладка остаётся раскладкой, только Stack в ней теперь настоящий. expo-router — этаж Expo, в песочницу он не собирается, так что его файлы в этой статье — листинги:

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

Имена в Stack.Screen считаются тем же правилом, что во второй задаче статьи «Раскладки и Slot»: относительно папки раскладки. Группа (tabs) со своей раскладкой видна корню одним ребёнком, а карточка — путём exercise/[id]. Сами маршруты перечислять не нужно, их даёт папка; Stack.Screen только настраивает экран. Шапку с кнопкой «назад» нативный стек рисует сам, поэтому «‹ Назад» из карточки миниатюры в настоящий файл не переезжает:

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

Отличий от миниатюры в этом файле два. nav.push стал router.push — объектом переходов, который expo-router экспортирует для вызова откуда угодно, в том числе снаружи React. И экран больше ничего не получает пропсами: у миниатюры в них лежали params и nav, а настоящий экран вызывают без них. В build/useScreens.js обёртка BaseRoute нарочно вынимает из пропсов route и navigation, которые передаёт react-navigation. Комментарий рядом объясняет причину: параметры должны читаться хуками, потому что строка запроса правильная только у них. Каких хуков два и чем они различаются — следующий раздел.

Листинги статьи в песочнице не запустить, зато их проверил tsc 6.0.3 на настоящих типах expo-router 57.0.16 и react-native 0.86 — ни одной ошибки. Чтобы убедиться, что проверка смотрит в эти типы, а не в пустоту, рядом положили пробный файл с тремя заведомыми ошибками: router.push(42), опечатка nme в Stack.Screen и toFixed у строкового id. Нашлись все три.

Вкладки в expo-router 57 берут из expo-router/js-tabs: у Tabs из самого expo-router в типах стоит @deprecated с этой подсказкой. В приложении автора импорт ещё старый — работает, но редактор его зачёркивает.

В части IV параметры приходили странице пропом params, а с Next.js 15 — промисом, который ждали через await или use(); из глубины дерева их доставал useParams(). Вопроса «чьи это параметры» там не возникало: адрес один на всё окно, и страница на экране одна. В expo-router пропа нет, а хуков два. useLocalSearchParams() отвечает параметрами той записи стека, в которой стоит компонент. useGlobalSearchParams() — параметрами экрана, который сейчас в фокусе. Пока экран один, ответы совпадают. Стек из прошлой статьи их разводит: две карточки одного файла exercise/[id].tsx смонтированы одновременно.

В миниатюре оба хука — контексты (как в части IV). Свой Provider стоит вокруг каждой записи стека со значением её параметров, общий — один над всем стеком, со значением верхней записи. Подложка — справочник, экраны, маршрутизатор, стек useStack и подписка useBackButton из прошлых статей. Карточка отмечает в эффекте, для какого id он сработал: в настоящем экране на этом месте загружались бы подходы. Прогноз: человек открывает жим лёжа, оттуда похожее упражнение и жмёт системную «назад». Сколько раз сработает эффект нижней карточки и с какими id?

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

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

Настоящие хуки устроены так же, как миниатюра. Каждый экран expo-router оборачивает в компонент Route, и тот кладёт в контекст параметры именно этой записи. useLocalSearchParams читает этот контекст. А useGlobalSearchParams идёт в общее хранилище роутера:

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

useRouteInfo подписан на хранилище через useSyncExternalStore (как в статье «Свой хук» части IV), поэтому компонент с глобальными параметрами перерисовывается при любом переходе, в фокусе он или нет. А заморозки нижних экранов в expo-router по умолчанию нет — об этом прошлая статья. Так что на телефоне нижняя карточка повела бы себя как в песочнице. Описание хука в его .d.ts говорит то же своими словами: хук обновляется и вне фокуса, годится для аналитики и фоновой работы, а в стеке советует брать локальные параметры. Отсюда правило: экран читает useLocalSearchParams, а useGlobalSearchParams остаётся тому, кто стоит над стеком, — это вторая задача.

App Router (часть IV)expo-router 57
параметры экранапроп params, промис: await или use()хук, отвечает сразу, без ожидания; пропа нет
из глубины дереваuseParams() в клиентском компонентете же два хука в любом компоненте экрана
строка запросаотдельно: проп searchParams и useSearchParams()в тех же объектах: /exercise/bench?set=3 → { id: "bench", set: "3" }
экранов на адресодна страницастек: несколько экранов одного файла; local — свой, global — верхнего

Строку запроса миниатюра пока не разбирает вовсе — ?set=3 прилипает к последнему сегменту. Как expo-router смешивает запрос с сегментами пути и кто из них главнее, выясняет первая задача.

В части IV служебные файлы назывались обычными словами — page, layout, loading, error, not-found, route, — и их список приходилось знать наизусть. expo-router помечает их первым символом. Подчёркивание — у раскладки _layout и у системного _sitemap. Плюс — у файлов, которые не становятся обычными экранами. Уже toHref в статье «Собираем маршруты» отвечала null на оба префикса. Теперь разберём, что стоит за плюсом:

ФайлЧто делаетБлижайшее в части IVГде работает
+not-found.tsxэкран для адреса, которому не нашлось файла; кладётся в любую папкуnot-found.tsxтелефон и веб
+html.tsxHTML-обёртка веб-версии; её берёт отдельный require.context из _ctx-html.jsкорневой layout.tsx с <html>только веб, только в корне app
+native-intent.tsxфункция redirectSystemPath переписывает входящую ссылку до подбора маршрута—телефон, только в корне; входящие ссылки — тема следующей статьи
имя+api.tsобработчик запроса: адрес без экранаroute.tsсервер; в бандл телефона не попадает
+middleware.tsкод до выбора маршрутаproxy.tsсервер, только в корне; без флага плагина expo-router пишет «Server middleware is not enabled»

Четыре нижних файла экранами не становятся. +html, +api и +middleware отсекает ещё выражение в require.context — то, что статья «Собираем маршруты» показывала для веба; в _ctx.ios.js и _ctx.android.js оно отсекает те же три. +native-intent в бандл телефона попадает — там он и нужен, — но getRoutes держит его в списке исключений вместе с корневым +html. Плюс в начале имени — не пожелание, а правило. Прогон настоящего getRoutes на ключе ./+stats.tsx падает с ошибкой «Invalid route ./+stats.tsx. Route nodes cannot start with the '+' character. "Rename it to stats.tsx"». С той же ошибкой падает и ./exercise/+html.tsx: вне корня +html — просто запрещённое имя. Исключение сделано ровно одно — для +not-found, это прямо написано в комментарии к проверке.

Почему +not-found — экран, а не служебная ветка в подборе? Потому что getRoutes превращает его в обычный динамический сегмент — хвост вида [...rest] из первой задачи статьи «Собираем маршруты». Имя параметра у хвоста — not-found, без плюса. При сортировке маршрутов у него своя ветка в getStateFromPath-forks.js: он проигрывает даже обычному хвосту [...rest], так что достаётся ему только то, что не взял никто. Если своего +not-found в app нет, getRoutes добавляет системный — вместе с системным _sitemap. В миниатюре хвост удобнее вынести в запасную функцию notFound: она вызывается, когда match не нашёл ничего. Прогноз: в корне и в папке exercise лежит по своему +not-found.tsx. Что откроется по каждому из четырёх адресов?

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

Экрана истории подходов ещё нет, и /exercise/bench/history достался ближайшему +not-found — тому, что в папке exercise, с хвостом ["bench", "history"]. А /exercise ушёл в корень, хотя папка та же: хвосту нужен хотя бы один сегмент после своей папки, как и [...rest]. Настоящие getRoutes и getStateFromPath из expo-router 57.0.16 на тех же ключах ответили точно так же, вплоть до массивов в параметре not-found. В самом файле хвост читают тем же хуком, что и id:

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

Stack.Screen внутри экрана задаёт ему заголовок в шапке стека, а Link — переход по адресу. Файлов error.tsx и loading.tsx в expo-router нет, их роли достались экспортам из файла маршрута. Если файл экспортирует функцию ErrorBoundary, useScreens.js оборачивает экран границей ошибок с ней в роли запасной разметки; пропсы у неё — error и retry. Раскладка может экспортировать SuspenseFallback: в синхронном режиме импорта, который включён по умолчанию, это запасная разметка для Suspense, которым useScreens.js оборачивает саму раскладку и, через контекст, её дочерние экраны. Граница ошибок и Suspense те же, что в части IV, изменилось только место, где их объявляют.

Страница в app Next.js по умолчанию была серверным компонентом: асинхронная функция, await params, запрос в базу прямо в теле, а 'use client' отмечал границу. У expo-router этой границы нет. На телефоне нет сервера, который отрисовал бы экран заранее: весь бандл уже на устройстве, и каждый файл в app — обычный клиентский компонент, как все примеры части. 'use client' писать не нужно, асинхронным экран быть не может. Опаснее всего привычка из части IV — страница, которая ждёт данные сама. Прогноз: что будет, если смонтировать такой экран? Появится название упражнения, ошибка или что-то третье? И сколько раз React вызовет функцию — один, два или много?

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

Компилятор промолчал: типы React 19 разрешают асинхронный компонент, ведь где он выполнится, им неизвестно. Возражает рантайм. Первая строка — предупреждение React: асинхронными пока бывают только серверные компоненты. Дальше React обошёлся с возвращённым промисом как с неготовым значением в use(): приостановил отрисовку, дождался и вызвал функцию снова. Но каждый вызов создаёт новый промис, и круг не замыкается. Считает React не вызовы, а приостановки корня, и когда их становится больше ста — в его коде стоит проверка 100 < shellSuspendCounter, — сдаётся и бросает ошибку. Браузер прячет её текст, песочница говорит об этом строкой «Uncaught». Тот же пример в кадре без изоляции показал текст целиком: «An unknown Component is an async Client Component…» — первое предупреждение, только уже ошибкой и без имени компонента. Последней React добавляет свою строку «An error occurred in the <ExerciseScreen> component». Вместо Promise.resolve мог стоять запрос в сеть — 104 запроса за одно открытие экрана.

На телефоне будет то же самое: это проверка React, а не браузера. В рендерере React Native 0.86, ReactFabric-dev.js, есть и предупреждение про «async Client Component», и тот же предел в сотню приостановок корня. Данные экрану нужно не ждать в теле функции, а получать хуком — через use() с Suspense или эффектом, как в части IV. У «Подхода» есть путь проще: его данные живут на самом телефоне, и хранилище, которое читается за один такт, соберёт следующая глава.

Сервер у Expo всё же бывает — для веба. Файлы +api.ts — это код для сервера, как route.ts, только к экранам он отношения не имеет. А серверные компоненты в Expo — эксперимент: в app.json есть флаги experiments.reactServerComponentRoutes и experiments.reactServerFunctions, и описание обоих в типах конфига Expo 57 начинается со слова «Experimentally». «Подход» их не включает.

Сверим миниатюру с настоящим маршрутизатором по частям. Каждую строку этой таблицы глава собрала руками, и каждую проверила прогоном настоящего кода expo-router 57.0.16:

Миниатюраexpo-router 57Где собрано
объект apprequire.context, который Metro разворачивает при сборке«Собираем маршруты»
routesFrom и matchgetRoutes и getStateFromPath«Собираем маршруты», «Раскладки и Slot»
Slot — контекст со звеном цепочкиSlot — навигатор, рисующий один маршрут«Раскладки и Slot»
useStack, StackStackRouter и нативный стек react-native-screens«Собираем стек экранов с историей»
свой и общий контекст параметровLocalRouteParamsContext и useRouteInfoэта статья
запасная notFound+not-found как хвост своей папкиэта статья

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

Всё, что собрано в статье, одним куском. Навигатор держит все записи стека, кладёт каждой её параметры, а верхние — в общий контекст. Незнакомый адрес он отдаёт ближайшему +not-found, а если такого нет — системному экрану. Маршрутизатор, стек useStack и подписка useBackButton лежат в подложке. Этот кусок уезжает в подложку следующей статьи.

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

Нажатие «история подходов» положило в стек адрес, которого у «Подхода» пока нет, — и на экране оказался exercise/+not-found.tsx с хвостом bench/history. Первая «назад» сняла его, вторая — карточку, третья ушла системе.

  • Переезд миниатюры в expo-router механический: ключ объекта app становится файлом в папке app, значение — его export default. nav.push превращается в router.push, свой Stack — в <Stack /> из expo-router, а «‹ Назад» рисует шапка нативного стека.
  • Параметры экран не получает пропом. useLocalSearchParams отвечает параметрами своей записи стека, useGlobalSearchParams — экрана в фокусе. Нижний экран с глобальными параметрами видит чужой id и перезапускает эффекты, поэтому экранам — local, раскладке над стеком — global.
  • Строка запроса едет в тех же объектах, что и сегменты пути: повторившийся ключ даёт массив, а при совпадении имён побеждает путь. Раздельных params и searchParams, как в Next, нет.
  • Служебные файлы начинаются с плюса: +not-found, +html, +native-intent, +api, +middleware. Экран из них один — +not-found, хвост своей папки, которому нужен хотя бы один сегмент. Любой другой плюс в начале вложенного имени роняет сборку маршрутов.
  • Серверных компонентов в expo-router нет: каждый файл в app клиентский. Асинхронный экран компилятор пропустит, а React предупредит, будет вызывать его снова и снова и сдастся с ошибкой. Серверные компоненты в Expo — только экспериментальный флаг.

1/4В стеке карточка жима, над ней карточка жима на наклонной скамье — обе из файла exercise/[id].tsx. Нижняя читает id через useGlobalSearchParams(). Что она получит?

2/4Как экран app/exercise/[id].tsx получает id из адреса /exercise/bench?

3/4В app/exercise лежат [id].tsx и +not-found.tsx, в корне app — свой +not-found.tsx. Что откроется по адресу /exercise?

4/4Экран написан по привычке из Next: async function ExerciseScreen() ждёт данные через await. Что будет в приложении на expo-router?

Строка запроса — тоже параметры

Экран подхода открывают адресом /exercise/bench?set=3: номер подхода едет в строке запроса. expo-router кладёт ключи запроса в те же объекты параметров, что и сегменты пути. Повторившийся ключ даёт массив, а если ключ запроса совпал по имени с сегментом пути, побеждает путь. Научите match тому же. Сейчас запрос прилипает к последнему сегменту, а справочник с запросом не находится вовсе.

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

Адрес сначала режется по ?: сегменты берутся только из пути.

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

А когда маршрут подошёл, в его параметры доливается запрос — после пути, чтобы ключи пути уже были на месте:

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

Вывод становится таким: { id: "bench", set: "3" }, { q: ["жим", "тяга"] } и { id: "bench" }. Настоящий getStateFromPath из expo-router 57.0.16 на тех же адресах вернул те же объекты. На третьем адресе он ещё и предупредил в консоли: «Route '/exercise/[id]' with param 'id' was specified both in the path and as a param, removing from path». Несмотря на слова «removing from path», в прогоне id остался из пути: код сразу за предупреждением записывает ключ запроса только туда, где такого параметра ещё нет. Поэтому экрану, которому нужен номер подхода, всё равно, пришёл адрес из нажатия или из ссылки в письме: и id, и set он берёт одним хуком. Раздельных params и searchParams, как в части IV, здесь нет.

Просмотры экранов в корневой раскладке

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

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

Одно слово:

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

Вывод становится таким: «просмотр: {}», «просмотр: { id: "bench" }», «просмотр: { id: "incline" }», «нажата системная «назад»» и снова «просмотр: { id: "bench" }». Корневая раскладка стоит над стеком, а не внутри записи. Свой контекст ей никто не ставил, и useLocalSearchParams отдавал значение по умолчанию — один и тот же пустой объект. Зависимость эффекта не менялась ни разу, отсюда одна пустая строка. useGlobalSearchParams отвечает, где человек сейчас, — ровно то, что нужно аналитике. Описание хука в .d.ts expo-router называет этот случай прямо: аналитика и фоновые операции, которые ничего не рисуют. Правило из статьи работает в обе стороны: экранам — свои параметры, раскладке над стеком — общие.