Одно приложение, три платформы

стиль≈ 16 минут

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

Вся глава сверяла телефон с предпросмотром, и почти в каждой статье нашлось место, где им нужен разный код. А платформ у «Подхода» не две, а три: iOS, Android и веб-версия. Разводить их можно двумя способами. Первый — ветка в коде: Platform.OS и Platform.select. Второй — файл: session-storage.ts для телефона рядом с session-storage.web.ts для браузера, а какой из них взять, решает сборщик. Разберём select телефона, упрёмся в ветки, которые расползаются по коду, посмотрим, что с ними делает выпускная сборка, и соберём резолвер расширений, который выбирает файл так же, как Metro, а поверх него — загрузчик модулей под платформу. Этажей в статье три: Platform — нижний, сам React Native; выбор файла — Metro, сборщик, с которым поставляется React Native; порядок расширений, суффикс .native и подстановку Platform.OS настраивает Expo. Сборщика в песочнице нет, поэтому файлы здесь — список имён, а правило резолвера сверено запуском настоящего metro-resolver.

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

В статье «Что в этой песочнице настоящее» Platform.select оказался одной строкой react-native-web: есть ключ web — берётся он, нет — default. Там же выяснилось, что default — это третья платформа, о которой подумали заранее. Но предпросмотр показывает только веб-половину правила. На телефоне у select на один ключ больше:

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

iOS и Android сначала ищут свой ключ, потом native — «любой телефон», — и только потом default. Веб о native не знает вовсе: он не телефон. Проверить это в песочнице напрямую нельзя — Platform.OS здесь всегда "web". Поэтому поступим, как с плотностью в прошлой статье: там densityFor(scale) отвечала за телефон с заданной плотностью, здесь platformFor(os) ответит за телефон с заданной системой — по тем же правилам, что в исходниках выше. Последней строкой сравним её с настоящим Platform.select предпросмотра. Прогноз: что выберет каждая платформа в трёх решениях «Подхода» — шрифт, место для токена и способ вернуться назад?

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

iOS взял свой шрифт System, Android — sans-serif, веб — default. Токен оба телефона положили в SecureStore: своих ключей у них нет, и сработал native. Веб его пропустил и взял default — localStorage. Вернуться назад Android предлагает системной кнопкой, остальные — кнопкой в шапке. Строка предпросмотра совпала со строкой web: наша функция и react-native-web выбирают одинаково. С этого места platformFor лежит в подложке: где предпросмотр отвечает за браузер, она ответит за телефон.

native в select — не платформа, а «любой телефон»: iOS и Android берут его, если своего ключа нет, веб — никогда. Тот же смысл у суффикса .native в имени файла, о нём третий раздел.

Теперь стена, в которую упирается проект. «Подход» хранит токен входа. На телефоне его место — защищённое хранилище системы: Keychain на iOS, Keystore на Android. До него дотягивается expo-secure-store — модуль SDK, верхний этаж. В браузере Keychain нет, остаётся localStorage. Первое, что приходит в голову, — один файл и ветка по Platform.OS в каждом методе. Пакеты Expo в песочнице не собираются, поэтому веб-сборка expo-secure-store изображена ровно тем, что в ней лежит: файл ExpoSecureStore.web.ts — это export default {}, а функции обёртки SecureStore.ts, повторённой в сокращении, зовут методы этого объекта. Прогноз: что напечатают вход и выход в предпросмотре?

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

Вход сработал: Platform.OS в предпросмотре — "web", и оба метода с проверкой ушли в память. Выход упал: removeItem дописывали позже, проверяли на телефоне, и ветку для веба забыли. Компилятор промолчал — модуль из реестра приходит типом any: у requireNativeModule из expo-modules-core параметр типа по умолчанию any, а ExpoSecureStore.ts вызывает его без параметра. Ошибку увидит пользователь веб-версии, когда нажмёт «Выйти». В этом и стена: платформ три, и каждый новый метод, каждое место, где читают токен, обязано помнить все три. Проверки Platform.OS расползаются по коду, а забытая ломает одну платформу, и только в рантайме.

