Что Expo делает за вас: prebuild, конфиг и плагины

сборка≈ 14 минут

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

Всю часть мы писали на React Native — примитивы, стили, списки, жесты, — а Expo оставался тем, как проект запускается. Глава 9 упёрлась в предел: модулю с нативным кодом нужна своя сборка, значит, пора вскрыть и это «как». Ответ проще, чем кажется. Expo — это генератор обычного проекта React Native: одна команда превращает app.config.js в папки ios/ и android/, которые открываются в Xcode и Android Studio как любые другие. Поверх — реестр модулей из прошлой главы и облачные сервисы, о которых следующие статьи. Генератор разберём до винтика: плагин конфига окажется функцией над объектом, а весь список плагинов — одним reduce.

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

До этой главы Expo был для нас командой expo start, Metro и QR-кодом. В приложении автора (Expo SDK 57, React Native 0.86) нет ни папки ios/, ни android/ — и это не упущение, а замысел. Нативные проекты не хранятся в репозитории, а выводятся из конфига; Expo называет это Continuous Native Generation, сокращённо CNG. Выводит их одна команда:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · строки из исходника @expo/cli 57.0.18, а не запись сеанса
✔ Created native directories
✔ Updated package.json
✔ Finished prebuild

За тремя строками — три шага. Сначала из npm берётся шаблон expo-template-bare-minimum@sdk-57 — тот самый «голый» проект React Native. Его папки копируются рядом с app.config.js, а заглушка HelloWorld в именах и файлах меняется на имя приложения. Затем правится package.json: скрипты ios и android переводятся на expo run:*, а если менять нечего, строка кончится пометкой | no changes. Последний шаг, «Running prebuild», — тот, ради которого написана статья: конфиг проходит через плагины, и результат ложится в файлы. Флаг --no-install нужен, чтобы на маке следом не запустился pod install.

Вот что появится. Дерево — не вывод команды: оно собрано по составу шаблона и по путям, которые читают и пишут моды @expo/config-plugins 57. Комментарии справа — чем эти файлы наполняет конфиг.

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

Prilozhenie — не опечатка. У приложения автора name: "Приложение", а для имён папок и цели Xcode Expo пропускает имя через sanitizedName, которая транслитерирует кириллицу; «Подход» стал бы Podhod. Проверено запуском самой функции из @expo/config-plugins. На экране телефона останется «Приложение»: отображаемое имя живёт отдельно, в CFBundleDisplayName.

Главное в этом дереве — чего в нём нет. Особой «экспо-сборки» здесь нет: Podfile зовёт use_react_native!, settings.gradle включает com.facebook.react.settings — React Native подключён как обычная зависимость CocoaPods и Gradle, как в проекте без всякого Expo. Expo добавил поверх две вещи: use_expo_modules! в Podfile и плагин expo-autolinking-settings в Gradle. Обе обходят node_modules, находят пакеты с нативным кодом и вписывают их в сборку, а на iOS ещё и генерируют ExpoModulesProvider.swift — список модулей, по которому приложение при запуске заполняет реестр. Тот самый, что вы собирали в статье «Модуль — это запись в реестре».

Этаж ExpoЧто делаетГде и когда работаетГде в части
Генератор: prebuild и плагины конфигаПревращает app.config.js в ios/ и android/В Node, на вашей машине или на сервере сборки, до Xcode и GradleЭта статья
Реестр модулей: autolinking и requireNativeModuleНаходит нативные пакеты и отдаёт их JS по имениСписок — при сборке, реестр — в запущенном приложенииГлава 9
Сервисы EAS: Build, Submit, UpdateСобирают, отправляют в магазины, доставляют обновленияВ облаке ExpoГлавы 10 и 11

В CNG папки ios/ и android/ — артефакт сборки, как dist/: их не правят руками и не хранят в git. Всё, что должно в них оказаться, пишется в конфиг.

