Самый дешёвый способ превратить веб в мобильное приложение — не упаковывать его в native-обёртку, а сделать сам веб устанавливаемым. Progressive Web App — это обычный сайт, который браузер умеет установить на домашний экран, запустить без адресной строки и обслужить офлайн. Никакого магазина приложений, никакого ревью, та же кодовая база React + TypeScript. Для продукт-инженера это нижняя точка отсчёта мобильной стратегии: прежде чем тянуть Capacitor и native API, стоит понять, что веб умеет сам.

Что такое PWA

PWA — это веб-приложение, которое можно установить и которое работает офлайн. Не отдельный фреймворк и не формат сборки, а набор веб-возможностей поверх обычного сайта: декларация приложения (manifest), скрипт-прокси между страницей и сетью (service worker) и кеш ресурсов (Cache API). Сложенные вместе, они дают браузеру право предложить установку, а приложению — жить без сети. «Progressive» означает постепенность: на старом браузере это просто сайт, на современном — устанавливаемое приложение с офлайном и push.

Web app manifest

Manifest — это JSON-файл, который описывает приложение операционной системе: как назвать иконку, в каком окне открыть, с какого адреса стартовать. Подключается через <link rel="manifest" href="/manifest.webmanifest">.

{
  "name": "Складской учёт",
  "short_name": "Склад",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#2F5B4F",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

name и short_name — полное и короткое имя (второе показывается под иконкой). start_url — адрес запуска (формально опционален: при отсутствии браузер берёт URL страницы, к которой подключён manifest, но для предсказуемой установки задавайте явно). display: standalone убирает адресную строку и кнопки браузера — приложение выглядит как native. theme_color красит системные элементы вокруг окна. icons — массив, где у каждого объекта обязателен src, а также sizes и type; 192 и 512 px — минимальный практичный набор.

Service worker и офлайн

Service worker — это скрипт, который браузер запускает отдельно от страницы и который перехватывает её сетевые запросы. Он работает как прокси: страница просит ресурс, service worker решает, отдать его из кеша или сходить в сеть. Это и есть механизм офлайна. У него три ключевых события жизненного цикла: install (срабатывает один раз после регистрации — момент наполнить кеш), activate (старые версии закрыты, можно подчистить устаревший кеш) и fetch (на каждый сетевой запрос страницы).

const CACHE = "app-v1";
const ASSETS = ["/", "/index.html", "/app.js", "/styles.css"];

self.addEventListener("install", (event: ExtendableEvent) => {
  event.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)));
});

self.addEventListener("activate", (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
});

self.addEventListener("fetch", (event: FetchEvent) => {
  event.respondWith(
    caches.match(event.request).then((hit) => hit ?? fetch(event.request))
  );
});

Cache API (caches.open, addAll, match) — это хранилище ответов, ключом к которому служит запрос. Регистрируют worker со страницы один раз:

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js");
}

Стратегия выше — cache-first: сперва кеш, потом сеть. Что и как хранить долговременно — данные, а не статику — разбирается в статье про офлайн и хранилища.

Установка на домашний экран

Чтобы браузер предложил установку, приложение должно отвечать критериям установимости: подключённый manifest, зарегистрированный service worker и отдача по HTTPS (для разработки годятся localhost и 127.0.0.1). При выполненных условиях Chromium-браузеры стреляют событием beforeinstallprompt — его можно перехватить, чтобы показать свою кнопку вместо стандартной подсказки.

let deferred: BeforeInstallPromptEvent | null = null;

window.addEventListener("beforeinstallprompt", (e) => {
  e.preventDefault();
  deferred = e as BeforeInstallPromptEvent;
  showInstallButton();
});

async function install() {
  if (!deferred) return;
  await deferred.prompt();
  deferred = null;
}

После установки приложение получает свою иконку в лаунчере и запускается в окне без браузерного обвеса — по display из manifest.

Ограничения на iOS

iOS — главная причина, по которой PWA не всегда достаточно. Safari не показывает beforeinstallprompt: установка только вручную через «Поделиться → На экран „Домой"». Web Push на iOS появился лишь в 16.4 и работает только для PWA, добавленных на домашний экран, — в самом Safari push недоступен. Дальше — лимиты выполнения и хранилища: нет фонового выполнения (worker засыпает, когда приложение свёрнуто), часть Web API недоступна (Web Bluetooth, Web NFC и ряд интеграционных), а у script-writable хранилища есть квоты и политика вытеснения. Когда этих ограничений становится слишком много, веб упаковывают в native-обёртку — Capacitor даёт настоящие native push (см. push-уведомления) и доступ к платформенным API, которых PWA лишён.

Где это в UCP

PWA — это первый рубеж мобильной стратегии продукт-инженера: те же компоненты и данные frontend, но устанавливаемые и работающие офлайн, без затрат на native-сборку и магазины. Граница его возможностей — это и есть критерий решения: пока хватает manifest, service worker и Cache API, продукт-инженер остаётся на вебе; как только нужны фоновое выполнение или native push, наступает черёд Capacitor с его доступом к платформе.