Второй вопрос — что с таким файлом делает сборка. В коде Platform.OS читается при каждом вызове, но выпускная сборка собирается под одну платформу и знает её заранее. babel-preset-expo в выпускном режиме подставляет вместо Platform.OS строку платформы, а вызов Platform.select со статичными ключами заменяет выбранным значением. Потом transform-worker из @expo/metro-config прогоняет свёртку констант — constantFoldingPlugin из Metro, — и ветка с заведомо ложным условием исчезает. Вот что вышло из настоящих пакетов на файле с той же веткой; служебные строки модуля — хелперы и объявления экспортов — из вывода убраны:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал: babel-preset-expo 57.0.8 в выпускном режиме, затем свёртка констант metro-transform-plugins 0.84.5
// ---- ios
var _reactNative = require("react-native");
var SecureStore = _interopRequireWildcard(require("expo-secure-store"));
async function getItem(key) {
return SecureStore.getItemAsync(key);
}
var tokenPlace = exports.tokenPlace = "SecureStore";
// ---- web
var _Platform = _interopRequireDefault(require("react-native-web/dist/exports/Platform"));
var SecureStore = _interopRequireWildcard(require("expo-secure-store"));
async function getItem(key) {
return localStorage.getItem(key);
return SecureStore.getItemAsync(key);
}
const tokenPlace = exports.tokenPlace = "localStorage";

Ветка чужой платформы и правда исчезла: на iOS от getItem осталось только чтение SecureStore, а select превратился в строку. Но require("expo-secure-store") остался в обеих сборках — свёртка убирает ветки, а не импорты. В веб-версии getItem после return лежит мёртвая строка с SecureStore, а сам модуль попадёт в веб-сборку: tree shaking у Metro в Expo 57 экспериментальный, его включает переменная EXPO_UNSTABLE_TREE_SHAKING, а по умолчанию она выключена. В сборке для разработки не подставляется ничего — тот же прогон в режиме разработки оставил Platform.OS === "web" и вызов select как есть: платформа читается в рантайме, и на месте все ветки. Исключение — process.env.EXPO_OS: его babel-preset-expo заменяет строкой платформы всегда, при разработке тоже.

У самого Metro есть такой же плагин — inline-plugin из metro-transform-plugins, — но в transform-worker Expo строка с ним закомментирована: подстановку делает babel-preset-expo, ещё до перевода модуля в CommonJS. Правила у двух плагинов разные, и это проверено запуском обоих на одном вызове select: для веба плагин Metro подставил «SecureStore», то есть значение native, а плагин Expo — «localStorage», как react-native-web в рантайме.

Второй способ — развести платформы не веткой, а файлом. Два файла с одним именем и разными суффиксами, а импорт — без суффикса и без расширения: import { sessionStorage } from "./session-storage". Какой из файлов подставить, решает сборщик, отдельно для каждой платформы. Так хранилище сессии сделано в мобильном приложении автора. Кавычки заменены на двойные, веб-версия сокращена: у автора каждый вызов там обёрнут в try, потому что в приватном режиме Safari запись бросает исключение.

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

Сравните с веткой. Проверок Platform.OS нет ни одной. Веб-сборка не видит expo-secure-store вовсе: его импортирует только файл, который в неё не попадает. Новый метод в интерфейсе заставит дописать оба файла — при условии, что оба объявлены одним типом; к этому вернёмся в последнем разделе. Тем же приёмом пользуются сами пакеты: у expo-secure-store рядом лежат ExpoSecureStore.ts и ExpoSecureStore.web.ts, у expo — registerRootComponent.tsx и registerRootComponent.web.tsx из статьи про свой проект.

Правило выбора живёт в Metro — сборщике, с которым поставляется React Native, — в функции resolveSourceFile пакета metro-resolver:

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

