Приложение заказов для курьера обязано открыться в подвале склада, где сети нет. Значит его каркас - HTML, CSS, точка входа, экран offline - должен лежать локально ещё до разрыва связи. За укладку каркаса и его замену отвечают два ранних события service worker: install и activate. Ошибка в любом оставит пользователя со сломанной оболочкой или смесью старых и новых файлов - поэтому оба работают атомарно.
Обычный обработчик завершается, как только из него выходит синхронный код. Но установка асинхронна: скачать файлы и разложить по кешу мгновенно нельзя. Метод event.waitUntil() решает это - он принимает Promise и продлевает жизнь события до его разрешения. Пока Promise не завершится, install считается незаконченным, а worker - не установленным. Установка становится атомарной по жизненному циклу: она либо целиком удалась, либо целиком провалилась.
Внутри install удобно открыть именованный кеш и одним cache.addAll() сложить весь обязательный каркас. У addAll() важное свойство: он атомарен. Если хотя бы один URL не скачался, весь вызов отклоняется, install проваливается, и новый worker не активируется - остаётся прежний, рабочий. Для app shell это правильно: лучше старая, но целостная оболочка, чем наполовину обновлённая. Правило: в обязательный precache кладут только то, что вы контролируете. Нестабильный сторонний скрипт будет ронять каждую установку из-за чужого сбоя.
Событие activate наступает, когда новый worker готов взять управление, и это естественное место для уборки. Между версиями меняются имена кешей - static-v41 сменяется static-v42 - и старые контейнеры надо удалить, иначе хранилище будет расти без предела. Удалять следует аккуратно: только те кеши, которыми владеет эта регистрация. Список разрешённых имён держат явно, а всё остальное стирают. Слепое caches.delete() по всем ключам опасно, если на том же origin живёт другое приложение.
Когда каркас на месте, вступает третье событие - fetch. Оно превращает worker в программируемый прокси: браузер спрашивает worker про каждый сетевой запрос страницы, а тот решает, ответить из кеша, сходить в сеть или собрать ответ сам. Ключевое ограничение - event.respondWith() нужно вызвать синхронно, прямо в обработчике. Сам ответ может прийти позже: respondWith() принимает Promise. Но если выйти, не вызвав respondWith(), браузер обработает запрос сам, обычной сетью.
Отсюда стиль маршрутизации: worker не должен перехватывать всё подряд. Разумно как можно раньше отсеять то, что его не касается - чужой origin, не-GET методы - и просто выйти, отдав такие запросы браузеру. Остальное разводят по стратегиям, опираясь на признаки запроса: метод, origin, путь, request.mode (навигация или подгрузка) и request.destination (документ, изображение, скрипт). Навигацию обслуживают одним способом, картинки - другим, API - третьим.
Отдельная ловушка - ответы с других origin в режиме no-cors. Такой ответ называется opaque: его статус равен нулю, тело и заголовки прочитать нельзя. Для worker это чёрный ящик - нельзя даже проверить, успешен ли запрос: response.ok всегда ложь. Опаснее то, что opaque-ответы занимают в хранилище не реальный, а завышенный, дополненный до крупной границы размер. Приложение, которое без разбора кеширует сторонние иконки, легко упрётся в квоту и получит отказы в записи там, где хранит заказы. Поэтому cross-origin ответы кешируют осознанно, с лимитом на число и возраст.
const STATIC = 'static-v42';
const PRECACHE = ['/', '/offline.html', '/assets/app.83fd1.js'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(STATIC).then(cache => cache.addAll(PRECACHE))
);
});
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const allowed = new Set([STATIC, 'images-v3', 'api-v8']);
for (const name of await caches.keys()) {
if (!allowed.has(name)) await caches.delete(name);
}
})());
});self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return;
if (url.origin !== self.location.origin) return;
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(event));
} else if (request.destination === 'image') {
event.respondWith(cacheFirst(request, 'images-v3'));
}
});