Что в этой песочнице настоящее

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

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

Две прошлые статьи ловили предпросмотр на расхождениях с телефоном: ответ measure() он отдаёт в другой момент, div рисует, строку внутри View прощает. Теперь проведём границу целиком, и не на глаз, а запуском. Спросим у песочницы, кто она такая (Platform.OS) и какие нативные модули у неё есть (NativeModules), а потом попросим камеру через PermissionsAndroid. Из ответов сложится правило, на котором стоит вся часть: типы React Native описывают все платформы сразу и поэтому шире любого рантайма — и предпросмотра, и даже одного конкретного телефона. Компилятор в таких местах молчит, падает уже запуск. Кто знает это заранее, не примет оговорки «на телефоне иначе» в следующих главах за отговорку.

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

Начнём с вопроса, который приложение задаёт само себе: на чём я запущено? Ответ хранит Platform с нижнего этажа, из React Native. Platform.OS — имя платформы, Platform.Version — версия системы, Platform.select выбирает значение по платформе. Рядом лежит NativeModules — реестр, через который JavaScript находит нативный код, скомпилированный в приложение. Прогноз: что напечатают пять строк? Особенно третья — у select в ней ключи только для iOS и Android.

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

Песочница называет себя честно — web. Это не телефон и не эмулятор, а react-native-web: те же компоненты, нарисованные в HTML. Версия системы — константа "0.0.0" из геттера react-native-web: у браузера нет ни уровня API Android, ни версии iOS. На телефоне здесь было бы одно из двух, и типы это знают: у Android Version — число, у iOS — строка.

Platform.select в react-native-web умещается в одну строку: obj => "web" in obj ? obj.web : obj.default. Нет ни web, ни default — выходит undefined. Компилятор такое предвидел: для вызова без default в типах есть отдельная перегрузка, и она возвращает number | undefined. NativeModules — объект с единственным ключом UIManager, и тот написан на JavaScript: нативного кода в предпросмотре нет ни строчки. Что лежит в этом реестре на телефоне и как в нём ищут модули — статья «Модуль — это запись в реестре» в главе 9.

Платформенная ветка есть в любом приложении, и самая частая — отступ шапки под вырез экрана. Напишем её двумя привычными способами и спросим у браузера, какой отступ получила каждая шапка. Спрашивает hostStyle — помощник из подложки прошлой статьи: сам пример в DOM не заглядывает. Прогноз: какие два значения он напечатает?

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

Два способа — два разных промаха. Тернарник знает две платформы: всё, что не iOS, для него Android, и веб получил отступ Android. select без web и default не вернул ничего, и отступа нет вовсе. На телефоне оба способа дают одно и то же и расходятся ровно там, где платформ больше двух. А у проекта на Expo их три: iOS, Android и веб-версия. Поэтому default в select — не педантизм, а третья платформа, о которой вспомнили заранее. Разводить платформы можно и файлами вместо веток — это статья «Одно приложение, три платформы» во второй главе.

Platform в типах — размеченное объединение из части III с меткой OS. Поэтому Platform.isPad компилятор не пропустит: у Android такого поля нет. После проверки Platform.OS === "ios" — пропустит.

Теперь попросим у песочницы то, чего у браузера точно нет. «Подход» умеет снимать технику упражнения на видео, а на Android камере нужно разрешение, которое спрашивают у пользователя системным диалогом. Для этого в пакете react-native есть PermissionsAndroid: request показывает диалог и возвращает промис со статусом "granted", "denied" или "never_ask_again". Код ниже написан так, как его пишут в проекте, — с объяснением, которое Android покажет пользователю, если тот уже однажды отказал.

Прогноз: сколько диагностик покажет компилятор и что будет в консоли после нажатия?

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

Диагностик ноль. В типах react-native 0.86 PermissionsAndroid объявлен целиком: request, PERMISSIONS.CAMERA, тип объяснения Rationale с обязательными title, message и buttonPositive, — и наш объект с ним сошёлся. А запуск упал: PermissionsAndroid здесь undefined, и чтение его request даёт TypeError. Упало внутри async-функции, поэтому ошибка пришла отклонённым промисом, которого никто не ждал, — это «Uncaught (in promise)» из статьи «Ошибки в промисах». Обработчик нажатия отработал до конца, строка «нажали» успела напечататься, и кнопка на экране по-прежнему жива.

