PWA или приложение из магазина

сборка≈ 16 минут

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

Прошлая статья закончилась иконками и веб-экспортом, следующая начнётся с трёх кабинетов разработчика и первой отправки. Между ними стоит вопрос, который дешевле задать до того, как заплачены 99 долларов: нужен ли магазин вообще. Код у «Подхода» один, и Expo отдаёт его двумя способами — сборкой для телефона и статическим сайтом по expo export --platform web. Выбирают не между технологиями, а между каналами доставки, и у каждого канала свой список того, чего он не умеет. Как устроен service worker, мы разобрали в главе части II «Service worker и PWA»; здесь — только решение. Проверим три вещи запуском: что из модулей «Подхода» доживает до браузера, что браузер отвечает на прямой вопрос о своих умениях и где проходит черта, после которой магазин перестаёт быть делом вкуса. Веб-экспорт при этом настоящий: мы выполнили его на приложении автора (Expo SDK 57) и разобрали получившуюся папку по файлам.

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

У приложения автора веб-версия живёт на подпути /app, и собирает её одна команда — в package.json она записана скриптом export:web. Мы её выполнили:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · настоящий запуск на приложении автора, Expo SDK 57, вывод в отдельную папку; фрагмент, пропуски отмечены …
Using (experimental) base path: /app
Expo Autolinking module resolution enabled
Starting Metro Bundler
Static rendering is enabled. Learn more: https://docs.expo.dev/router/web/static-rendering/
Web Bundled 8291ms node_modules/…/expo-router/entry.js (1007 modules)
› web bundles (1):
_expo/static/js/web/entry-bbe441cf9b0b0e50a999c4ee749beebd.js (1.8MB)
› Static routes (13):
/ (index) (21KB)
/login (22KB)
/profile (25KB)
…
Exported: dist-web

В dist-web легли 42 файла. Вот они, с пометками, откуда что взялось:

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

Это обычный статический сайт: по файлу на маршрут (в конфиге web.output: "static"), один бандл с хешем в имени и картинки. Никакого сервера ему не нужно — у автора его отдаёт Caddy из тома, в который образ сборки выкладывает готовую статику. Папка public/ при этом копируется целиком: в @expo/cli за это отвечает copyPublicFolderAsync, и мы сверили файлы побайтно — manifest.webmanifest и sw.js в экспорте ровно те же, что в public/. Страницы маршрутов и бандл экспорт, конечно, собрал сам, а вот из картинок сделал ровно одну — favicon.ico из поля web.favicon; его разобрала прошлая статья.

А теперь главное — чего в этой команде нет. Ни манифеста, ни service worker Expo не создаёт. Это легко принять на веру неправильно, поэтому проверим по установленному пакету, а рядом поставим контрольное слово, которое там точно есть:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал · наш запуск по @expo/cli 57.0.18 из node_modules проекта
807
0
0
12

Восемьсот с лишним файлов, и ни одного упоминания service worker или workbox. Значит, PWA из веб-сборки Expo делаете вы: манифест, воркер и его регистрация — ваши файлы. У автора они лежат в public/ и потому доезжают до dist-web сами. Манифест целиком:

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

Главные поля этого файла разобраны в статье части II «Манифест и установка на экран»; здесь важно одно: он написан руками и лежит рядом с иконками, которые прошлая статья готовила для телефона. Регистрация воркера тоже своя — строкой в шаблоне страницы:

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

Мы нашли этот скрипт в собранном dist-web/index.html — он попадает в каждую страницу экспорта. Механизмы (жизненный цикл воркера, стратегии кеша, обновление) — глава части II; здесь нас интересует другое: если экран и логика те же, что на телефоне, то что из модулей «Подхода» переживает переезд в браузер?

Ответ даёт реестр модулей из статьи «Модуль — это запись в реестре»: он в подложке, и registerWebModule кладёт в него веб-реализацию, а requireOptionalNativeModule её оттуда достаёт. Настоящие пакеты Expo в предпросмотр не собираются вовсе — expo-modules-core тянет TurboModuleRegistry, которого у react-native-web нет, — поэтому веб-файлы модулей ниже переписаны по исходникам SDK 57, с теми же именами и теми же текстами. Соберём перепись трёх способностей «Подхода»: обновление по воздуху, токен синхронизации в SecureStore и звонок таймера отдыха. Прогноз перед запуском: какая из трёх работает в браузере, какая молча отвечает «нечего делать», а какая падает?

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

