Как только приложение работает без сети, встаёт вопрос: где физически лежат данные. Данные разной природы требуют разных хранилищ и правил. HTTP-ответы, доменные записи и ещё не подтверждённые сервером команды - три разные сущности, и смешивать их в одном контейнере значит закладывать баги. Полезно сразу развести клиентское состояние по трём слоям.
Первый слой - Cache Storage: программируемое хранилище пар запрос-ответ (Request и Response), которым управляет service worker. Сюда кладут app shell - статический каркас: HTML, скрипты, стили, шрифты, - картинки и, при желании, ответы на GET к API. Cache Storage мыслит в терминах HTTP: ключ - запрос, значение - готовый ответ. Типичная ошибка - принимать его за базу доменных объектов: он про возврат того же ответа, что и сеть, а не про сущности и связи.
Второй слой - IndexedDB: встроенная в браузер транзакционная база с объектными хранилищами и индексами. Здесь живут настоящие данные приложения: сущности, индексы, blob-объекты и outbox - очередь исходящих команд. IndexedDB - durable-хранилище, но с ценой: у него есть схемы и версии; критичные данные без миграций хранить нельзя - обновление схемы однажды сломает доступ к уже созданному.
Третий слой - состояние в памяти: черновик в поле ввода, выделения, временный оптимистичный статус до подтверждения. Самый быстрый и самый ненадёжный: исчезает при перезагрузке вкладки. Ошибка - принимать его за долговременное хранилище. Оптимистичное обновление в памяти - UX-приём, а не гарантия сохранности. Пока запись не легла в IndexedDB, считайте, что её нет.
| Слой | Что хранит | Типичная ошибка |
|---|---|---|
| Cache Storage | Пары Request/Response, app shell, изображения, GET | Считать его базой доменных объектов |
| IndexedDB | Структурированные сущности, индексы, blobs, outbox | Хранить критичные данные без миграций |
| Memory/UI state | Текущий draft, selections, transient optimistic state | Принимать его за durable storage |
Приложим это к Offline Notes. Заметки, очередь outbox и запись meta (курсор последней синхронизации) лежат в IndexedDB: доменные данные, обязанные пережить перезапуск. App shell идёт в precache Cache Storage при установке worker. GET-ответы кешируем, только если это полезный слой. Отдельное правило: команды на изменение никогда не кешируются - новая заметка получает client-generated ID (создаётся на клиенте до отправки) и durable-запись в outbox.
Теперь фундамент, без которого ничего не запустится. Service Worker видит и может подменять сетевые запросы страницы, поэтому браузер отдаёт его только в secure context - по HTTPS. Единственное послабление - localhost: он доверенный - разработку не вести через туннели. На проде без валидного TLS worker просто не зарегистрируется - это не баг, а защита от атак, где посредник подменяет worker и перехватывает трафик.
Второй момент - scope, область контроля worker. По умолчанию он контролирует только путь, где лежит его скрипт, и всё, что ниже. Worker по адресу /assets/sw.js не управляет страницами в /app/. Отсюда правило: размещайте sw.js настолько высоко, насколько широким должен быть scope; чтобы контролировать весь сайт, кладите его в корень - /sw.js. Заголовок Service-Worker-Allowed расширяет scope за пределы папки скрипта, но если он понадобился, чаще это признак неудобной структуры деплоя.
Собранный воедино минимальный каркас прост. В HTML подключается манифест и тема, а скрипт регистрирует worker в корневом scope. Регистрацию вешают на событие load, чтобы не мешать первичной загрузке, и проверяют поддержку через 'serviceWorker' in navigator. Флаг updateViaCache: 'none' велит не брать файл sw.js из HTTP-кеша при проверке обновлений - иначе рискуете месяцами раздавать устаревший worker. Этого достаточно, чтобы worker получил контроль над страницей.
<link rel="manifest" href="/app.webmanifest">
<meta name="theme-color" content="#087f56">
<script type="module">
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none'
});
console.log('SW scope:', registration.scope);
});
}
</script>