Свой проект: где кончается React Native и начинается Expo

первый экран≈ 15 минут

Здесь будет встроен видео-разбор темы (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компоненты, хуки, состояние, эффекты — часть IVreactнастоящий React 19
React Nativeпримитивы и StyleSheet, рендерер Fabric, Hermes, нативные модули через JSI, AppRegistryreact-nativereact-native-web вместо него
Expoмодули SDK, expo-router, конфиг и prebuild, Expo Go, expo start, сервисы EASexpo, expo-*, @expo/*ничего: пакеты Expo не собираются
сообществобезопасные зоны, экраны, жесты, анимации, хранилищеreact-native-safe-area-context, react-native-screens, react-native-reanimated…нет

Проверим карту на свежем проекте. В подложке — probe из прошлой статьи: она спрашивает рантайм, что лежит под именем из типов. В массиве — четыре зависимости, которые кладёт в package.json шаблон Expo SDK 57. Прогноз: что напечатают семь строк, особенно три последние, про имена из react-native? И какой из двух пакетов со строкой состояния окажется в песочнице?

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

Половины зависимостей в песочнице нет, и это ровно граница этажей. react и react-native лежат в глобалях, второй — в облике react-native-web, а оба пакета Expo существуют только в проекте. PermissionsAndroid — старый знакомый: имя из типов, которого нет в рантайме предпросмотра. А вот StatusBar нашёлся. Строка состояния есть на нижнем этаже, но свежий проект берёт её из expo-status-bar. Это не другая строка состояния, а надстройка над той же, и исходник это показывает:

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

Вклад 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. Без отладочных подробностей это несколько строк:

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

На телефоне функция только регистрирует: runApplication вызовет нативная половина, когда корневой вид будет готов. В вебе нативной половины нет, и Expo вызывает его сам, с узлом #root со страницы. Это ровно то, что делает mount в песочнице: создаёт корень React на узле root и рисует в нём дерево. Одна оговорка: в веб-версии проекта Metro подставит соседний файл registerRootComponent.web.tsx, и там AppRegistry — собственная урезанная копия Expo поверх react-dom/client. Логика та же.

AppRegistry — нижний этаж, и в песочнице он есть. Значит, верхний этаж можно собрать из него своими руками. Корень отдаёт помощник подложки hostRoot: примеру document не положен. Сработает веб-ветка: Platform.OS в песочнице — "web", как мы выяснили в прошлой статье, так что пример повторяет веб-версию проекта, а не телефон. Прогноз: в каком порядке появятся строка от runApplication, строка «зарегистрировано» и строки рендера?

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

Первой пришла строка самого 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". Прогноз: что напечатает пример и доберётся ли он до строки «запущено»?

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

Приложение зарегистрировано, но под другим ключом, и 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. От вас оно берёт только бандл, поэтому собирать ничего не придётся.

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

Первая команда скачивает шаблон и ставит зависимости, вторая запускает Metro. Шаблон выбран blank-typescript, а не шаблон по умолчанию. Тот сразу приносит expo-router, а маршрутизатор у нас появится в главе 5, после собственного. В пустом шаблоне точка входа видна целиком. Тег latest у шаблона в момент написания — 57.0.24, то есть SDK 57. Когда latest переедет на следующую версию, та же команда принесёт её.

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

Этажи видны уже здесь: две зависимости из четырёх — Expo. "main": "index.ts" — файл, с которого Metro начинает бандл. Все четыре скрипта — expo start, даже android и ios. Они открывают проект в Expo Go на эмуляторе или симуляторе, а не собирают своё приложение. Своя сборка — expo run:ios, как в скриптах приложения автора, — появится в главе 10.

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

index.ts — ровно одна строка из прошлого раздела: регистрация под ключом "main". А в App.tsx кладём экран, который только что выполнялся в песочнице. Меняются только первые строки: глобали React и ReactNative уступают место импортам, и у каждого импорта свой этаж.

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

Теперь npx expo start. Терминал печатает QR-код и меню клавиш:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · строки из исходника @expo/cli 57.0.18 (startAsync.js, MetroTerminalReporter.js, interactiveActions.js, commandsTable.js) в порядке вывода, а не запись сеанса; адрес — для примера, строки о веб-версии и проверке зависимостей опущены
Starting project at /Users/…/podhod
Starting Metro Bundler
[QR-код]
› Scan the QR code above to open in Expo Go.
› Metro: exp://192.168.1.5:8081
› Using Expo Go
› Press s │ switch to development build
› Press a │ open Android
› Press i │ open iOS simulator
› Press w │ open web
› Press j │ open debugger
› Press r │ reload app
› Press m │ toggle menu
› shift+m │ more tools
› Press o │ open project code in your editor
› Press ? │ show all commands
Logs for your project will appear below. Press Ctrl+C to exit.

Metro теперь — сервер на компьютере, порт 8081. В QR-коде закодирован адрес exp://…: exp — схема ссылок Expo Go. На Android код сканируют из самого Expo Go, на iPhone — обычной камерой, и система предложит открыть ссылку в Expo Go. Телефон идёт к Metro за бандлом, Metro собирает его и отчитывается в том же терминале. После двух нажатий на «Записать подход» там окажется:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · формат из исходника @expo/cli 57.0.18 (MetroTerminalReporter.js, serverLogLikeMetro.js), а не запись сеанса; время и число модулей — для примера
iOS Bundled 812ms index.ts (655 modules)
LOG рендер: подходов 0
LOG рендер: подходов 1
LOG рендер: подходов 2

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 ругается на версию SDKExpo 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.

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

Сейчас startsWith("react") проверяется первым и забирает всё, что начинается с этих букв: react-native, react-native-web и react-native-screens оказались в React, до второй проверки дело не дошло. А @expo/metro-runtime начинается с @ и мимо startsWith("expo") прошёл в «прочее». Этаж нужно узнавать по точному имени или по области пакета, а не по первым буквам:

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

Вывод становится таким: «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?

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

Проверить корень до runApplication:

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

Вывод — «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 и проверяет корень сам, раньше: иначе опечатка в разметке выглядит как поломка чужой библиотеки.