Сначала путь пробуется как есть: так находится импорт, записанный с расширением. Затем для каждого расширения по порядку делаются три попытки: .платформа.расширение, потом .native.расширение, если включён preferNativePlatform, и наконец просто .расширение. Остальное настраивает Expo. Порядок расширений собирает @expo/metro-config: getBareExtensions из @expo/config даёт ts, tsx, mjs, js, jsx и json, а в конец дописывается cjs. preferNativePlatform ставит @expo/cli в withMetroMultiPlatform.js одним выражением: platform !== "web". Отсюда таблица:

СуффиксiOSAndroidВеб
.iosберёт——
.android—берёт—
.nativeесли нет .iosесли нет .android—
.web——берёт
без суффиксаесли нет своего и .nativeесли нет своего и .nativeесли нет .web

В таблице спрятана оговорка — в словах «если нет»: сравниваются файлы с одним и тем же расширением, а расширения перебираются снаружи. Во что это выливается, покажет свой резолвер.

В песочнице нет ни диска, ни сборщика, поэтому файлы проекта станут списком имён, а резолвер — функцией от списка, пути импорта и платформы. Правило то же, что в resolveSourceFile: путь как есть, затем расширения в порядке Expo и три попытки внутри каждого. Неудачные попытки копим в tried. Прогноз: какой файл достанется каждой платформе для хранилища, вибрации и нижней панели?

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

Хранилище разошлось, как задумано: телефонам — session-storage.ts, вебу — .web.ts. session-storage.types.ts не выбрался ни разу: types — не платформа, а путь ./session-storage плюс .ts даёт другое имя. У вибрации каждому телефону свой файл, а веб взял общий — своего у него нет, и общий может просто ничего не делать. Панель tab-bar.native.tsx досталась обоим телефонам, а веб её пропустил. Так же в expo-router лежат useBackButton.native.js и useBackButton.js: первый подписывается на системную «назад» Android, он встретится в статье «Собираем стек экранов с историей».

Теперь оговорка из прошлого раздела. Внешний цикл — по расширениям, внутренний — по суффиксам. Значит, суффикс платформы побеждает только среди файлов с одним расширением. С этого места резолвер лежит в подложке рядом с platformFor. Диалог подтверждения: телефонная версия — app-alert.ts без JSX, а веб-версию на Modal написали прямо в app-alert.web.tsx. Рядом — звук конца отдыха, который есть только для iOS и Android. Прогноз: что достанется вебу в обоих случаях?

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

Веб получил телефонный app-alert.ts. Для расширения .ts резолвер попробовал app-alert.web.ts, затем app-alert.ts — и второй файл нашёлся; до .tsx очередь не дошла. Сборка пройдёт без единого предупреждения, а в веб-версии вызов уйдёт в Alert.alert — в react-native-web у Alert единственный статический метод alert с пустым телом, и диалог молча не появится. Лечится одинаковым расширением в паре: .ts с .web.ts или .tsx с .web.tsx. В статье «Modal и свой Alert вместо пустышки» веб-файл так и сделан: app-alert.web.ts только реэкспортирует диалог из соседнего модуля.

Звук отдыха — противоположный случай, громкий. Для веба нет ни своего файла, ни общего, и резолвер перебрал четырнадцать имён. Metro на этом месте останавливает сборку с тем же списком в скобках: «The module could not be resolved because none of these files exist». Файлы только под телефоны — ошибка сборки для веба, и хорошо, что ошибка: её видно сразу, а не у пользователя. Лечится общим файлом-заглушкой rest-sound.ts.

Резолвер — миниатюра, и её стоит сверить с оригиналом. Настоящий metro-resolver 0.84.5 можно вызвать из Node, подсунув ему выдуманную папку вместо диска. Контекст у него большой, в листинге оставлены поля, от которых зависит выбор файла:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал: node, metro-resolver 0.84.5
./app-alert ios → /app/src/app-alert.ts
./app-alert android → /app/src/app-alert.ts
./app-alert web → /app/src/app-alert.ts
./rest-sound ios → /app/src/rest-sound.ios.ts
./rest-sound android → /app/src/rest-sound.android.ts
./rest-sound web → The module could not be resolved because none of these files exist:
* /app/src/rest-sound(.web.ts|.ts|.web.tsx|.tsx|.web.mjs|.mjs|.web.js|.js|.web.jsx|.jsx|.web.json|.json|.web.cjs|.cjs)
* /app/src/rest-sound

