Модуль — это запись в реестре

натив≈ 12 минут

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

Камера, защищённое хранилище, уведомления — всё, чем приложение отличается от сайта, живёт в нативном коде, и в предпросмотре не выполнится ни одной его строки. Но до камеры стоит вопрос проще и важнее: как JavaScript вообще находит нативный код? Ответ скучный и потому надёжный — по имени, в реестре. Заглянем в настоящий NativeModules предпросмотра, пройдём цепочку, по которой модуль ищут на телефоне, и соберём реестр с той же логикой, какой Expo подключает свои модули в браузере: функции из пакета expo-modules-core 57, переписанные для песочницы.

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

NativeModules — самый старый реестр нижнего этажа, React Native: обычный объект «имя → модуль». В статье «Что в этой песочнице настоящее» мы в него уже заглядывали; теперь посмотрим внимательно, что в нём лежит и что умеет лежащее. Прогноз перед запуском: сколько ключей у NativeModules в предпросмотре и что вернёт обращение к модулю, которого в нём нет?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Платформа — честное 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. Что изменилось, когда мост убрали совсем, разбирает следующая статья.

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

Пакеты Expo в NativeModules руками не лезут. Они стоят этажом выше и зовут одну функцию — requireNativeModule(имя) из expo-modules-core. Сама она только бросает ошибку, если ничего не нашлось, а ищет её соседка requireOptionalNativeModule. Вот поиск на iOS и Android — это файл requireNativeModule.ts, не .web.ts:

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

Три звена через ??. Первое — globalThis.expo.modules, собственный реестр Expo: на телефоне его наполняет натив, тоже через JSI. Второе — NativeModulesProxy, прослойка для модулей старого формата, в пакете она помечена @deprecated. Третье — TurboModuleRegistry.get с этажа React Native, а обёртка createTurboModuleToExpoProxy копирует методы и константы найденного турбомодуля в объект привычной для Expo формы. Сам TurboModuleRegistry — тоже маленькая цепочка из двух попыток:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
ИмяЭтажЧто это
NativeModulesReact Nativeстарый реестр «имя → модуль»; на телефоне — global.nativeModuleProxy, в предпросмотре — объект с одним UIManager
TurboModuleRegistry.getReact Nativeсначала __turboModuleProxy, потом NativeModules; в типах есть, в рантайме предпросмотра нет
globalThis.expo.modulesExpoреестр expo-modules-core: на телефоне его наполняет натив, в браузере — registerWebModule
requireNativeModuleExpoчитатель: идёт по цепочке и бросает ошибку, если не нашёл никого

Соберём цепочку в песочнице. NativeModulesProxy опустим, а TurboModuleRegistry напишем сами: в рантайме предпросмотра его нет, ReactNative.TurboModuleRegistry там undefined. По этой же причине настоящий expo-modules-core в песочницу не собирается: первой строкой requireNativeModule.ts импортирует TurboModuleRegistry из react-native. Две глобали объявим через declare var — так же expo-modules-core объявляет свою expo. Прогноз: через какое звено найдётся UIManager и сколько строк напечатает пример?

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

Строк три, а не четыре: 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, вплоть до текста ошибки; отличие одно — у нас имя честно помечено необязательным, а в пакете оно объявлено обязательным и всё равно подменяется именем класса.

Прогноз: модуль импортируют два файла — сколько раз выполнится конструктор? И что будет с классом без имени?

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

Конструктор выполнился один раз: второй вызов нашёл запись и вернул готовый экземпляр. На всё приложение NetStatus один, и это важнее, чем кажется: в следующем разделе у модуля появятся события, а подписчик и отправитель обязаны держать один и тот же объект.

Класс без имени дал пустую строку и ошибку. Но в тексте ошибки названа причина поопаснее — минификация. Минификатор переименует class NetStatus в какой-нибудь class e, и ключ реестра молча станет "e": проверка ловит только пустое имя. Поэтому все три веб-модуля, что нашлись в node_modules авторского приложения, передают имя явно: registerWebModule(ExpoUpdatesModule, "ExpoUpdates"), а в expo-font — "ExpoFontLoader" и "ExpoFontUtils".

И оговорка, без которой миниатюра соврала бы. У нас писатель (registerWebModule) и читатель (requireNativeModule из прошлого раздела) встречаются в одном объекте — как на телефоне, где в expo.modules пишет натив. В настоящей веб-сборке читатель почти не участвует: веб-версия requireOptionalNativeModule заглядывает в реестр только внутри DOM-компонента Expo, а в остальных случаях возвращает null.

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

Веб-модуль попадает в код не поиском по имени, а импортом: его .web.ts-файл сам экспортирует то, что вернул registerWebModule. Как это устроено в настоящих пакетах, покажет последний раздел.