Три разных способа не работать. expo-updates веб-реализацию имеет, и она честно отвечает isAvailable: false: обновлений по воздуху в вебе не бывает — там обновление это новый деплой, и reload() у той же заглушки сводится к window.location.reload. ExpoSecureStore.web.ts — пустой объект, и метода в нём не окажется в момент вызова, а не при сборке; так модуль и устроен, мы вскрывали его в главе 9. А у уведомлений в вебе нет планировщика: файл NotificationScheduler.ts без расширения .native состоит из двух заглушек, и scheduleNotificationAsync бросает UnavailabilityError с тем самым текстом про «linked all the native dependencies». Заметьте, что решение принимает не платформа, а наличие метода: if (!NotificationScheduler.scheduleNotificationAsync).

Ни одно из трёх не чинится кодом. Это свойства канала: браузеру не выдают ни Keychain, ни системный планировщик, а обновлять сайт через магазин никому не нужно. Поэтому дальше вопрос ставится не «как обойти», а «что из этих потерь смертельно для дневника тренировок».

Веб-сборка Expo — статический сайт. PWA из него делаете вы: манифест, service worker и его регистрация — ваши файлы в public/.

«PWA умеет всё, кроме мелочей» и «PWA на айфоне не работает» — два одинаково бесполезных утверждения. Полезен список проверок, каждая из которых отвечает за одно решение. Спросим браузер прямо сейчас. Заглядывание в window и navigator идёт в подложке: у примера React Native их нет, и правильно — на телефоне их тоже нет. Каждая проверка обёрнута, потому что отказ безопасности — такой же ответ, как «нет».

Прогноз: сколько строк ответят «есть» и что окажется в строке про SharedArrayBuffer?

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

Разберём по строкам — и сразу отделим то, что говорит о браузерах вообще, от того, что говорит только о нашей песочнице. Первая строка — как раз из второго рода: Platform.OS здесь всегда web, потому что примеры части выполняет react-native-web. На телефоне в ней стояло бы ios или android, и про канал доставки она не сообщает ничего — про него говорят следующие восемь.

СтрокаЧто она решает
своя кнопка «Установить»Есть ли beforeinstallprompt — событие, которым приложение предлагает установку само. Событие нестандартное, оно есть только в Chromium. В Firefox и Safari строка читается «нет», и это не значит «нельзя установить»: значит «нельзя предложить»
разрешение на уведомленияЗдесь это ответ песочницы: у кадра уникальное происхождение, и Chromium отвечает denied. На настоящем сайте вы увидите default, пока не спросили, и denied после отказа. Проверка главы части II в той же песочнице получила от Firefox 155 default — значение зависит и от браузера
фоновая синхронизацияBackground Sync — тоже только Chromium. Для «Подхода» это значит, что отправить накопленный журнал, пока вкладка закрыта, в общем случае нельзя: синхронизация случится при возврате (статья «Свернули и вернулись»)
открыто как приложениеТак приложение узнаёт, что его поставили на экран: display-mode: standalone. В обычной вкладке — «нет», и по этой же проверке показывают подсказку «поставьте на экран»
кросс-источниковая изоляция и SharedArrayBufferЦена настоящей базы в браузере: веб-сборка expo-sqlite ждёт ответ рабочего потока через SharedArrayBuffer, а он выдаётся только изолированной странице — той, которой сервер прислал COOP и COEP
хранилище ключ-значение и IndexedDBОграничение песочницы, а не канала: localStorage здесь подменён хранилищем в памяти, а IndexedDB в уникальном происхождении запрещена. На настоящем сайте есть и то и другое

Две строки про изоляцию стоят отдельного слова, потому что это единственная проверка, ответ на которую зависит от вас, а не от браузера пользователя. Заголовки Cross-Origin-Opener-Policy и Cross-Origin-Embedder-Policy шлёт ваш хостинг. У автора статику отдаёт Caddy, и он их не шлёт — там заданы только правила кеша:

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

