Что в этой песочнице настоящее
Здесь будет встроен видео-разбор темы (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.
Песочница называет себя честно — 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 не заглядывает. Прогноз: какие два значения он напечатает?
Два способа — два разных промаха. Тернарник знает две платформы: всё, что не 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 покажет пользователю, если тот уже однажды отказал.
Прогноз: сколько диагностик покажет компилятор и что будет в консоли после нажатия?
Диагностик ноль. В типах 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 первым делом проверяет платформу:
Выходит три рантайма и три исхода: на Android — диалог, на iPhone — предупреждение в консоли и честное "denied", в предпросмотре и в веб-версии — TypeError. И всё это против одних и тех же типов, где ни одна строка не намекает на разницу. Сам Platform компилятор по платформам различает, а PermissionsAndroid для него просто есть. Поэтому проверка платформы перед платформенным API — ваша работа, и компилятор о ней не напомнит. Её вы допишете в задаче к статье, а как спрашивать разрешения на обеих платформах и жить с отказом — статья «Разрешения: спросить, получить отказ, жить с ним» в главе 9.
PermissionsAndroid такой не один. Чтобы не ловить эти имена по одному, соберём проверку: пройдём по списку и спросим у рантайма, что лежит под каждым именем. Список типизирован как keyof typeof ReactNative, так что попасть в него могут только имена из типов. Последней строкой спросим имя, которого в типах нет. Прогноз: сколько строк скажут «нет в рантайме» и сколько диагностик покажет компилятор?
Шесть имён из девяти есть только в типах, а десятое, 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 имён без рантайма в предпросмотре | Имена | На телефоне |
|---|---|---|
| только Android | PermissionsAndroid, ToastAndroid, DrawerLayoutAndroid, ProgressBarAndroid | Android; на iOS — заглушка или ошибка |
| только iOS | ActionSheetIOS, DynamicColorIOS, InputAccessoryView, PushNotificationIOS, Settings | iOS; на 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, onPress | onPress вызывает click, а не отпускание пальца, и может прийти без onPressIn | «Нажатие — это автомат, а не onClick», глава 4 |
Alert.alert | метод есть, но тело пустое: диалога не будет | «Modal и свой Alert вместо пустышки», глава 4 |
KeyboardAvoidingView | обработчик клавиатуры onKeyboardChange пустой | «Клавиатура и безопасные зоны: что здесь рисуется, но мертво», глава 4 |
RefreshControl | onRefresh отбрасывается, остаётся голый 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(…) прямо в комментарии. Прогноз: что напечатает пример и появится ли на экране «Подход»?
Диагностик ноль, в консоли ReferenceError, экран пуст: исключение оборвало пример раньше mount. На этот раз модуль ни при чём: __DEV__ — не экспорт react-native, а переменная, которую объявляет сборщик. Metro вставляет в начало каждого бандла прелюдию, и начинается она с объявления переменных:
Metro собирает и приложение для телефона, и веб-версию проекта на Expo, поэтому в проекте __DEV__ есть на всех трёх платформах: true в отладке, false в релизе. В песочнице Metro нет: рантайм учебника собран esbuild, а пример выполняется сам по себе, без бандла, — и прелюдии нет ни у того, ни у другого. Это граница другого рода: не «телефон или браузер», а «проект или песочница». В примерах части __DEV__ не встретится, а в своём проекте пользуйтесь им свободно.
Соберём опись: у каждой части песочницы — откуда она и насколько она настоящая. По ней дальше удобно взвешивать вывод любого примера.
| Что в песочнице | Откуда | Насколько настоящее |
|---|---|---|
| диагностики компилятора | TypeScript 6.0.3, сообщения по-русски | настоящие: то же скажет tsc в русской локали; ошибки типов запуск не отменяют |
| типы | пакет react-native 0.86, без библиотеки DOM | настоящие, но описывают все платформы сразу — шире любого рантайма |
| React | React 19, отладочная сборка | настоящий: снимки, пачки и предупреждения — как в части IV |
View, Text, Pressable, StyleSheet, Platform | react-native-web 0.21.2, отладочная сборка | настоящий код, но веб-реализация: View — это div, стиль — CSS, Platform.OS — "web" |
| красные строки в консоли | проверки react-native-web под NODE_ENV !== "production" | подлинные, но это слова react-native-web, а не React Native: предупредив, предпросмотр всё равно рисует то, чего телефон не покажет, — как строку в View |
NativeModules | react-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; на iPhonePermissionsAndroidпредупреждает и отвечает"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 получится.
Нужен default, а не web:
Вывод — «отступ шапки: 16px», на iOS и Android остаются 44 и 24. Ключ web: 16 тоже дал бы 16 в предпросмотре, но у Platform.OS в типах пять значений, и Windows с macOS получили бы undefined. С default срабатывает первая перегрузка select, и тип paddingTop — number, без undefined.
В настоящем приложении высоту выреза константой не угадывают: её отдаёт провайдер безопасных отступов, и его мы соберём в главе 4.
Кнопка, которая не роняет веб
Кнопка «Снять подход» спрашивает разрешение на камеру и в предпросмотре падает. Допишите askCamera так, чтобы на вебе и iOS дело не доходило до PermissionsAndroid, а в консоли вместо ошибки появилась строка «камера: …» с понятным ответом. Подумайте, почему здесь проверяют платформу, а не наличие объекта.
Проверять нужно платформу:
Вывод — «камера: спросит модуль камеры». Компилятор ни до, ни после правки ничего не сказал: Platform.OS он сужает, а PermissionsAndroid для него есть на любой платформе.
Соблазн — написать PermissionsAndroid?.request(…). Компилятор не возразит, предпросмотр напечатает «камера: undefined», а на iPhone объект есть, и вызов уйдёт в предупреждение и "denied". Проверка наличия отвечает на вопрос «есть ли модуль», а здесь вопрос другой — «на какой я платформе». На iOS разрешение на камеру спрашивает модуль камеры, об этом статья «Разрешения: спросить, получить отказ, жить с ним» в главе 9.