Модуль — это не только методы. Сеть пропала, пришло уведомление, сел аккумулятор — это события. Базовый класс веб-модуля в expo-modules-core — class NativeModule extends EventEmitter, и своей логики у него нет вовсе. Интересное — в EventEmitter: кроме подписки и рассылки у него есть два пустых крючка, startObserving и stopObserving. Первый зовётся, когда слушателей становится один из нуля, второй — когда из одного ноль. Модуль может держать тяжёлую работу — датчик, системную подписку — включённой ровно тогда, когда его кто-то слушает.

Прогноз: на модуль подписались лента и экран тренировки, после первого события лента отписалась. Сколько раз модуль начнёт слушать систему и когда перестанет?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
«Слушаю» — один раз, на первом слушателе; второй просто встал в набор. feed.remove() систему не выключил: тренировка ещё слушает. Выключил её только последний remove(). На телефоне в эти же крючки встаёт нативный код, поэтому забытая отписка там стоит дороже, чем в браузере: модуль продолжает работать на экран, которого давно нет.

Теперь то же с экрана. Подписка на модуль снимается в уборке эффекта, как любая подписка в части IV; нового здесь одно — объект, на который подписываются, живёт в реестре, вне дерева, и переживает экран. В подложке примера — миниатюра этой статьи целиком. Прогноз: что напечатается после того, как тренировку закрыли?

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

Экран ушёл — ушёл последний слушатель — натив выключился. А модуль остался: повторный импорт отдаёт тот же объект, и следующий экран, который на него подпишется, снова разбудит систему через startObserving.

Проверка стоит в отдельном таймере не из аккуратности. Напечатайте число слушателей сразу после fireEvent.press, в той же функции, — и увидите единицу: уборка эффекта выполнилась позже, чем вернулось нажатие.

В настоящем пакете модуль приходит в код файлом с постоянным именем, а какой из двух файлов попадёт в сборку, решает резолвер платформенных расширений из статьи «Одно приложение, три платформы»: для веба он берёт .web.ts. Вот как это выглядит в expo-secure-store и expo-updates (кавычки заменены на двойные, остальное как в исходниках):

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

Пакеты, установленные в приложении автора, отвечают на веб тремя способами:

ПакетФайл для телефонаФайл для вебаЧто получает код в браузере
expo-secure-store 57.0.1requireNativeModule("ExpoSecureStore")export default {}пустой объект, методов нет
expo-device 57.0.1requireNativeModule("ExpoDevice")объект с геттерами, данные разобраны из userAgentответ браузера, реестр не нужен
expo-linking 57.0.7requireNativeModule("ExpoLinking")объект, getLinkingURL() отдаёт window.location.hrefответ браузера, реестр не нужен
expo-updates 57.0.17requireNativeModule("ExpoUpdates")registerWebModule(ExpoUpdatesModule, "ExpoUpdates")экземпляр класса с событиями, записанный в реестр

Первый способ самый честный и самый коварный. Обёртка SecureStore.ts одна на все платформы: она зовёт методы модуля не глядя, а доступность проверяет наличием метода — в пакете это буквально !!ExpoSecureStore.getValueWithKeyAsync. Прогноз: что скажет isAvailableAsync в браузере и чем кончится getItemAsync?

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

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 здесь свой, и в нём забыта одна проверка. Из-за неё лента подписалась на один экземпляр модуля, а событие ушло из другого. Допишите функцию так, чтобы на одно имя приходился один экземпляр.

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

Перед созданием экземпляра спросите реестр: если запись уже есть, верните её. Это та самая строка, которая в registerWebModule.ts стоит до new.

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

Теперь конструктор выполняется один раз, forFeed и forSync — один объект, и событие доходит до ленты: «лента: в сети false». Без проверки каждый импорт получал свой экземпляр, и подписчики одного не слышали рассылку другого — ошибка без исключения, просто тишина.

Кнопка для модуля, которого нет в сборке

Экран прикрепляет фото к тренировке через модуль ExponentImagePicker (имя настоящее, из expo-image-picker 57, — старое «Exponent» осталось в строке с давних времён). В сборке, где модуля нет, экран падает целиком ещё до первой отрисовки. Сделайте так, чтобы экран открывался, а нажатие честно сообщало, что фото недоступно.

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

requireNativeModule бросает исключение прямо при загрузке файла — до mount дело не доходит. requireOptionalNativeModule вместо исключения возвращает null, а решение, что делать без модуля, остаётся за экраном.

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

Тип ImagePickerModule | null заставит проверить модуль перед вызовом — компилятор не пропустит Picker.launchImageLibraryAsync без проверки. В настоящей веб-сборке этот вопрос не встанет вовсе: у expo-image-picker есть .web.ts, который открывает выбор файла браузера.