Причина — в том, из чего сделана глобаль ReactNative. Тип у неё — typeof import("react-native"), настоящие типы пакета. А значение — react-native-web, где PermissionsAndroid нет, как нет и других модулей, которым в браузере нечего делать. Деструктуризация получает имя, которое пообещали типы, и значение, которое нашлось в рантайме. Это тот же случай, что в статье «.d.ts и @types: пакет без типов» из части III: объявление — обещание о рантайме, а не его проверка, и здесь выполнять обещание некому.

Граница предпросмотра — заодно граница веб-версии вашего приложения. Собирая её, Expo подменяет react-native на react-native-web: в @expo/cli 57 это словарь aliases.web в файле withMetroMultiPlatform.js. Это тот же react-native-web, что работает в песочнице, и PermissionsAndroid в нём тоже нет.

А на телефоне? Типы react-native одни на все платформы: в них лежат и API только для Android, и API только для iOS. На Android request покажет диалог. На iPhone объект PermissionsAndroid существует, но request первым делом проверяет платформу:

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

Выходит три рантайма и три исхода: на Android — диалог, на iPhone — предупреждение в консоли и честное "denied", в предпросмотре и в веб-версии — TypeError. И всё это против одних и тех же типов, где ни одна строка не намекает на разницу. Сам Platform компилятор по платформам различает, а PermissionsAndroid для него просто есть. Поэтому проверка платформы перед платформенным API — ваша работа, и компилятор о ней не напомнит. Её вы допишете в задаче к статье, а как спрашивать разрешения на обеих платформах и жить с отказом — статья «Разрешения: спросить, получить отказ, жить с ним» в главе 9.

PermissionsAndroid такой не один. Чтобы не ловить эти имена по одному, соберём проверку: пройдём по списку и спросим у рантайма, что лежит под каждым именем. Список типизирован как keyof typeof ReactNative, так что попасть в него могут только имена из типов. Последней строкой спросим имя, которого в типах нет. Прогноз: сколько строк скажут «нет в рантайме» и сколько диагностик покажет компилятор?

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

Шесть имён из девяти есть только в типах, а десятое, CheckBox, — только в рантайме, и компилятор заметил лишь его: в react-native-web он остался, а из React Native давно убран. Остальные шесть промахов прошли молча. Три имени нашлись, и среди них Alert — к нему вернёмся ниже.

Посчитаем целиком — по тем типам, что возит с собой песочница. У модуля react-native в них 116 имён-значений, в react-native-web 0.21.2 — 61, общих — 52. Только в типах — 64 имени, и 37 из них нет даже в рантайме телефона: это служебные имена самих объявлений вроде ViewBase и TextComponent. Остальные 27 — настоящие API телефона, которых нет в предпросмотре. Только в рантайме — 9: CheckBox и Picker, веб-помощники вроде render и unmountComponentAtNode и VirtualizedList, который есть и на телефоне: его типы react-native реэкспортирует из отдельного пакета @react-native/virtualized-lists, а оснастка песочницы этот пакет не возит — потому имя и не попало в наши 116.

27 имён без рантайма в предпросмотреИменаНа телефоне
только AndroidPermissionsAndroid, ToastAndroid, DrawerLayoutAndroid, ProgressBarAndroidAndroid; на iOS — заглушка или ошибка
только iOSActionSheetIOS, DynamicColorIOS, InputAccessoryView, PushNotificationIOS, SettingsiOS; на Android — заглушка или ошибка
цвета системыPlatformColorесть
механика нижнего этажаTurboModuleRegistry, requireNativeComponent, codegenNativeComponent, codegenNativeCommands, NativeComponentRegistry, registerCallableModule, NativeAppEventEmitterесть
инструменты разработкиDevSettings, DevMenu, Systraceесть
хуки анимацииuseAnimatedValue, useAnimatedValueXY, useAnimatedColorесть
прочееunstable_batchedUpdates, RootTagContext, unstable_TextAncestorContext, experimental_LayoutConformanceесть

