expo-router: те же файлы, но со стеком
Здесь будет встроен видео-разбор темы (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, в песочницу он не собирается, так что его файлы в этой статье — листинги:
Имена в Stack.Screen считаются тем же правилом, что во второй задаче статьи «Раскладки и Slot»: относительно папки раскладки. Группа (tabs) со своей раскладкой видна корню одним ребёнком, а карточка — путём exercise/[id]. Сами маршруты перечислять не нужно, их даёт папка; Stack.Screen только настраивает экран. Шапку с кнопкой «назад» нативный стек рисует сам, поэтому «‹ Назад» из карточки миниатюры в настоящий файл не переезжает:
Отличий от миниатюры в этом файле два. 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?
Нижнюю карточку никто не трогал, а её эффект сработал ещё дважды: сперва для наклонной скамьи, потом снова для жима. Строки «local» в ней не меняются — это её собственный адрес. Строка «global» отвечает на другой вопрос — где человек сейчас. Появилась карточка сверху — и нижняя увидела чужой id. Будь в эффекте загрузка подходов, спрятанная карточка жима загрузила бы в себя историю наклонной скамьи. Верхняя карточка при этом вела бы себя безупречно, так что ошибку искали бы долго.
Настоящие хуки устроены так же, как миниатюра. Каждый экран expo-router оборачивает в компонент Route, и тот кладёт в контекст параметры именно этой записи. useLocalSearchParams читает этот контекст. А useGlobalSearchParams идёт в общее хранилище роутера:
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.tsx | HTML-обёртка веб-версии; её берёт отдельный 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. Что откроется по каждому из четырёх адресов?
Экрана истории подходов ещё нет, и /exercise/bench/history достался ближайшему +not-found — тому, что в папке exercise, с хвостом ["bench", "history"]. А /exercise ушёл в корень, хотя папка та же: хвосту нужен хотя бы один сегмент после своей папки, как и [...rest]. Настоящие getRoutes и getStateFromPath из expo-router 57.0.16 на тех же ключах ответили точно так же, вплоть до массивов в параметре not-found. В самом файле хвост читают тем же хуком, что и id:
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 вызовет функцию — один, два или много?
Компилятор промолчал: типы 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 | Где собрано |
|---|---|---|
объект app | require.context, который Metro разворачивает при сборке | «Собираем маршруты» |
routesFrom и match | getRoutes и getStateFromPath | «Собираем маршруты», «Раскладки и Slot» |
Slot — контекст со звеном цепочки | Slot — навигатор, рисующий один маршрут | «Раскладки и Slot» |
useStack, Stack | StackRouter и нативный стек react-native-screens | «Собираем стек экранов с историей» |
| свой и общий контекст параметров | LocalRouteParamsContext и useRouteInfo | эта статья |
запасная notFound | +not-found как хвост своей папки | эта статья |
В песочнице переходы «Подхода» до конца части будет делать миниатюра. Ей остаётся одно испытание — переход, пришедший снаружи: из ссылки в письме или из нажатия на уведомление, когда навигатор, возможно, ещё не смонтирован. Этим займётся следующая статья.
Всё, что собрано в статье, одним куском. Навигатор держит все записи стека, кладёт каждой её параметры, а верхние — в общий контекст. Незнакомый адрес он отдаёт ближайшему +not-found, а если такого нет — системному экрану. Маршрутизатор, стек useStack и подписка useBackButton лежат в подложке. Этот кусок уезжает в подложку следующей статьи.
Нажатие «история подходов» положило в стек адрес, которого у «Подхода» пока нет, — и на экране оказался 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 тому же. Сейчас запрос прилипает к последнему сегменту, а справочник с запросом не находится вовсе.
Адрес сначала режется по ?: сегменты берутся только из пути.
А когда маршрут подошёл, в его параметры доливается запрос — после пути, чтобы ключи пути уже были на месте:
Вывод становится таким: { 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, здесь нет.
Просмотры экранов в корневой раскладке
Корневая раскладка должна записывать каждый просмотр экрана с его параметрами — так в приложениях подключают аналитику. В подложке — навигатор этой статьи с обоими хуками. Раскладка уже пишет просмотры, но выбрала не тот хук: строка появилась одна, и та пустая. Почините раскладку; экраны не трогайте.
Одно слово:
Вывод становится таким: «просмотр: {}», «просмотр: { id: "bench" }», «просмотр: { id: "incline" }», «нажата системная «назад»» и снова «просмотр: { id: "bench" }». Корневая раскладка стоит над стеком, а не внутри записи. Свой контекст ей никто не ставил, и useLocalSearchParams отдавал значение по умолчанию — один и тот же пустой объект. Зависимость эффекта не менялась ни разу, отсюда одна пустая строка. useGlobalSearchParams отвечает, где человек сейчас, — ровно то, что нужно аналитике. Описание хука в .d.ts expo-router называет этот случай прямо: аналитика и фоновые операции, которые ничего не рисуют. Правило из статьи работает в обе стороны: экранам — свои параметры, раскладке над стеком — общие.