app.config.js — не JSON, а модуль Node, поэтому конфиг может вычисляться. У автора так и сделано: Firebase подключается, только если в окружении есть файлы с ключами, иначе проект не собрал бы никто, у кого их нет.

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

Какие поля какими нативными ключами становятся — в таблице. Эти превращения делают плагины @expo/config-plugins, которые prebuild применяет всегда, даже при пустом списке plugins.

Поле конфигаiOSAndroid
nameCFBundleDisplayName в Info.plistapp_name в strings.xml
versionCFBundleShortVersionStringversionName в app/build.gradle
ios.bundleIdentifier, android.packagePRODUCT_BUNDLE_IDENTIFIER в project.pbxprojapplicationId и namespace в app/build.gradle
schemeCFBundleURLTypesintent-filter с android:scheme в манифесте
orientationUISupportedInterfaceOrientationsandroid:screenOrientation у MainActivity
pluginsВсё остальное: разрешения, entitlements, PodfileРазрешения, meta-data, Gradle

Смотреть на это глазами удобнее, чем по таблице, и генерировать папки для этого не нужно. npx expo config --type introspect прогоняет те же плагины и те же моды вхолостую, против встроенных заготовок файлов, и печатает, что легло бы в Info.plist и AndroidManifest.xml. Настоящий запуск на полном конфиге автора — выше он сокращён:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · настоящий запуск на конфиге приложения автора, Expo SDK 57; фрагмент, пропуски отмечены …
» android: EDGE_TO_EDGE_PLUGIN: `edgeToEdgeEnabled` customization is no longer available - Android 16 makes edge-to-edge mandatory. Remove the `edgeToEdgeEnabled` entry from your app.json/app.config.js.
» android: userInterfaceStyle: Install expo-system-ui in your project to enable this feature.
{
name: 'Приложение',
…
ios: {
supportsTablet: false,
bundleIdentifier: 'com.appstarter.app',
infoPlist: {
…
CFBundleShortVersionString: '1.0.0',
CFBundleVersion: '1',
RCTMetroPort: '$(RCT_METRO_PORT)',
CFBundleURLTypes: [
{
CFBundleURLSchemes: [
'appstarter',
'com.appstarter.app'
]
}
],
UIRequiresFullScreen: false,
UISupportedInterfaceOrientations: [
'UIInterfaceOrientationPortrait',
'UIInterfaceOrientationPortraitUpsideDown'
],
CFBundleDisplayName: 'Приложение',
NSPhotoLibraryUsageDescription: 'Allow $(PRODUCT_NAME) to access your photos',
NSCameraUsageDescription: 'Allow $(PRODUCT_NAME) to access your camera',
NSMicrophoneUsageDescription: 'Allow $(PRODUCT_NAME) to access your microphone',
UIUserInterfaceStyle: 'Light',
NSFaceIDUsageDescription: 'Allow $(PRODUCT_NAME) to access your Face ID biometric data.',
…
},
entitlements: {
'aps-environment': 'development'
}
},
android: {
package: 'com.appstarter.app',
edgeToEdgeEnabled: true,
permissions: [
'android.permission.RECORD_AUDIO',
…
],
…
},
…
_internal: {
…
pluginHistory: {
'expo-secure-store': {
name: 'expo-secure-store',
version: '57.0.1'
},
…
'react-native-maps': {
name: 'react-native-maps',
version: 'UNVERSIONED'
},
…
'expo-image-picker': {
name: 'expo-image-picker',
version: '57.0.13'
}
},
…

Первые две строки — предупреждения самих плагинов, и обе по делу: edgeToEdgeEnabled больше ничего не значит, потому что Android 16 делает отрисовку под системными панелями обязательной, а userInterfaceStyle на Android без пакета expo-system-ui не применится. Дальше таблица сбывается построчно: name стал CFBundleDisplayName, scheme — CFBundleURLSchemes, orientation — две портретные ориентации. А вот чего таблица не предсказывала.

В Info.plist стоят строки про фотографии, камеру и микрофон, а на Android — разрешение RECORD_AUDIO. Ни один плагин из списка их не просил, и звук приложение не пишет. Источник виден в pluginHistory того же вывода: там есть expo-image-picker, которого в plugins нет. Это тот же пикер, что в статье «Фото из галереи и отправка файла», — у автора он стоит ради аватара. А у prebuild есть свой список пакетов, чьи плагины он применяет сам, стоит пакету найтись в проекте: expo-camera, expo-location, expo-image-picker и ещё почти три десятка. Плагин пикера по умолчанию просит и микрофон — на случай съёмки видео.

Строки к тому же английские и безликие: «Allow $(PRODUCT_NAME) to access your microphone». Apple требует, чтобы строка назначения объясняла, зачем приложению доступ, а разрешение на микрофон у приложения, которое ничего не записывает, — лишний повод для вопросов на модерации. Лечится это не правкой Info.plist, а тем же конфигом: плагин пикера перечисляют явно и отдают ему параметры.

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

false сильнее, чем пропуск: плагин удаляет ключ из Info.plist и помечает разрешение в манифесте на удаление, чтобы его не вернула никакая другая библиотека. Тот же запуск после правки — строки про микрофон в выводе больше нет, про фото — своя, а в манифесте:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · тот же запуск после правки; фрагмент
NSFaceIDUsageDescription: 'Allow $(PRODUCT_NAME) to access your Face ID biometric data.',
NSPhotoLibraryUsageDescription: 'Фото нужно, чтобы поставить аватар в профиле',
NSCameraUsageDescription: 'Allow $(PRODUCT_NAME) to access your camera',
…
{
'$': {
'android:name': 'android.permission.RECORD_AUDIO',
'tools:node': 'remove'
}
}
…

Как плагин выглядит изнутри, видно по настоящему. Вот expo-secure-store — тот, что вписал в вывод выше строку про Face ID и правила резервного копирования. В пакете он лежит собранным JS, здесь сокращён:

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

Всё, что делает плагин, — берёт конфиг и параметры и возвращает конфиг. Строка в списке plugins — имя пакета: Node находит в нём файл app.plugin.js. Кортеж — то же имя плюс параметры, которые приходят вторым аргументом. А сам список применяет функция withPlugins, и в @expo/config-plugins она занимает одну строку:

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

withStaticPlugin превращает строку или кортеж в вызов функции, остальное делает reduce. Настоящего @expo/config-plugins в песочнице нет: это пакет для Node, он читает и пишет файлы проекта. Поэтому соберём то же самое: два плагина по образцу настоящих и реестр вместо поиска по node_modules. Прогноз перед запуском: какие ключи окажутся в infoPlist и изменился ли исходный appConfig?

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

Ключей два, хотя параметры получил только первый плагин: второй вписал строку по умолчанию — ровно так она и попала в приложение автора. appConfig не тронут: наши плагины каждый раз собирают новый объект, и reduce передаёт следующему то, что вернул предыдущий. Настоящие плагины чаще правят объект на месте и возвращают его же — цепочке это безразлично. Ей важно одно: плагин обязан что-то вернуть. Забытый return настоящий withPlugins встречает так: TypeError: Cannot read properties of undefined (reading '_internal') — следующему плагину достался undefined.

Теперь случай, ради которого у настоящего плагина последняя строка — createRunOncePlugin. В истории плагинов из вывода выше, кроме expo-image-picker, есть и пакеты, которых в проекте нет вовсе: react-native-maps с версией UNVERSIONED. Это встроенная копия его плагина из @expo/prebuild-config: свои списки prebuild применяет после вашего, на всякий случай, и без установленного пакета такая копия ничего не меняет. Но если пакет установлен, один и тот же плагин приходит дважды — из вашего списка с параметрами и из встроенного без них. Прогноз: что останется в infoPlist, если в своём списке микрофон выключен?

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

Без страховки встроенный проход вернул строку по умолчанию: он пришёл без параметров, ключа не нашёл и вписал свой. С createRunOncePlugin второй вызов видит имя в истории и отдаёт конфиг нетронутым, и ваше false уцелело. Так ведут себя плагины, которые, как наши, пишут прямо в объект конфига. Настоящий пикер строки разрешений ставит иначе — модом, а у модов свой порядок выполнения; к пикеру вернёмся в следующей секции. История лежит в том же поле, что и в настоящем выводе, — _internal.pluginHistory, только там вместо строки объект { name, version }. Живёт она один прогон конфига: следующий prebuild считает всё заново.

Наши плагины писали прямо в объект конфига, и так поступают многие настоящие: ios.infoPlist и android.permissions потом разберут встроенные плагины. Но файлов плагин не трогает никогда. withInfoPlist и withAndroidManifest только кладут в config.mods функцию над содержимым файла — мод. В выводе npx expo config --type prebuild для приложения автора их видно поимённо:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · настоящий запуск на конфиге приложения автора; фрагмент
mods: {
ios: {
infoPlist: [AsyncFunction: interceptingMod] {
isProvider: false
},
podfile: [AsyncFunction: interceptingMod] {
isProvider: false
},
…
android: {
manifest: [AsyncFunction: interceptingMod] {
isProvider: false
},
strings: [AsyncFunction: interceptingMod] {
isProvider: false
},
…

Сам файл читается позже, на последнем шаге prebuild, и ровно один раз: «поставщик» читает AndroidManifest.xml, пропускает содержимое через всю цепочку модов и записывает результат. Манифест в приложении автора правят пятнадцать модов, и разбирать XML на каждый было бы и медленно, и хрупко. Цепочку каждый новый мод строит так: сперва он сам, потом всё, что было записано до него. Прогноз: два плагина ставят один и тот же ключ, A стоит в списке первым. Какие строки напечатаются и чьё значение запишется в файл?

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

Плагины отработали по порядку, а моды — задом наперёд: B раньше A, и последнее слово осталось за A, первым в списке. Такое легко принять за причуду миниатюры, поэтому сверимся с настоящим Expo. Значение мы проверили отдельно: два плагина через настоящий withInfoPlist в том же порядке — и в файл легло значение A. А порядок Expo покажет сам: с переменной окружения EXPO_DEBUG=1 он печатает каждый мод в момент выполнения. Вот все правки AndroidManifest.xml приложения автора:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · настоящий запуск на конфиге приложения автора; только строки android.manifest, пропуск отмечен …
android.manifest: withIntrospectionBaseMods ➜ withAndroidBaseMods ➜ withGeneratedBaseMods ➜ withAndroidManifestBaseMod
android.manifest: withAndroidExpoPlugins ➜ withPlugins ➜ withStaticPlugin ➜ withAndroidIcons ➜ withAndroidManifestIcons ➜ withAndroidManifest
android.manifest: withAndroidExpoPlugins ➜ withPlugins ➜ withStaticPlugin ➜ withPermissions ➜ withAndroidManifest
android.manifest: withAndroidExpoPlugins ➜ withPlugins ➜ withStaticPlugin ➜ withOrientation ➜ withAndroidManifest
android.manifest: withAndroidExpoPlugins ➜ withPlugins ➜ withStaticPlugin ➜ withScheme ➜ withAndroidManifest
…
android.manifest: withStaticPlugin ➜ withRunOnce ➜ withImagePicker ➜ withBlockedPermissions ➜ withAndroidManifest
android.manifest: withStaticPlugin ➜ withRunOnce ➜ withImagePicker ➜ withPermissions ➜ withAndroidManifest
android.manifest: withFallback ➜ withPlugins ➜ withStaticPlugin ➜ withGoogleMapsApiKey ➜ withAndroidManifest
android.manifest: withStaticPlugin ➜ withRunOnce ➜ withNotifications ➜ withNotificationsAndroid ➜ withNotificationManifest ➜ withAndroidManifest
android.manifest: withUpdates ➜ withUpdates ➜ withPlugins ➜ withStaticPlugin ➜ withUpdatesManifest ➜ withAndroidManifest
android.manifest: withStaticPlugin ➜ withRunOnce ➜ withSecureStore ➜ withAndroidManifest

Первой строкой идёт поставщик — он читает файл. За ним встроенные плагины Expo (withAndroidExpoPlugins), потом пикер, которого prebuild применил сам, и встроенная копия плагина карт. Ваш список — в самом конце и задом наперёд: в app.config.js стоят secure-store, updates, notifications, а выполняются notifications, updates, secure-store. Отсюда практическое следствие: моды встроенных плагинов выполняются раньше ваших, и ваш мод видит файл уже после Expo. Так задумано: свой плагин может поправить то, что записал Expo, а не наоборот.

Этот же порядок закрывает вопрос из прошлой секции. Строки разрешений настоящий пикер ставит модом, и, приди он дважды, мод из вашего списка выполнился бы последним — false уцелело бы и без createRunOncePlugin. Мы проверили это на настоящем expo-image-picker, стерев историю между двумя вызовами: ключа про микрофон в Info.plist нет, а RECORD_AUDIO в манифесте помечено tools:node="remove". Обёртка спасает плагины другого склада: те, что пишут прямо в объект конфига, как наша миниатюра, — второй вызов затрёт их параметры, — и те, что дописывают, не проверяя, — второй вызов задвоит строку. В трассировке выше она стоит у пикера, уведомлений и secure-store: это withRunOnce в их строках.

Для Info.plist, манифеста, Podfile, build.gradle, strings.xml и AppDelegate есть свои поставщики: файл разбирается в объект или строку, и мод работает уже с ними. Всё прочее — скопировать файл, поправить что-то редкое — делается через withDangerousMod: такой мод получает только путь к папке платформы и сам отвечает за то, что там натворит. Оттого он и «опасный», и оттого prebuild выполняет такие моды раньше всех остальных.

И второе следствие — для тех, кто пишет свои моды. prebuild без --clean не пересоздаёт папки, а прогоняет моды поверх уже сгенерированных файлов, и мод, который просто дописывает, допишет второй раз. Встроенные плагины от этого защищены: перед вставкой проверяют, нет ли там уже их строки, а вставки в текстовые файлы помечают комментарием @generated begin … - expo prebuild (DO NOT MODIFY), по которому находят их в следующий раз. На этом стоит первая задача к статье.

Раз ios/ и android/ — обычные проекты, из Expo можно выйти в любой момент: сгенерировать папки и начать хранить их как исходники.

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

С этой минуты меняется вот что.

CNG: папки выводятся из конфигаПапки в git
Где правится нативВ app.config.js и плагинахВ Xcode, Android Studio и руками в файлах
prebuild --cleanБезопасен: всё выводится зановоСносит ручные правки; при незакоммиченных изменениях предупреждает «Git branch has uncommitted file changes» и спрашивает, продолжать ли
EAS BuildСам запускает prebuild на сервереСобирает папки как есть: иконка, ориентация и плагины из конфига на сборке не применяются
Новый SDKПоднять версии и сгенерировать зановоПереносить изменения шаблона в свои файлы вручную
Модули Expo, expo run:*, expo-router, EAS UpdateРаботаютРаботают: use_expo_modules! и autolinking уже вписаны в файлы

Последняя строка и есть ответ на вопрос, как выйти из Expo к голому React Native. Закоммиченные папки с модулями Expo — это проект React Native, в котором подключён реестр модулей; Expo называет такой режим bare workflow. Выбросить и сам пакет expo можно, но вместе с ним уйдёт всё, что стоит на expo-modules-core: роутер, обновления, уведомления, хранилище. На это решаются, когда Expo проекту действительно чужой — например, когда React Native встраивают в давно живущее нативное приложение.

А повод выйти обычно один: какой-то SDK просит правку, для которой в конфиге нет поля, — строку в AppDelegate, свойство в gradle.properties. Такую правку лучше оформить своим плагином: файл рядом с конфигом и строка в списке.

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

Правка тогда живёт в коде: переживает --clean, новый SDK и сборку в EAS, а ios/ и android/ остаются тем, чем были всю часть, — артефактом. Следующая статья — о том, что делать, когда приложению мало Expo Go и нужна своя отладочная сборка.

  • Expo — это генератор обычного проекта React Native, плюс реестр модулей, плюс облачные сервисы. npx expo prebuild копирует шаблон expo-template-bare-minimum и переписывает его по app.config.js: получаются проекты Xcode и Gradle, где React Native — обычная зависимость, а модули Expo подключает autolinking.
  • Плагин конфига — функция config → config. Список plugins применяется одним reduce: строка — имя пакета с app.plugin.js, кортеж — имя и параметры. Плагин обязан вернуть конфиг.
  • Файлы плагин не трогает: он кладёт мод в config.mods. prebuild читает каждый файл один раз, прогоняет через цепочку и пишет один раз; моды идут задом наперёд, поэтому последнее слово за первым плагином списка, а ваши моды выполняются после встроенных.
  • Что уедет в натив, показывает npx expo config --type introspect — вместе с тем, что пришло без спроса: плагины установленных пакетов prebuild применяет сам, а createRunOncePlugin не даёт плагину, пришедшему дважды, выполниться второй раз.
  • Выйти из CNG — значит закоммитить ios/ и android/: модули Expo остаются, но конфиг перестаёт управлять нативом, а EAS Build собирает папки как есть. Прежде чем выходить, стоит оформить правку своим плагином.

1/4Что делает npx expo prebuild?

2/4Плагины A и B через withInfoPlist ставят один и тот же ключ, в списке plugins A стоит первым. Что окажется в Info.plist?

3/4В Info.plist приложения появилась строка про микрофон, хотя в plugins о микрофоне ни слова. Откуда она?

4/4Зачем плагины оборачивают в createRunOncePlugin?

Режим фона, который не задваивается

Таймер отдыха должен доиграть сигнал, когда приложение свёрнуто, — для этого в UIBackgroundModes нужен режим audio. Плагин ниже его добавляет, и после первого прогона всё хорошо. Но prebuild без --clean прогоняет моды поверх уже сгенерированного файла, и режим задваивается. В файле к тому же есть чужой режим — remote-notification: его вписывает expo-notifications с параметром enableBackgroundRemoteNotifications. Поправьте мод: чужие режимы сохранить, свой добавить один раз.

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

Мод обязан смотреть, что уже лежит в файле, прежде чем дописывать: если audio там есть, файл возвращается как был.

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

Второй прогон теперь печатает ["remote-notification", "audio"]. Ровно так устроен и настоящий expo-notifications: перед push он проверяет includes("remote-notification"). Положенный в plugins/with-rest-timer-audio.js и подключённый строкой в списке, этот плагин переживёт и --clean, и повторный prebuild.

Иконка, которая не доехала

Коллега однажды выполнил npx expo prebuild, закоммитил ios/ и android/ и забыл о них. Через месяц вы поменяли icon в app.config.js и собрали приложение в EAS Build, а иконка осталась старой. Ошибок в журнале сборки нет. Что произошло и какими двумя путями это чинится?

EAS Build нашёл в репозитории папки ios/ и android/ и собрал их как есть: prebuild на сервере не запускался, поэтому поле icon до нативных файлов не дошло, и в Images.xcassets и res/mipmap-* лежит иконка с того самого коммита.

Путь первый — вернуться к CNG: удалить папки из репозитория и дописать /ios и /android в .gitignore; дальше сервер сборки будет генерировать их сам. Путь второй — остаться с папками в git, но после каждой правки нативных полей конфига запускать npx expo prebuild локально и коммитить изменения, помня, что такой прогон может затереть ручные правки в этих файлах. Смешанный режим — папки в git, а правки в конфиге — самый частый источник таких «не доехало».