Пока этих заголовков нет, веб-версия «Подхода» останется без того самого файла базы, который мы открывали в статье «expo-sqlite: настоящая база в телефоне». Дневник в браузере придётся держать в другом хранилище — IndexedDB, — и место для него уже готово: это тот самый подменяемый по расширению journal-db.web.ts, который в песочнице лежал поверх ящика из главы 6. А включить изоляцию не всегда дёшево: COEP ломает загрузку чужих ресурсов, пока их сервер не пришлёт нужных заголовков, — у автора с чужого домена приходит счётчик Метрики.

Ответ «нет» у возможности, которая есть только в Chromium, — это не про ваш код. Это карта браузеров ваших пользователей.

Проверки превращаются в решение одним движением: у каждого требования приложения есть пометка «обязательно» или «хотелось бы». Обязательное требование, которого веб не умеет, закрывает вопрос. Остальное — цена, которую надо назвать вслух, а не обнаружить в отзывах.

Соберём требования «Подхода», какими они сложились за часть. Прогноз: какая строка окажется решающей и что напечатает второй вызов, где этого требования нет?

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

Решает одна строка. Таймер отдыха — сердце «Подхода»: между подходами человек кладёт телефон в карман, и звонок обязан прийти, когда приложение закрыто. В браузере планировщика нет вовсе, мы это только что видели запуском, а веб-push — не замена: он приходит с сервера, значит, требует сети в зале, где её нет. Всё остальное веб-версия умеет или умеет с оговоркой.

Уберите таймер — и вердикт меняется на противоположный: приложению без напоминаний магазин не нужен, но остаётся цена. «Открываться с домашнего экрана» стоит в списке обязательных не случайно: приложение, которое каждый раз ищут в браузере, перестают открывать. А на iPhone поставить его может только сам пользователь, руками.

Тот же список удобно держать не в голове, а в виде таблицы канала. Слева — чего просит «Подход», справа — что за это придётся сделать в каждом канале.

Веб-версия (PWA)Сборка из магазина
Цена входадомен, HTTPS и хостинг; у автора это тот же VPS, что у сайтаApple Developer 99 долларов в год, Play Console 25 долларов разово, RuStore бесплатно — статья «Магазины»
Кто пускает к пользователюникто: выложили — доступнопроверка магазина; у нового личного аккаунта Google Play перед продакшном ещё и закрытый тест из 12 человек на 14 дней
Как доходит обновлениедеплой: следующий заход берёт новую сборку (за вычетом кеша воркера)новая сборка через магазин или бандл по воздуху с замком runtimeVersion — глава 11
Установка на экранChromium предлагает сам, Safari — только «Поделиться» → «На экран Домой»обычная установка из магазина
Напоминание при закрытом приложениинет: планировщика нет, push требует сервера и разрешенияесть: системный планировщик, статья «Уведомления: планировщик, канал, нажатие»
Работа в фонетолько Chromium и только по правилам браузерафоновые задачи системы
Данные на устройствеIndexedDB; SQLite — лишь на изолированной странице; хранилище могут выселитьфайл SQLite в песочнице приложения, живёт, пока приложение не удалили

Вопрос не «что лучше», а «есть ли в списке обязательного хоть одна строка, которой веб не умеет». Одной хватает.

Эти две способности в спорах называют первыми, и обе устроены сложнее, чем «есть или нет». Механизмы — в главе части II; здесь только то, что влияет на выбор канала.

Установка. В Chrome у неё свои критерии: HTTPS, манифест с нужными полями и вовлечённость — нажатие на странице и около тридцати секунд на ней. Только после этого браузер даёт то самое событие beforeinstallprompt, которым приложение показывает свою кнопку. Событие нестандартное: его нет ни в Firefox, ни в Safari. На iPhone и iPad ставят через «Поделиться» → «На экран Домой», и с iOS 26 так открывается как веб-приложение любой сайт, даже без манифеста. Firefox 143 умеет ставить сайты на панель задач, но только в Windows. Отсюда практическое: кнопку «Установить» показывают по событию, а всем остальным — короткую подсказку, и понять, что подсказка больше не нужна, помогает display-mode: standalone из наших проверок.

