Свой проект: где кончается React Native и начинается Expo
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Три статьи подряд мы работали в песочнице и ловили её на расхождениях с телефоном. Пора завести проект, который на телефоне запустится. Но сначала — карта. В словах «приложение на React Native и Expo» спрятано несколько этажей. Один знаком — React из части IV, на нём пишутся экраны. Два других новые: внизу React Native — примитивы, рендерер, нативные модули, над ним Expo: модули SDK, expo-router, конфиг с prebuild и облачные сервисы EAS. Разложим стек по этажам и найдём место, где они стыкуются, — это одна строка с ключом "main". Потом создадим свой проект, запустим Metro и откроем первый экран на телефоне в руке. С этой статьи у каждого API в части помечено, на каком он этаже.
Этажи здесь — не метафора, а пакеты, от которых зависит проект. React — это компоненты, хуки и снимки состояния из части IV. Рисовать он не умеет сам: рисует рендерер, которого React не знает. Этаж React Native даёт всё, что нужно на телефоне. Это примитивы View, Text, Pressable и FlatList, рендерер Fabric, превращающий элементы в нативные виды, и движок Hermes, в котором выполняется ваш JavaScript. Сюда же входят нативные модули, которые JS вызывает через JSI как обычные функции, и AppRegistry — место, где нативная половина приложения передаёт управление JS. Бандл собирает Metro, сборщик той же команды.
Над этим этажом стоит Expo. Он ничего из нижнего этажа не переписывает, а добавляет то, без чего проект не доходит до магазина. Это модули SDK (expo-secure-store, expo-notifications, expo-sqlite), маршрутизатор expo-router, конфиг app.json или app.config.js и prebuild, который генерирует из него папки ios/ и android/. Ещё — приложение Expo Go, команда npx expo start поверх Metro и облачные сервисы EAS: Build, Submit, Update. Сбоку от обоих этажей живут библиотеки сообщества — безопасные зоны, экраны, жесты, анимации. Это не React Native и не Expo, но Expo Go везёт их с собой, а npx expo install подбирает их версии под SDK.
| Этаж | Что на нём | Пакеты | В песочнице |
|---|---|---|---|
| React | компоненты, хуки, состояние, эффекты — часть IV | react | настоящий React 19 |
| React Native | примитивы и StyleSheet, рендерер Fabric, Hermes, нативные модули через JSI, AppRegistry | react-native | react-native-web вместо него |
| Expo | модули SDK, expo-router, конфиг и prebuild, Expo Go, expo start, сервисы EAS | expo, expo-*, @expo/* | ничего: пакеты Expo не собираются |
| сообщество | безопасные зоны, экраны, жесты, анимации, хранилище | react-native-safe-area-context, react-native-screens, react-native-reanimated… | нет |
Проверим карту на свежем проекте. В подложке — probe из прошлой статьи: она спрашивает рантайм, что лежит под именем из типов. В массиве — четыре зависимости, которые кладёт в package.json шаблон Expo SDK 57. Прогноз: что напечатают семь строк, особенно три последние, про имена из react-native? И какой из двух пакетов со строкой состояния окажется в песочнице?
Половины зависимостей в песочнице нет, и это ровно граница этажей. react и react-native лежат в глобалях, второй — в облике react-native-web, а оба пакета Expo существуют только в проекте. PermissionsAndroid — старый знакомый: имя из типов, которого нет в рантайме предпросмотра. А вот StatusBar нашёлся. Строка состояния есть на нижнем этаже, но свежий проект берёт её из expo-status-bar. Это не другая строка состояния, а надстройка над той же, и исходник это показывает:
Вклад Expo — перевести короткий style в barStyle, а при "auto" выбрать его по теме системы. Рисует строку StatusBar из react-native. Отсюда правило, по которому дальше в части помечен каждый API: этаж определяется пакетом в import, а не именем. import { StatusBar } from "react-native" выполнится в песочнице, from "expo-status-bar" — только в проекте, хотя внутри второго тот же компонент.
В первой статье главы мы прошли цепочку запуска: expo-router/entry, registerRootComponent из пакета expo, AppRegistry.registerComponent из React Native. Теперь посмотрим на место, где она переходит с этажа на этаж. Запускает приложение нативная половина. iOS или Android создаёт корневой вид и просит React Native выполнить JS-приложение с таким-то именем. JS к этому моменту должен положить в AppRegistry компонент под тем же именем. Имя — строковый ключ, договор двух половин, и в проекте на Expo он всегда "main". В iOS-половине Expo 57 это прямо константа defaultModuleName = "main" в ExpoReactNativeFactory.swift. Android-половина отдаёт то же имя из getMainComponentName в MainActivity, которую генерирует prebuild.
JS-сторону договора исполняет registerRootComponent. Без отладочных подробностей это несколько строк:
На телефоне функция только регистрирует: runApplication вызовет нативная половина, когда корневой вид будет готов. В вебе нативной половины нет, и Expo вызывает его сам, с узлом #root со страницы. Это ровно то, что делает mount в песочнице: создаёт корень React на узле root и рисует в нём дерево. Одна оговорка: в веб-версии проекта Metro подставит соседний файл registerRootComponent.web.tsx, и там AppRegistry — собственная урезанная копия Expo поверх react-dom/client. Логика та же.
AppRegistry — нижний этаж, и в песочнице он есть. Значит, верхний этаж можно собрать из него своими руками. Корень отдаёт помощник подложки hostRoot: примеру document не положен. Сработает веб-ветка: Platform.OS в песочнице — "web", как мы выяснили в прошлой статье, так что пример повторяет веб-версию проекта, а не телефон. Прогноз: в каком порядке появятся строка от runApplication, строка «зарегистрировано» и строки рендера?
Первой пришла строка самого runApplication: react-native-web в отладочной сборке сообщает, какое приложение и с какими параметрами запускает. Затем «зарегистрировано», и только потом рендер. runApplication, как и mount, рендер лишь планирует, а рисует React в одной из следующих задач. Эту особенность мы видели в первой статье главы. Строка запуска зависит от рантайма, и печатает её разный код:
| Где запущено | Строка запуска | Кто печатает |
|---|---|---|
| песочница | Running application "main" with appParams: … | AppRegistry из react-native-web 0.21.2, только в отладке |
| веб-версия проекта на Expo 57 | нет, если параметры обычные | копия AppRegistry в пакете expo печатает только при нестандартном корне, hydrate или initialProps |
| телефон | Running "main" with {…} | AppRegistry из React Native 0.86 в отладке; в терминал не попадает — Expo CLI вырезает её нарочно |
Теперь сломаем договор. Шаблон голого React Native, без Expo, регистрирует приложение под его именем из app.json: AppRegistry.registerComponent(appName, () => App). Привычку легко принести в проект на Expo, где нативная половина спросит "main". Прогноз: что напечатает пример и доберётся ли он до строки «запущено»?
Приложение зарегистрировано, но под другим ключом, и runApplication бросает исключение. «Запущено» не напечатано: исключение оборвало пример. Компилятор промолчал — ключ здесь просто строка, и сверить её с нативной половиной типам не с чем. На телефоне будет красный экран со своим текстом. Любопытно, что обе его подсказки уводят в сторону:
| Где | Сообщение |
|---|---|
| песочница | Invariant Violation: Application "main" has not been registered. This is either due to an import error during initialization or failure to call AppRegistry.registerComponent. |
| веб-версия проекта на Expo 57 | тот же текст, но обычным Error, без «Invariant Violation» |
| телефон, React Native 0.86 | "main" has not been registered. This can happen if: Metro запущен не из той папки или модуль упал при загрузке, и AppRegistry.registerComponent не был вызван |
Эту ошибку чаще даёт не чужое имя, а упавший импорт: модуль бросил исключение раньше, чем дошло до registerRootComponent. Ищите первое красное сообщение выше — причина там.
Для первого экрана на телефоне не нужны ни Xcode, ни Android Studio. Нужны Node — React Native 0.86 в поле engines требует ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0, — телефон с Expo Go из App Store или Google Play и одна сеть у телефона и компьютера. Expo Go — готовое приложение с нативной половиной, собранной командой Expo. От вас оно берёт только бандл, поэтому собирать ничего не придётся.
Первая команда скачивает шаблон и ставит зависимости, вторая запускает Metro. Шаблон выбран blank-typescript, а не шаблон по умолчанию. Тот сразу приносит expo-router, а маршрутизатор у нас появится в главе 5, после собственного. В пустом шаблоне точка входа видна целиком. Тег latest у шаблона в момент написания — 57.0.24, то есть SDK 57. Когда latest переедет на следующую версию, та же команда принесёт её.
Этажи видны уже здесь: две зависимости из четырёх — Expo. "main": "index.ts" — файл, с которого Metro начинает бандл. Все четыре скрипта — expo start, даже android и ios. Они открывают проект в Expo Go на эмуляторе или симуляторе, а не собирают своё приложение. Своя сборка — expo run:ios, как в скриптах приложения автора, — появится в главе 10.
index.ts — ровно одна строка из прошлого раздела: регистрация под ключом "main". А в App.tsx кладём экран, который только что выполнялся в песочнице. Меняются только первые строки: глобали React и ReactNative уступают место импортам, и у каждого импорта свой этаж.
Теперь npx expo start. Терминал печатает QR-код и меню клавиш:
Metro теперь — сервер на компьютере, порт 8081. В QR-коде закодирован адрес exp://…: exp — схема ссылок Expo Go. На Android код сканируют из самого Expo Go, на iPhone — обычной камерой, и система предложит открыть ссылку в Expo Go. Телефон идёт к Metro за бандлом, Metro собирает его и отчитывается в том же терминале. После двух нажатий на «Записать подход» там окажется:
console.log с телефона приходит в терминал: в отладке React Native 0.86 пересылает консоль в Metro. Сделано это через старый путь, помеченный в setUpDeveloperTools.js как «Remove legacy Metro log forwarding», так что надёжное место для логов — React Native DevTools, клавиша j. Строки Running "main" with {…} в терминале нет, хотя телефон её печатает: Expo CLI отфильтровывает её регулярным выражением. В нём, кстати, записаны обе формулировки, и React Native, и react-native-web.
Последняя проба — поправить экран, не трогая телефон. Замените «Жим лёжа» на «Присед» и сохраните файл. На телефоне мелькнёт плашка «Refreshing...», заголовок сменится, а счётчик останется на двойке. Это Fast Refresh — тот, что в части IV мимоходом перезапускал эффекты в статье «Уборка, двойной запуск и гонки запросов»: он подменяет изменённый модуль и сохраняет состояние компонентов. Перезагрузить приложение целиком, со сбросом состояния, — клавиша r в терминале.
| Что видно | Причина | Что делать |
|---|---|---|
| телефон долго грузит проект по QR-коду и не открывает | телефон не видит компьютер: разные сети или сеть не пускает устройства друг к другу | одна сеть Wi-Fi; если не помогло — npx expo start --tunnel, трафик пойдёт через туннель |
| Expo Go ругается на версию SDK | Expo Go из магазина собран под одну версию SDK | держать проект на той же SDK, что и Expo Go, или собрать свой клиент — глава 10 |
красный экран "main" has not been registered | ключ не совпал, импорт упал при загрузке или Metro запущен не из папки проекта | первое красное сообщение выше; npx expo start — из корня проекта |
Почему часть начинается с Expo, а не с голого React Native? Голый React Native — это часы Xcode, Gradle и CocoaPods до первого экрана, а затем переучивание под фреймворк, на котором всё равно пишут приложения. Документация React Native сама советует начинать с фреймворка и первым называет Expo. Почему тогда не с Expo целиком? Без нижнего этажа он превращается в магию, а замысел учебника — показать, из чего состоят фреймворки. Так же в части IV мы собирали своё, прежде чем взять Next.js.
Поэтому дальше у каждого API сказано, на каком он этаже. Нижний этаж выполняется в песочнице: там, где готового нет или его не видно, мы собираем миниатюру механизма. Настоящий Expo приходит там, где проект упирается в телефон, сборку и магазины, — листингом и записанным выводом.
| Глава | Нижний этаж: запускается в песочнице | Верхний этаж и сообщество: приходят листингом |
|---|---|---|
| 2. Разметка и стиль | свои create и flatten, резолвер файлов .ios, .android, .web — такой же, как в Metro | — |
| 3. Списки | своё «окно» на ScrollView, затем FlatList и SectionList | — |
| 4. Ввод и нажатия | автомат нажатия, свой Alert на Modal, свой провайдер отступов | react-native-safe-area-context |
| 5. Навигация | свой маршрутизатор со стеком и историей | expo-router |
| 6. Данные | хранилище, журнал операций, лестница миграций | expo-sqlite, expo-secure-store |
| 7. Синхронизация | партнёр синхронизации и цикл | сервер — в части VI |
| 8. Движение и жесты | своё анимируемое значение, Animated, система ответчика | Reanimated и gesture-handler |
| 9. Нативные возможности | реестр модулей | модули Expo SDK: разрешения, пикер, уведомления |
| 10. Настоящее приложение | плагин конфига как функция | prebuild, свой dev client, EAS Build, магазины |
| 11. Обновления по воздуху | манифест, сверка runtimeVersion, отпечаток | EAS Update |
| 12. Проект | «Подход» целиком, собранный из миниатюр глав 2–11 | сборка, магазин и первое обновление по воздуху |
Expo Go будет возить «Подход» почти всю часть. Когда появится нативный модуль, которого в нём нет, понадобится свой клиент — статья «Где кончается готовый клиент: свой dev client» в главе 10. То, что Expo генерирует обычный проект React Native и из него можно выйти, покажет статья перед ней — «Что Expo делает за вас: prebuild, конфиг и плагины». А песочница остаётся тем, чем была в прошлых статьях: React Native без Expo и без Metro. Всё, что в ней запускается, на телефоне запустится тоже, но не всё, что запускается на телефоне, в ней есть.
- Стек в три этажа. React — компоненты и хуки из части IV. React Native — примитивы, рендерер Fabric, Hermes, нативные модули через JSI и
AppRegistry. Expo — модули SDK, expo-router, конфиг и prebuild, Expo Go,expo startи сервисы EAS. Библиотеки сообщества — сбоку, их версии под SDK подбираетnpx expo install. - Этаж API определяется пакетом в
import, а не именем:StatusBarизreact-nativeвыполнится в песочнице, изexpo-status-bar— только в проекте, хотя это обёртка над тем же компонентом. В песочнице ровно два пакета — глобалиReactиReactNative. - Этажи стыкуются на ключе
"main". Нативная половина Expo запускает приложение с этим именем,registerRootComponentрегистрирует его черезAppRegistry.registerComponent, а в вебе сам вызываетrunApplicationна узле#root— так же, какmountв песочнице. - Регистрация под другим ключом — привычка голого React Native — даёт
"main" has not been registered. Тот же текст бывает, когда модуль упал при загрузке: ищите первое красное сообщение. - Свой проект:
npx create-expo-app@latest podhod --template blank-typescript, затемnpx expo start. Metro работает на компьютере, Expo Go скачивает бандл поexp://из QR-кода,console.logс телефона приходит в терминал с пометкойLOG, Fast Refresh сохраняет состояние при правке. - Дальше у каждого API помечен этаж: миниатюры собирают нижний этаж и выполняются в песочнице, настоящий Expo приходит листингом там, где проект упирается в телефон, сборку и магазины.
1/4Кто на телефоне вызывает AppRegistry.runApplication("main", …) в проекте на Expo?
2/4В проекте на Expo вместо registerRootComponent написали AppRegistry.registerComponent("podhod", () => App). Что будет?
3/4import { StatusBar } from "react-native" в песочнице работает, а from "expo-status-bar" — нет, хотя внутри второго тот же компонент. Почему?
4/4Что делает npx expo start?
Разложить зависимости по этажам
Функция floorOf делит зависимости приложения автора по этажам и ошибается на четырёх пакетах из тринадцати. Прогноз: в какие группы они попали сейчас? Затем исправьте функцию так, чтобы react-native стоял на своём этаже, react-native-web и остальные react-native-* ушли к сообществу, а @expo/metro-runtime — к Expo.
Сейчас startsWith("react") проверяется первым и забирает всё, что начинается с этих букв: react-native, react-native-web и react-native-screens оказались в React, до второй проверки дело не дошло. А @expo/metro-runtime начинается с @ и мимо startsWith("expo") прошёл в «прочее». Этаж нужно узнавать по точному имени или по области пакета, а не по первым буквам:
Вывод становится таким: «React: react, react-dom», «React Native: react-native», «сообщество: react-native-web, react-native-screens, @react-native-async-storage/async-storage, @appmetrica/react-native-analytics», «Expo: expo, expo-router, @expo/metro-runtime, expo-secure-store», «прочее: @tanstack/react-query, zustand».
Две тонкости. Область @react-native/ — это сам React Native: там его конфиг Metro и полифилы. А @react-native-async-storage/ и @react-native-community/ — сообщество, хотя отличаются одним дефисом. И "expo-" с дефисом вместо "expo" — не педантизм: голый префикс поймал бы чужой пакет вроде exponential-backoff.
Корень, которого нет
registerRootComponent из статьи получил имя корня параметром, а в шаблоне страницы корень назвали app. Сейчас пример падает на чужой строке внутри react-native-web. Сделайте так, чтобы ошибка называла причину, как это делает пакет expo, и ответьте: откуда взялся TypeError?
Проверить корень до runApplication:
Вывод — «Error: Required HTML element with id "app" was not found in the document HTML.»: тот же текст, что в registerRootComponent пакета expo 57, только там id всегда root, а проверка стоит под process.env.NODE_ENV !== "production". «Экран запущен» не печатается ни до, ни после правки — исключение обрывает пример. Разница в том, что теперь оно называет причину.
TypeError пришёл из отладочного лога runApplication в react-native-web: чтобы напечатать «Running application …», он читает rootTag.id, а rootTag равен null. До собственных проверок react-native-web дело не дошло. Поэтому Expo и проверяет корень сам, раньше: иначе опечатка в разметке выглядит как поломка чужой библиотеки.