Модуль — это запись в реестре
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Камера, защищённое хранилище, уведомления — всё, чем приложение отличается от сайта, живёт в нативном коде, и в предпросмотре не выполнится ни одной его строки. Но до камеры стоит вопрос проще и важнее: как JavaScript вообще находит нативный код? Ответ скучный и потому надёжный — по имени, в реестре. Заглянем в настоящий NativeModules предпросмотра, пройдём цепочку, по которой модуль ищут на телефоне, и соберём реестр с той же логикой, какой Expo подключает свои модули в браузере: функции из пакета expo-modules-core 57, переписанные для песочницы.
NativeModules — самый старый реестр нижнего этажа, React Native: обычный объект «имя → модуль». В статье «Что в этой песочнице настоящее» мы в него уже заглядывали; теперь посмотрим внимательно, что в нём лежит и что умеет лежащее. Прогноз перед запуском: сколько ключей у NativeModules в предпросмотре и что вернёт обращение к модулю, которого в нём нет?
web: предпросмотр — это react-native-web в браузере, а не телефон. Ключ один — UIManager, и «нативного» в нём нет ничего: это восемь функций react-native-web на JavaScript. measure и measureInWindow читают геометрию DOM-узла (offsetTop, getBoundingClientRect) и отдают её колбэку через setTimeout(…, 0), то есть уже после того, как вызов вернулся. Это тот самый measure(), который ref отдаёт вместо DOM-узла, — здесь видно, на чём он держится в браузере. На телефоне с новой архитектурой порядок другой: NativeDOM::measure в C++ зовёт колбэк прямо внутри вызова. Поэтому не стройте код на порядке: в предпросмотре колбэк приходит после вызова, на устройстве — во время него.Отсутствующий модуль ошибкой не стал: NativeModules.ExpoSecureStore — просто undefined, как любой несуществующий ключ объекта. Упадёт следующий шаг, вызов метода, — с TypeError: Cannot read properties of undefined. Компилятор промолчит и там: в типах react-native 0.86 реестр описан как interface NativeModulesStatic { [name: string]: any }, то есть «любое имя, что угодно». Проверить имя модуля до запуска нельзя в принципе — это строка, а ответ на неё знает только рантайм.
На телефоне объект собирается в Libraries/BatchedBridge/NativeModules.js, и в 0.86 там две ветки. Первая — реестром становится global.nativeModuleProxy, объект, который натив ставит в глобальную область через JSI, и модуль отыскивается в момент обращения по имени. Ставит его новая архитектура без моста (BridgelessNativeModuleProxy в TurboModuleBinding.cpp), но ставил и поздний мост, если JS выполнялся через JSIExecutor. Вторая ветка — самая старая, без JSI: список модулей приходил готовой конфигурацией __fbBatchedBridgeConfig. Что изменилось, когда мост убрали совсем, разбирает следующая статья.
Пакеты Expo в NativeModules руками не лезут. Они стоят этажом выше и зовут одну функцию — requireNativeModule(имя) из expo-modules-core. Сама она только бросает ошибку, если ничего не нашлось, а ищет её соседка requireOptionalNativeModule. Вот поиск на iOS и Android — это файл requireNativeModule.ts, не .web.ts:
Три звена через ??. Первое — globalThis.expo.modules, собственный реестр Expo: на телефоне его наполняет натив, тоже через JSI. Второе — NativeModulesProxy, прослойка для модулей старого формата, в пакете она помечена @deprecated. Третье — TurboModuleRegistry.get с этажа React Native, а обёртка createTurboModuleToExpoProxy копирует методы и константы найденного турбомодуля в объект привычной для Expo формы. Сам TurboModuleRegistry — тоже маленькая цепочка из двух попыток:
| Имя | Этаж | Что это |
|---|---|---|
NativeModules | React Native | старый реестр «имя → модуль»; на телефоне — global.nativeModuleProxy, в предпросмотре — объект с одним UIManager |
TurboModuleRegistry.get | React Native | сначала __turboModuleProxy, потом NativeModules; в типах есть, в рантайме предпросмотра нет |
globalThis.expo.modules | Expo | реестр expo-modules-core: на телефоне его наполняет натив, в браузере — registerWebModule |
requireNativeModule | Expo | читатель: идёт по цепочке и бросает ошибку, если не нашёл никого |
Соберём цепочку в песочнице. NativeModulesProxy опустим, а TurboModuleRegistry напишем сами: в рантайме предпросмотра его нет, ReactNative.TurboModuleRegistry там undefined. По этой же причине настоящий expo-modules-core в песочницу не собирается: первой строкой requireNativeModule.ts импортирует TurboModuleRegistry из react-native. Две глобали объявим через declare var — так же expo-modules-core объявляет свою expo. Прогноз: через какое звено найдётся UIManager и сколько строк напечатает пример?
Строк три, а не четыре: requireNativeModule бросил исключение, и последняя строка не выполнилась. UIManager прошёл всю цепочку до самого конца — expo и __turboModuleProxy в предпросмотре не определены, и нашёлся он в старом NativeModules, тот самый объект react-native-web. Для ExpoSecureStore пусты все звенья, и выходит ровно тот текст, который бросает настоящий пакет.
Этот текст стоит запомнить: на телефоне его видят, когда пакет с нативным кодом поставили через npm, Metro привёз его JavaScript, а приложение не пересобрали. JS-половина на месте, нативной в бинарнике нет, цепочка пуста. Лечится это не правкой кода, а новой сборкой — об этом глава 10.
Реестр ищет по строке. Опечатка в имени модуля — не ошибка компилятора, а та же «Cannot find native module» при запуске.
Откуда в реестре берутся записи? На телефоне их кладёт натив. В браузере натива нет, и Expo пишет туда сам: polyfill/dangerous-internal.ts один раз ставит globalThis.expo с пустым modules, а registerWebModule(класс, имя) создаёт экземпляр и записывает его под именем. Логика ниже та же, что в registerWebModule.ts, вплоть до текста ошибки; отличие одно — у нас имя честно помечено необязательным, а в пакете оно объявлено обязательным и всё равно подменяется именем класса.
Прогноз: модуль импортируют два файла — сколько раз выполнится конструктор? И что будет с классом без имени?
Конструктор выполнился один раз: второй вызов нашёл запись и вернул готовый экземпляр. На всё приложение NetStatus один, и это важнее, чем кажется: в следующем разделе у модуля появятся события, а подписчик и отправитель обязаны держать один и тот же объект.
Класс без имени дал пустую строку и ошибку. Но в тексте ошибки названа причина поопаснее — минификация. Минификатор переименует class NetStatus в какой-нибудь class e, и ключ реестра молча станет "e": проверка ловит только пустое имя. Поэтому все три веб-модуля, что нашлись в node_modules авторского приложения, передают имя явно: registerWebModule(ExpoUpdatesModule, "ExpoUpdates"), а в expo-font — "ExpoFontLoader" и "ExpoFontUtils".
И оговорка, без которой миниатюра соврала бы. У нас писатель (registerWebModule) и читатель (requireNativeModule из прошлого раздела) встречаются в одном объекте — как на телефоне, где в expo.modules пишет натив. В настоящей веб-сборке читатель почти не участвует: веб-версия requireOptionalNativeModule заглядывает в реестр только внутри DOM-компонента Expo, а в остальных случаях возвращает null.
Веб-модуль попадает в код не поиском по имени, а импортом: его .web.ts-файл сам экспортирует то, что вернул registerWebModule. Как это устроено в настоящих пакетах, покажет последний раздел.
Модуль — это не только методы. Сеть пропала, пришло уведомление, сел аккумулятор — это события. Базовый класс веб-модуля в expo-modules-core — class NativeModule extends EventEmitter, и своей логики у него нет вовсе. Интересное — в EventEmitter: кроме подписки и рассылки у него есть два пустых крючка, startObserving и stopObserving. Первый зовётся, когда слушателей становится один из нуля, второй — когда из одного ноль. Модуль может держать тяжёлую работу — датчик, системную подписку — включённой ровно тогда, когда его кто-то слушает.
Прогноз: на модуль подписались лента и экран тренировки, после первого события лента отписалась. Сколько раз модуль начнёт слушать систему и когда перестанет?
feed.remove() систему не выключил: тренировка ещё слушает. Выключил её только последний remove(). На телефоне в эти же крючки встаёт нативный код, поэтому забытая отписка там стоит дороже, чем в браузере: модуль продолжает работать на экран, которого давно нет.Теперь то же с экрана. Подписка на модуль снимается в уборке эффекта, как любая подписка в части IV; нового здесь одно — объект, на который подписываются, живёт в реестре, вне дерева, и переживает экран. В подложке примера — миниатюра этой статьи целиком. Прогноз: что напечатается после того, как тренировку закрыли?
Экран ушёл — ушёл последний слушатель — натив выключился. А модуль остался: повторный импорт отдаёт тот же объект, и следующий экран, который на него подпишется, снова разбудит систему через startObserving.
Проверка стоит в отдельном таймере не из аккуратности. Напечатайте число слушателей сразу после fireEvent.press, в той же функции, — и увидите единицу: уборка эффекта выполнилась позже, чем вернулось нажатие.
В настоящем пакете модуль приходит в код файлом с постоянным именем, а какой из двух файлов попадёт в сборку, решает резолвер платформенных расширений из статьи «Одно приложение, три платформы»: для веба он берёт .web.ts. Вот как это выглядит в expo-secure-store и expo-updates (кавычки заменены на двойные, остальное как в исходниках):
Пакеты, установленные в приложении автора, отвечают на веб тремя способами:
| Пакет | Файл для телефона | Файл для веба | Что получает код в браузере |
|---|---|---|---|
expo-secure-store 57.0.1 | requireNativeModule("ExpoSecureStore") | export default {} | пустой объект, методов нет |
expo-device 57.0.1 | requireNativeModule("ExpoDevice") | объект с геттерами, данные разобраны из userAgent | ответ браузера, реестр не нужен |
expo-linking 57.0.7 | requireNativeModule("ExpoLinking") | объект, getLinkingURL() отдаёт window.location.href | ответ браузера, реестр не нужен |
expo-updates 57.0.17 | requireNativeModule("ExpoUpdates") | registerWebModule(ExpoUpdatesModule, "ExpoUpdates") | экземпляр класса с событиями, записанный в реестр |
Первый способ самый честный и самый коварный. Обёртка SecureStore.ts одна на все платформы: она зовёт методы модуля не глядя, а доступность проверяет наличием метода — в пакете это буквально !!ExpoSecureStore.getValueWithKeyAsync. Прогноз: что скажет isAvailableAsync в браузере и чем кончится getItemAsync?
false и TypeError. Тип any не предупредил ни о чём — и не мог: модуль — запись, а есть ли в записи метод, выясняется только при вызове. Отсюда правило для своего кода: где модуль может отсутствовать, спрашивайте requireOptionalNativeModule или isAvailableAsync пакета до вызова, а не ловите падение после.
Что лежит в записи на телефоне? Объект, который натив поставил через JSI, и вызов его метода — это не сообщение в очередь, а синхронный вызов функции на C++. Почему так стало и куда делся мост, через который React Native жил до новой архитектуры, — в следующей статье.
- В предпросмотре
NativeModules— объект с одним ключомUIManager, за которым функции react-native-web поверх DOM; на телефоне на его местеglobal.nativeModuleProxy, поставленный через JSI. - Нативный модуль находят по строке-имени:
requireNativeModuleидёт по цепочкеexpo.modules→NativeModulesProxy→TurboModuleRegistryи бросает «Cannot find native module», если все звенья пусты. registerWebModuleкладёт вglobalThis.expo.modulesодин экземпляр на имя и отдаёт его всем; имя передают явно, потому что минификатор переименует класс.- База веб-модуля —
EventEmitter:startObservingсрабатывает на первом слушателе,stopObserving— после последнего, а сам модуль переживает любой экран. - В настоящем пакете модуль приходит файлом:
.tsзовётrequireNativeModule,.web.tsотдаёт веб-реализацию или пустой объект — и отсутствие метода обнаруживается только при вызове.
1/4Что вернёт Object.keys(ReactNative.NativeModules) в предпросмотре?
2/4Пакет с нативным кодом поставили через npm, Metro привёз его JavaScript, а приложение на телефоне не пересобирали. Что будет при первом импорте?
3/4Зачем настоящие веб-модули передают в registerWebModule имя вторым аргументом, если у класса оно и так есть?
4/4На модуль подписаны лента и экран тренировки. Лента отписалась. Что происходит с нативной стороной?
Реестр без двойников
В подложке — миниатюра статьи, но registerWebModule здесь свой, и в нём забыта одна проверка. Из-за неё лента подписалась на один экземпляр модуля, а событие ушло из другого. Допишите функцию так, чтобы на одно имя приходился один экземпляр.
Перед созданием экземпляра спросите реестр: если запись уже есть, верните её. Это та самая строка, которая в registerWebModule.ts стоит до new.
Теперь конструктор выполняется один раз, forFeed и forSync — один объект, и событие доходит до ленты: «лента: в сети false». Без проверки каждый импорт получал свой экземпляр, и подписчики одного не слышали рассылку другого — ошибка без исключения, просто тишина.
Кнопка для модуля, которого нет в сборке
Экран прикрепляет фото к тренировке через модуль ExponentImagePicker (имя настоящее, из expo-image-picker 57, — старое «Exponent» осталось в строке с давних времён). В сборке, где модуля нет, экран падает целиком ещё до первой отрисовки. Сделайте так, чтобы экран открывался, а нажатие честно сообщало, что фото недоступно.
requireNativeModule бросает исключение прямо при загрузке файла — до mount дело не доходит. requireOptionalNativeModule вместо исключения возвращает null, а решение, что делать без модуля, остаётся за экраном.
Тип ImagePickerModule | null заставит проверить модуль перед вызовом — компилятор не пропустит Picker.launchImageLibraryAsync без проверки. В настоящей веб-сборке этот вопрос не встанет вовсе: у expo-image-picker есть .web.ts, который открывает выбор файла браузера.