Совпало всё, вплоть до порядка попыток в скобках. Вторая строка со звёздочкой — попытка Metro найти папку rest-sound; папки разберём в задаче номер два. На тех же выдуманных папках сверены и ответы первого примера этого раздела и всех задач.

Резолвер выбирает имя. Дальше сборщик кладёт в сборку код только выбранного файла, а выполняет его один раз — при первом require. В сборке Metro каждый файл — фабрика, зарегистрированная через define(factory, moduleId, dependencyMap), а metroRequire отдаёт уже выполненный модуль, если у него стоит isInitialized; это metro-runtime/src/polyfills/require.js — тот же реестр с кешем, что в своём сборщике. Повторим его поверх резолвера: requireFor(platform, modules) принимает файлы проекта словарём «путь → фабрика» и возвращает свою функцию загрузки. Хранилища в песочнице — память, но печатают, куда пишут. Прогноз для двух сборок?

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

Сборка iOS выполнила только session-storage.ts, веб — только .web.ts; чужой файл не выполнился ни разу, а в настоящей сборке его кода нет вовсе. Второй вызов load вернул тот же объект: модуль выполняется один раз, как у metroRequire. requireFor с этого места тоже лежит в подложке — в главе 6 так, сменой файла по расширению, хранилище в памяти подменится настоящей базой в телефоне.

Остался компилятор. В песочнице оба файла живут в одном объекте, и тип можно повесить на весь словарь сразу. В проекте о платформах компилятор не знает. В expo/tsconfig.base.json стоит moduleResolution: "bundler", и путь ./session-storage компилятор разрешает в session-storage.ts — телефонный файл. Веб-файл он проверит лишь на то, что тот объявил о себе сам. Проверено на копии с настройками Expo: tsc 6.0.3 разрешил импорт в телефонный файл и промолчал о веб-файле без removeItem. Поэтому у автора интерфейс вынесен в session-storage.types.ts, а каждый файл объявляет export const sessionStorage: SessionStorage: пропуск в любом из двух назовёт компилятор. У TypeScript есть и опция moduleSuffixes — например [".ios", ".native", ""], — с ней импорт разрешается так, как на iOS; но список в tsconfig один, а платформ три.

У экранов в папке app правило своё: timer.tsx, timer.ios.tsx и timer.web.tsx дают один адрес, и выбирает между ними сам expo-router. Это статья «Собираем маршруты: путь файла — это адрес» в главе 5.

  • Платформы разводят двумя способами: веткой в коде — Platform.OS и Platform.select — и файлом: x.ts рядом с x.web.ts. select на телефоне ищет свой ключ, потом native, потом default; веб — только web и default. Своя platformFor(os) повторяет оба правила.
  • Ветки расползаются: каждый метод обязан помнить три платформы, а забытая ветка ломает одну из них только в рантайме. В выпускной сборке babel-preset-expo подставляет Platform.OS и значение select, а свёртка убирает мёртвые ветки — но не импорты: телефонный модуль всё равно уедет в веб-сборку. При разработке не подставляется ничего.
  • Файл выбирает Metro: для каждого расширения по порядку Expo — ts, tsx, mjs, js, jsx, json, cjs — он пробует .платформа, затем .native (кроме веба), затем имя без суффикса. Импорт пишут без суффикса и без расширения.
  • Расширение важнее платформы: x.ts побеждает x.web.tsx, и веб молча получает телефонный файл, поэтому расширения в паре держат одинаковыми. Если у платформы нет ни своего файла, ни общего, сборка останавливается со списком попыток.
  • Своя миниатюра — resolvePlatform на двадцать строк и requireFor поверх неё: сборка под платформу выполняет только выбранный файл и только один раз. Обе лежат в подложке вместе с platformFor.
  • Компилятор о платформах не знает и разрешает импорт в x.ts; веб-файл проверяется лишь на то, что объявил сам. Общий интерфейс в x.types.ts и аннотация в каждом файле делают пропуск в любом из них ошибкой компилятора.

