Что Expo делает за вас: prebuild, конфиг и плагины
Здесь будет встроен видео-разбор темы (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. Выводит их одна команда:
За тремя строками — три шага. Сначала из 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. Комментарии справа — чем эти файлы наполняет конфиг.
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 подключается, только если в окружении есть файлы с ключами, иначе проект не собрал бы никто, у кого их нет.
Какие поля какими нативными ключами становятся — в таблице. Эти превращения делают плагины @expo/config-plugins, которые prebuild применяет всегда, даже при пустом списке plugins.
| Поле конфига | iOS | Android |
|---|---|---|
name | CFBundleDisplayName в Info.plist | app_name в strings.xml |
version | CFBundleShortVersionString | versionName в app/build.gradle |
ios.bundleIdentifier, android.package | PRODUCT_BUNDLE_IDENTIFIER в project.pbxproj | applicationId и namespace в app/build.gradle |
scheme | CFBundleURLTypes | intent-filter с android:scheme в манифесте |
orientation | UISupportedInterfaceOrientations | android:screenOrientation у MainActivity |
plugins | Всё остальное: разрешения, entitlements, Podfile | Разрешения, meta-data, Gradle |
Смотреть на это глазами удобнее, чем по таблице, и генерировать папки для этого не нужно. npx expo config --type introspect прогоняет те же плагины и те же моды вхолостую, против встроенных заготовок файлов, и печатает, что легло бы в Info.plist и AndroidManifest.xml. Настоящий запуск на полном конфиге автора — выше он сокращён:
Первые две строки — предупреждения самих плагинов, и обе по делу: 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, а тем же конфигом: плагин пикера перечисляют явно и отдают ему параметры.
false сильнее, чем пропуск: плагин удаляет ключ из Info.plist и помечает разрешение в манифесте на удаление, чтобы его не вернула никакая другая библиотека. Тот же запуск после правки — строки про микрофон в выводе больше нет, про фото — своя, а в манифесте:
Как плагин выглядит изнутри, видно по настоящему. Вот expo-secure-store — тот, что вписал в вывод выше строку про Face ID и правила резервного копирования. В пакете он лежит собранным JS, здесь сокращён:
Всё, что делает плагин, — берёт конфиг и параметры и возвращает конфиг. Строка в списке plugins — имя пакета: Node находит в нём файл app.plugin.js. Кортеж — то же имя плюс параметры, которые приходят вторым аргументом. А сам список применяет функция withPlugins, и в @expo/config-plugins она занимает одну строку:
withStaticPlugin превращает строку или кортеж в вызов функции, остальное делает reduce. Настоящего @expo/config-plugins в песочнице нет: это пакет для Node, он читает и пишет файлы проекта. Поэтому соберём то же самое: два плагина по образцу настоящих и реестр вместо поиска по node_modules. Прогноз перед запуском: какие ключи окажутся в infoPlist и изменился ли исходный appConfig?
Ключей два, хотя параметры получил только первый плагин: второй вписал строку по умолчанию — ровно так она и попала в приложение автора. 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, если в своём списке микрофон выключен?
Без страховки встроенный проход вернул строку по умолчанию: он пришёл без параметров, ключа не нашёл и вписал свой. С createRunOncePlugin второй вызов видит имя в истории и отдаёт конфиг нетронутым, и ваше false уцелело. Так ведут себя плагины, которые, как наши, пишут прямо в объект конфига. Настоящий пикер строки разрешений ставит иначе — модом, а у модов свой порядок выполнения; к пикеру вернёмся в следующей секции. История лежит в том же поле, что и в настоящем выводе, — _internal.pluginHistory, только там вместо строки объект { name, version }. Живёт она один прогон конфига: следующий prebuild считает всё заново.
Наши плагины писали прямо в объект конфига, и так поступают многие настоящие: ios.infoPlist и android.permissions потом разберут встроенные плагины. Но файлов плагин не трогает никогда. withInfoPlist и withAndroidManifest только кладут в config.mods функцию над содержимым файла — мод. В выводе npx expo config --type prebuild для приложения автора их видно поимённо:
Сам файл читается позже, на последнем шаге prebuild, и ровно один раз: «поставщик» читает AndroidManifest.xml, пропускает содержимое через всю цепочку модов и записывает результат. Манифест в приложении автора правят пятнадцать модов, и разбирать XML на каждый было бы и медленно, и хрупко. Цепочку каждый новый мод строит так: сперва он сам, потом всё, что было записано до него. Прогноз: два плагина ставят один и тот же ключ, A стоит в списке первым. Какие строки напечатаются и чьё значение запишется в файл?
Плагины отработали по порядку, а моды — задом наперёд: B раньше A, и последнее слово осталось за A, первым в списке. Такое легко принять за причуду миниатюры, поэтому сверимся с настоящим Expo. Значение мы проверили отдельно: два плагина через настоящий withInfoPlist в том же порядке — и в файл легло значение A. А порядок Expo покажет сам: с переменной окружения EXPO_DEBUG=1 он печатает каждый мод в момент выполнения. Вот все правки AndroidManifest.xml приложения автора:
Первой строкой идёт поставщик — он читает файл. За ним встроенные плагины 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 можно выйти в любой момент: сгенерировать папки и начать хранить их как исходники.
С этой минуты меняется вот что.
| 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. Такую правку лучше оформить своим плагином: файл рядом с конфигом и строка в списке.
Правка тогда живёт в коде: переживает --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. Поправьте мод: чужие режимы сохранить, свой добавить один раз.
Мод обязан смотреть, что уже лежит в файле, прежде чем дописывать: если audio там есть, файл возвращается как был.
Второй прогон теперь печатает ["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, а правки в конфиге — самый частый источник таких «не доехало».