App shell - минимальный каркас интерфейса, который должен появляться мгновенно и работать без сети. Runtime-кэширование, которое наполняется по мере того как пользователь ходит по приложению, оставляет самый первый офлайн-визит пустым: класть в кэш ещё нечего. Ключевое наблюдение в том, что build знает точный набор файлов app shell лучше, чем runtime, - на этапе сборки список известен целиком и без догадок.
Precache - это список файлов, которые service worker скачивает и сохраняет при install и версионирует вместе с собой. При установке worker загружает их все разом; при обновлении - пересинхронизирует изменившиеся. Именно поэтому каркас доступен уже на самой первой офлайн-загрузке, а не после того как пользователь случайно открыл нужный экран онлайн.
URL с content hash - например app.4f3a2b.js - опознаёт содержимое сам: меняется файл, меняется и URL. Для адресов без хэша (index.html, /manifest.webmanifest) нужна строка revision, чтобы precache понял, что содержимое изменилось. Список нельзя составлять руками: интеграция build tool автоматически включает все чанки и не пропустит забытый. Плагин подставляет манифест в переменную __WB_MANIFEST, а precacheAndRoute кладёт его в кэш и раздаёт.
У Workbox два режима. generateSW генерирует worker целиком - проще и подходит стандартному приложению, которому нужно только кэширование. injectManifest выбирают, когда нужен собственный worker: IndexedDB и outbox, push, сложная маршрутизация, обмен сообщениями со страницей, кастомная телеметрия. Нашему приложению заметок нужен outbox, поэтому здесь только injectManifest - Workbox лишь подставляет манифест в написанный вами код.
Отдельная задача - навигация. Navigation request - это запрос браузера на HTML-документ верхнего уровня, то есть переход по адресу. Офлайн такой запрос падает, и хочется отдать заготовленный каркас. Но офлайн-shell не должен маскировать настоящий 404 и не должен превращать любую ссылку в SPA - иначе пользователь перестанет отличать реально отсутствующую страницу от временно недоступной.
Для SPA navigation fallback часто возвращает precached index.html, после чего клиентский router по URL восстанавливает нужный экран. Для SSR или MPA лучше Network First с кэшированной страницей или отдельной offline.html. Из fallback обязательно исключают API, admin, auth callback и файлы - им нечего делать в роли HTML-каркаса. Обработчик пробует сеть, а на ошибке спускается к кэшу и затем к офлайн-странице.
navigationPreload запускает сетевой запрос параллельно со стартом service worker, чтобы не платить за загрузку worker'а прежде, чем начнётся сам запрос; event.preloadResponse - это тот самый ответ, уже находящийся в полёте. Пока сеть есть, возвращается он или обычный fetch. Как только сеть пропала, catch спускается к caches.match и, в последнюю очередь, к /offline.html.
Офлайн-страница должна быть по-настоящему автономной: критический CSS - inline или в precached-зависимостях, понятное объяснение, список доступных офлайн-разделов и кнопка повторить. Нельзя показывать фальшивый серверный контент, который притворяется, будто данные загрузились. Для приложения заметок offline.html перечисляет заметки, уже лежащие в локальном кэше, и даёт в них перейти - это честный офлайн, а не имитация онлайна.
// sw.ts с Workbox injectManifest
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
declare let self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision?: string }>
};
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();async function handleNavigation(event) {
try {
const preload = await event.preloadResponse;
if (preload) return preload;
return await fetch(event.request);
} catch {
return (await caches.match(event.request)) ??
(await caches.match('/offline.html'));
}
}
self.addEventListener('activate', event => {
event.waitUntil(self.registration.navigationPreload?.enable());
});