1/4Что вернёт Platform.select({ native: "SecureStore", default: "localStorage" }) на iPhone и в веб-версии?

2/4В папке лежат map.ts и map.web.tsx, импорт — "./map". Что попадёт в веб-сборку?

3/4Файл импортирует expo-secure-store и зовёт его в ветке if (Platform.OS !== "web"). Что будет в выпускной веб-сборке?

4/4В проекте есть session-storage.ts и session-storage.web.ts. Как компилятор узнает, что в веб-файле забыт removeItem?

Выход, которого нет в вебе

Два файла хранилища собраны через requireFor из подложки. Веб-файл писали отдельно и проверяли только вход, поэтому выход в вебе падает, а компилятор молчит. Добавьте тип так, чтобы пропуск назвал компилятор, а потом исправьте сам пропуск.

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

Тип на словаре файлов — это session-storage.types.ts в миниатюре:

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

После этой правки компилятор отмечает веб-файл: «Свойство "removeItem" отсутствует в типе "{ getItem: …; setItem: …; }" и является обязательным в типе "SessionStorage"». Ошибка видна до запуска, без нажатия «Выйти». Дописываем метод:

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

Вывод — «ios: вышли» и «web: вышли». as SessionStorage в месте вызова ничего не проверял: это утверждение для компилятора, а не проверка, поэтому ошибка и дожила до рантайма. В проекте ту же работу делает аннотация в каждом файле, export const sessionStorage: SessionStorage, — как у автора.

Папка вместо файла

Хранилище разрослось и переехало в папку: storage/index.ts и storage/index.web.ts. Импорт остался прежним — "./storage". Metro, не найдя файла, ищет в папке index тем же правилом. Допишите resolveModule. Что должен получить веб для "./timer", где есть и файл, и папка?

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

Вывод: ./storage — ./storage/index.ts для iOS и ./storage/index.web.ts для веба; ./timer — ./timer.ts обоим. Файл проверяется раньше папки, поэтому timer/index.web.ts веб не получит никогда: timer.ts нашёлся на первом шаге. Настоящий metro-resolver на тех же именах отвечает так же.

В Metro между файлом и index есть ещё шаг: если в папке лежит package.json, точку входа берут из его полей — у Expo для телефонов это react-native, browser и main, для веба — browser, module и main. Без package.json сразу ищется index, как в нашем решении.

Файл и select на трёх платформах

Не запуская, скажите, какой файл получит каждая платформа для трёх импортов — "./chart", "./session-storage.ts" и "./session-storage.web", — и что вернёт select с ключами ios и native. Резолвер и platformFor лежат в подложке. Потом запустите и сверьтесь.

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

Первый столбец — та же ловушка с расширением, только на телефоне. Для .ts резолвер пробует chart.ios.ts, затем chart.native.ts — и находит его; chart.ios.tsx проверялся бы только на следующем расширении, до которого дело не дошло. А select на iOS взял ios: у ключей объекта расширений нет, и свой ключ побеждает всегда. Веб native пропускает в обоих способах: общего файла нет — сборка остановится, общего ключа нет — undefined.

Импорт с расширением находится на первом шаге, как есть, и платформа не спрашивается: веб получит телефонный файл вместе с expo-secure-store. Импорт .web без расширения — тоже не выбор платформы, а часть имени: на iOS для .ts резолвер ищет session-storage.web.ios.ts, session-storage.web.native.ts и находит session-storage.web.ts — и так на всех трёх платформах. Правило простое: платформенный файл импортируют без суффикса и без расширения. Настоящий metro-resolver отвечает на эти три импорта так же.