Третий вид самый коварный: имя есть и в типах, и в рантайме, а поведение разное. Alert проверка нашла, и это действительно класс со статическим alert. Но «есть» ещё не значит «работает». Таких мест в react-native-web немного, и каждое разберём там, где до него дойдём:

ИмяВ предпросмотреГде разберём
Pressable, onPressonPress вызывает click, а не отпускание пальца, и может прийти без onPressIn«Нажатие — это автомат, а не onClick», глава 4
Alert.alertметод есть, но тело пустое: диалога не будет«Modal и свой Alert вместо пустышки», глава 4
KeyboardAvoidingViewобработчик клавиатуры onKeyboardChange пустой«Клавиатура и безопасные зоны: что здесь рисуется, но мертво», глава 4
RefreshControlonRefresh отбрасывается, остаётся голый View«Бесконечная лента и обновление жестом», глава 3
StyleSheet.hairlineWidthвсегда 1«Размеры без пикселей: dp, проценты, PixelRatio», глава 2

probe стоит держать под рукой: имя из документации React Native сначала покажите ей, а потом зовите. «Нет в рантайме» значит, что в предпросмотре и в веб-версии проекта этот вызов упадёт, а на телефоне его судьбу решит платформа.

Граница проходит не только по модулям. В коде на React Native часто встречается __DEV__ — флаг отладочной сборки: показать плашку «dev», включить подробный лог. Типы react-native объявляют его глобальной константой в src/types/globals.d.ts, с примером if (__DEV__) console.log(…) прямо в комментарии. Прогноз: что напечатает пример и появится ли на экране «Подход»?

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

Диагностик ноль, в консоли ReferenceError, экран пуст: исключение оборвало пример раньше mount. На этот раз модуль ни при чём: __DEV__ — не экспорт react-native, а переменная, которую объявляет сборщик. Metro вставляет в начало каждого бандла прелюдию, и начинается она с объявления переменных:

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

Metro собирает и приложение для телефона, и веб-версию проекта на Expo, поэтому в проекте __DEV__ есть на всех трёх платформах: true в отладке, false в релизе. В песочнице Metro нет: рантайм учебника собран esbuild, а пример выполняется сам по себе, без бандла, — и прелюдии нет ни у того, ни у другого. Это граница другого рода: не «телефон или браузер», а «проект или песочница». В примерах части __DEV__ не встретится, а в своём проекте пользуйтесь им свободно.

Соберём опись: у каждой части песочницы — откуда она и насколько она настоящая. По ней дальше удобно взвешивать вывод любого примера.

Что в песочницеОткудаНасколько настоящее
диагностики компилятораTypeScript 6.0.3, сообщения по-русскинастоящие: то же скажет tsc в русской локали; ошибки типов запуск не отменяют
типыпакет react-native 0.86, без библиотеки DOMнастоящие, но описывают все платформы сразу — шире любого рантайма
ReactReact 19, отладочная сборканастоящий: снимки, пачки и предупреждения — как в части IV
View, Text, Pressable, StyleSheet, Platformreact-native-web 0.21.2, отладочная сборканастоящий код, но веб-реализация: View — это div, стиль — CSS, Platform.OS — "web"
красные строки в консолипроверки react-native-web под NODE_ENV !== "production"подлинные, но это слова react-native-web, а не React Native: предупредив, предпросмотр всё равно рисует то, чего телефон не покажет, — как строку в View
NativeModulesreact-native-webодин UIManager на JavaScript; нативного кода нет
mount, fireEventпесочница учебникасвоё: на телефоне их работу делают registerRootComponent и палец
__DEV__—нет: песочницу собирает не Metro
пакеты Expo, камера, файлы, Keychain, уведомления—нет и не будет: только листинг и запись

Пакетов Expo в песочнице нет вовсе. Пример — один файл без npm и сборщика, и всё, что у него есть, лежит в глобалях ReactNative и React. Как Expo находит нативный код и почему этот путь в песочнице обрывается на TurboModuleRegistry, которого, как показала probe, здесь нет, — глава 9. Поэтому expo-router, expo-secure-store, expo-notifications и сервисы EAS приходят в часть листингами и записями.

Оговорки дальше в части бывают четырёх видов, и все они — про то, что вы увидели в этой статье:

ПометкаЧто значитКак с ней обходиться
пример с запускомкод выполняется в песочнице, вывод настоящийсначала прогноз, потом запуск; диагностики читать так же внимательно, как консоль
«листинг · не запускается»коду нужен телефон, Metro или Node: пакеты Expo, конфиги, исходники React Nativeчитать, не запускать; обещанный вывод дан записью
записанный вывод с подписью источниказапись из терминала, консоли Metro или с телефонаснята с настоящего приложения автора (Expo SDK 57, React Native 0.86); ваш проект не обязан выглядеть так же
«на телефоне иначе»в предпросмотре работает, на устройстве ведёт себя по-другомуотличие названо поимённо — как PermissionsAndroid на iPhone в этой статье

Следующая статья — последняя в главе. Заведём свой проект на Expo, запустим Metro и откроем первый экран на телефоне, а стек разложим на этажи: что здесь React Native, а что Expo.

  • Предпросмотр — это react-native-web в браузере, и он называет себя честно: Platform.OS равен "web", Platform.Version — "0.0.0", в NativeModules один UIManager на JavaScript.
  • У проекта на Expo три платформы. Тернарник Platform.OS === "ios" ? … : … отдаёт вебу ветку Android, Platform.select без web и default — undefined; default закрывает всё остальное.
  • Типы react-native одни на все платформы и шире любого рантайма. PermissionsAndroid, ToastAndroid, ActionSheetIOS, PlatformColor, TurboModuleRegistry, useAnimatedValue компилируются без единой диагностики и в предпросмотре падают TypeError; на iPhone PermissionsAndroid предупреждает и отвечает "denied".
  • Обратное тоже бывает: CheckBox и Picker живут в react-native-web, но не в типах, — такое ловит компилятор. А имя, которое есть везде, ещё не обязано работать: Alert.alert в вебе пустой.
  • __DEV__ объявляет прелюдия Metro: в проекте он есть на всех платформах, в песочнице — ReferenceError.
  • Проверку платформы перед платформенным API пишете вы: компилятор различает платформы только у самого Platform. Имя из документации сначала покажите probe, потом зовите.

1/4В предпросмотре вызвали Platform.select({ ios: 44, android: 24 }). Что вернётся?

2/4PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA) выполнился на iPhone. Что будет?

3/4Почему компилятор не предупредил, что PermissionsAndroid в предпросмотре нет?

4/4if (__DEV__) в песочнице даёт ReferenceError. А в проекте на Expo?

Шапка для трёх платформ

Шапка тренировки должна получать отступ 44 на iOS, 24 на Android и 16 на всех остальных платформах, включая веб. Сейчас веб получает отступ Android. Перепишите строку с paddingTop одним вызовом Platform.select и скажите, какой тип у paddingTop получится.

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

Нужен default, а не web:

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

Вывод — «отступ шапки: 16px», на iOS и Android остаются 44 и 24. Ключ web: 16 тоже дал бы 16 в предпросмотре, но у Platform.OS в типах пять значений, и Windows с macOS получили бы undefined. С default срабатывает первая перегрузка select, и тип paddingTop — number, без undefined.

В настоящем приложении высоту выреза константой не угадывают: её отдаёт провайдер безопасных отступов, и его мы соберём в главе 4.

Кнопка, которая не роняет веб

Кнопка «Снять подход» спрашивает разрешение на камеру и в предпросмотре падает. Допишите askCamera так, чтобы на вебе и iOS дело не доходило до PermissionsAndroid, а в консоли вместо ошибки появилась строка «камера: …» с понятным ответом. Подумайте, почему здесь проверяют платформу, а не наличие объекта.

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

Проверять нужно платформу:

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

Вывод — «камера: спросит модуль камеры». Компилятор ни до, ни после правки ничего не сказал: Platform.OS он сужает, а PermissionsAndroid для него есть на любой платформе.

Соблазн — написать PermissionsAndroid?.request(…). Компилятор не возразит, предпросмотр напечатает «камера: undefined», а на iPhone объект есть, и вызов уйдёт в предупреждение и "denied". Проверка наличия отвечает на вопрос «есть ли модуль», а здесь вопрос другой — «на какой я платформе». На iOS разрешение на камеру спрашивает модуль камеры, об этом статья «Разрешения: спросить, получить отказ, жить с ним» в главе 9.