У service worker есть жизненный цикл, и почти вся боль с обновлениями PWA - от непонимания, что новый worker не заменяет старый мгновенно. Он проходит состояния install, waiting, activate и, наконец, контроль над страницей. На install worker наполняет precache. В waiting он ждёт. На activate приводит систему в порядок - чистит устаревшие кеши. И только потом начинает контролировать загрузки. Пропустить переход нельзя - получите гонки.
Регистрация worker - не разовый вызов, а долгоживущая связь пары origin и scope с конкретным скриптом. Зарегистрировав worker на /sw.js со scope /, вы создаёте запись, переживающую закрытие вкладки и перезапуск браузера. При следующем заходе браузер не регистрирует worker заново - он проверяет, не изменился ли скрипт.
Проверяет так: сравнивает файл worker и его зависимости с установленной версией. Совпадает - ничего не происходит. Отличается хоть на байт - браузер считает это новой версией и устанавливает её параллельно. Ключевой момент: собрав новую версию, браузер не активирует её сразу, а держит в waiting - пока все вкладки под управлением старого worker не закроются.
Удержание выглядит как назойливость - обновление ведь готово, - но причина веская. Открытые вкладки уже загрузили ассеты старой версии - скрипты и стили с хешами. Активируйся новый worker мгновенно, на странице смешались бы несовместимые версии: старый JavaScript запросил бы уже отсутствующий чанк, и приложение упало бы. Waiting защищает открытые сессии от такого смешивания. Плата - обновление доезжает не сразу; управлять этим будем позже.
Обновлением управляют, различая три сущности, которые путают новички. Страница - вкладка с вашим кодом. Registration - запись о worker для scope, с полями installing, waiting, active. Controller - active worker, который сейчас перехватывает запросы страницы. Тонкость: navigator.serviceWorker.ready резолвится, когда появился active worker, - но navigator.serviceWorker.controller при первом открытии может быть null. Поэтому первая загрузка идёт по сети; контроль появляется после перезагрузки или явного clients.claim.
Страница слушает появление новой версии и решает, что делать. Событие updatefound срабатывает, когда началась установка нового worker; за ней следят через statechange. Момент state === 'installed' при непустом controller означает именно обновление, а не первую установку. А controllerchange ловит смену контролирующего worker; на него вешают один аккуратный перезагруз на новую версию.
Worker и страница живут в разных потоках и общаются только сообщениями. Базовый механизм - postMessage; для запроса с ответом удобен MessageChannel с парой портов. Когда данные нужно разослать сразу во все вкладки, помогает BroadcastChannel - общий канал, в который пишет один, а слышат все. В Offline Notes это нужно, когда синхронизация в одной вкладке подтвердила отправку, а остальные меняют статус заметки с ожидает на синхронизировано.
Последнее правило - дисциплина scope. Не регистрируйте worker с перекрывающимися областями без причины. Запрос обслуживает наиболее специфичный scope, и при перекрытии поведение перестаёт читаться. Типичная авария: остался старый worker на /app/sw.js, добавили новый на /sw.js - часть страниц молча обслуживается старым кодом, а отладка - головоломка: в DevTools два worker, и какой ответил, неочевидно. Один origin - один worker в корне: скучно, предсказуемо и отлаживается.
const reg = await navigator.serviceWorker.register('/sw.js');
reg.addEventListener('updatefound', () => {
const worker = reg.installing;
worker?.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateAvailable();
}
});
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Один контролируемый reload после осознанного обновления.
location.reload();
});