Заодно о ходовом заблуждении: start_url за пределами scope установку не ломает. По спецификации манифеста такой scope просто отбрасывается и берётся значение по умолчанию — от start_url; манифест остаётся годным, и Chrome его ставит. Иначе с start_url на чужом происхождении: спецификация отбрасывает и его, но к установке Chrome такой манифест уже не пустит. У автора оба поля — /app/, потому что веб-версия живёт на подпути.

Уведомления. Локального планировщика в вебе нет: мы видели UnavailabilityError запуском. Остаётся push, а это разговор трёх сторон — страница, служба доставки браузера и ваш сервер. Для «Подхода» существенны две оговорки. Первая: на iPhone и iPad push есть с iOS 16.4 и только у приложения, поставленного на экран; из вкладки Safari подписки нет вовсе. Declarative Web Push (iOS и iPadOS 18.4, Safari 18.5 на macOS) показывает уведомление даже без service worker, но на iPhone иконка на экране всё равно нужна. Вторая: push приходит с сервера, то есть требует сети — а «Подход» задумывался для зала, где её нет.

Любопытно, что expo-notifications в вебе всё же умеет одно — подписку. Её веб-файл требует двух полей конфига и сам регистрирует ваш воркер:

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

То есть из Expo-кода веб-push достижим, но опирается он целиком на браузерный механизм: ключ VAPID, ваш service worker, ваш сервер рассылки. Ничего общего с scheduleNotificationAsync, которым «Подход» ставит звонок таймера, у него нет.

«Push в PWA есть» и «напоминание придёт» — разные утверждения. Напоминание без сети умеет только системный планировщик, а он живёт в сборке.

Выбор канала — не развилка «или-или». У автора приложение живёт в обоих: сборка для телефона и веб-версия на /app, собранные из одного кода. Для «Подхода» после главы 7 это даже естественно: телефон и браузер — два устройства одного дневника, а сервер синхронизации делает их копиями друг друга. Разница только в роли. На телефоне — главная копия и вся работа в зале; в браузере — второе устройство: посмотреть историю с рабочего компьютера, поправить название упражнения, свести объём за месяц.

Держать два канала сразу стоит внимания в трёх местах.

Обновления. В вебе обновление — это деплой, и доезжает оно сразу; в магазине — сборка, проверка и, если правка только в JS, бандл по воздуху с замком runtimeVersion (глава 11). Общее у них одно: и там и там старый код может задержаться. В вебе его держит service worker, в телефоне — установленная сборка, которую пользователь не обновил. Поэтому сервер синхронизации обязан понимать операции старой формы — это правило мы записали ещё в главе 7.

Данные. Хранилище браузера — не файл приложения: его выселяют. WebKit стирает данные сайта, записанные скриптом, после семи дней пользования Safari без визитов к вам; приложение, поставленное на экран Домой, из этого правила исключено. Для local-first вывод простой: неотправленный журнал в браузере — риск, которого на телефоне нет. Поэтому веб-версия «Подхода» синхронизируется при каждом заходе и не должна становиться единственным местом, где живут записи.

Правила. У сайта нет ни проверки, ни анкет о данных, ни требования удалять аккаунт внутри приложения — всё это начинается в магазине, и об этом следующая статья. Но у сайта нет и витрины: его не найдут поиском по магазину, а доверие к «сайту, который просит поставить себя на экран» ниже, чем к карточке с отзывами.

Итог для «Подхода»: основной канал — магазин, потому что таймер отдыха обязан звонить при закрытом приложении, а дневник должен лежать в файле, который никто не выселит. Веб-версия остаётся — как второе устройство и как вход для тех, кто пробует приложение и ещё ничего не ставил. Значит, дальше нас ждут три кабинета разработчика, ключи и первая отправка — и лучше знать заранее, что именно мы покупаем за 99 долларов в год.

  • expo export --platform web отдаёт тот же код статическим сайтом: страница на маршрут, бандл с хешем, public/ байт в байт. Из PWA он не делает ничего — ни манифеста, ни service worker: в @expo/cli 57.0.18 этих слов нет вовсе. Манифест, воркер и его регистрация — ваши файлы.
  • Модули переезжают по-разному: expo-updates в вебе отвечает «обновлений нет» (там обновление — деплой), ExpoSecureStore.web.ts — пустой объект, а у expo-notifications нет планировщика: scheduleNotificationAsync бросает UnavailabilityError. Ни одно из трёх не чинится кодом.
  • Решение принимают по проверкам, а не по памяти: beforeinstallprompt и фоновая синхронизация — только Chromium, display-mode: standalone отвечает, поставлено ли приложение на экран, а SharedArrayBuffer без COOP и COEP от вашего хостинга не появится, и значит, веб-сборке expo-sqlite не открыться.
  • Черту проводит одно обязательное требование, которого веб не умеет. У «Подхода» это звонок таймера при закрытом приложении, поэтому основной канал — магазин. Остальные различия — цена: установка на iPhone только руками, хранилище браузера выселяется, витрины у сайта нет.
  • Два канала у одного кода — нормальное состояние: телефон как главная копия, браузер как второе устройство, синхронизация из главы 7 делает их одним дневником. Обновление в вебе — деплой, в магазине — сборка и бандл по воздуху с замком runtimeVersion.

1/4Что из перечисленного создаёт expo export --platform web само?

2/4В «Подходе» таймер отдыха должен звонить, когда приложение закрыто, а телефон лежит в кармане. Что это значит для выбора канала?

3/4Проверка в браузере пользователя вернула 'onbeforeinstallprompt' in window === false. Что из этого следует?

4/4Веб-версии «Подхода» хочется той же базы expo-sqlite, что и на телефоне. Что для этого нужно?

Цена, которую решатель прячет

Решатель из статьи честен наполовину: когда магазин обязателен, он называет блокирующие требования и молчит про оговорки. А оговорка — это работа, которую кто-то должен сделать: объяснить пользователю iPhone, как поставить приложение на экран, нужно и тогда, когда в магазин вы всё равно идёте. Требования «Подхода» лежат в подложке. Допишите decide так, чтобы цена называлась в обоих случаях.

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

Цена считается одинаково в обеих ветках, поэтому её достаточно собрать один раз и дописать к любому вердикту:

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

Теперь печатается магазин обязателен — звонок таймера отдыха при закрытом приложении · с оговорками: на iPhone ставит сам пользователь через «Поделиться». Разница не косметическая: в первом варианте оговорка исчезала из виду ровно в том случае, когда вы всё равно делаете обе сборки, — и подсказку «поставьте на экран» в веб-версии некому было запланировать.

Четыре заявки — назовите канал

Для каждой заявки скажите, что выбрать — веб-версию, магазин или оба канала, — и какое требование оказалось решающим.

№Заявка
1Внутренний инструмент для тренеров одного зала: таблица нагрузок, вход по ссылке, обновляется по несколько раз в неделю. Телефоны у всех разные, часть тренеров работает с ноутбука
2«Подход» хотят показывать на конференции: раздать ссылку, чтобы люди попробовали прямо со стенда, не устанавливая ничего
3Приложение для бассейна: секундомер должен пищать, когда экран заблокирован, а телефон лежит на бортике. Сети в зале нет
4Дневник питания с фотографиями блюд, который должен быть найден по запросу «дневник питания» в магазине и брать деньги за подписку

1. Веб-версия. Решает не список возможностей, а темп: обновление по несколько раз в неделю в магазине означает очередь на проверку каждый раз, а в вебе это деплой. Разные телефоны и ноутбуки — довод в ту же сторону. Уведомлений в требованиях нет, значит, ничего блокирующего тоже нет.

2. Веб-версия, причём рядом с магазинной сборкой: это второй канал у того же кода. «Попробовать, не устанавливая» — ровно то, чего сборка не умеет: путь до первого экрана у неё длиннее на магазин, вход в аккаунт и сотню мегабайт.

3. Магазин. Тот же решающий признак, что у таймера отдыха: звук при закрытом приложении и заблокированном экране без сети. Планировщика в вебе нет, push требует сервера. Уточнение про бассейн добавляет второе: непрерывный звук в фоне живёт на фоновых режимах системы (в статье «Что Expo делает за вас» это UIBackgroundModes), а их у сайта нет вовсе.

4. Магазин. Здесь решают не возможности, а витрина и деньги: поиск по магазину и подписка через магазинные платежи. Фотографии, к слову, ничего не решают — камера и выбор файла в браузере есть.