Service worker: посредник, который живёт без страницы

PWA≈ 13 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Доска задач из прошлой главы умеет всё, о чём мы договорились, — и разваливается, стоит спуститься в метро. Глава двенадцатая идёт после проекта и дописывает его: доска научится открываться без сети, вставать на домашний экран и обновляться, не ломаясь. Начинается это с посредника между страницей и сетью — service worker. Первый прогноз главы будет необычным: код примера в песочнице учебника не заработает, и это не поломка, а самый короткий способ понять, к чему воркер привязан.

Разделы темы:·

Service worker — это скрипт, который браузер запускает отдельно от страницы. У него нет ни document, ни доступа к вашим переменным, зато он стоит между страницей и сетью и видит её запросы раньше сервера. Поэтому им и открывают сайт без сети: на запрос отвечает не сервер, а посредник, у которого всё нужное уже лежит в кеше.

Регистрируют его одной строкой — navigator.serviceWorker.register(…). Прежде чем нажимать «Запустить», предскажите, что напечатает этот код здесь, в песочнице учебника. Ничего нового в нём нет: async-функция и try/catch вокруг await — из статьи «async/await» части I.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка — чистая правда: имя serviceWorker у navigator есть. А дальше отказ, и настоящий. Примеры учебника выполняются в <iframe> с атрибутом sandbox без флага allow-same-origin, и происхождение у такого документа уникальное, «непрозрачное»: ни домена, ни порта, ни своего места на диске. Service worker и Cache API привязаны к происхождению — привязывать нечего и не к чему.

Отказ выглядит одинаково, а устроен по-разному, и для кода это важно. В Chrome бросает уже сам геттер navigator.serviceWorker — синхронно, до всякого register. Значит, привычная форма navigator.serviceWorker.register(…).catch(…) ошибку не поймает: до .catch дело не дойдёт, выполнение оборвётся на обращении к свойству. В Firefox геттер отдаёт объект, а отказ приезжает отклонённым промисом — там .catch сработал бы. Форма, которая ловит оба случая, одна: try/catch вокруг await, как в примере.

Проверка вида 'serviceWorker' in navigator отвечает на вопрос «знает ли браузер такое имя», а не «разрешено ли им пользоваться здесь и сейчас». Разрешение выясняется только попыткой — и попытку надо обернуть в try/catch.

Отсюда устройство всей главы. Механику мы собираем миниатюрами в памяти: обычные функции, которые печатают в консоль, — их можно предсказать и запустить прямо на странице. А настоящий воркер, настоящий кеш и настоящий манифест запускаем по-честному, на своём сервере. Этим и займёмся прямо сейчас.

За две части мы ни разу не поднимали сервер: всё жило в песочнице. Дальше так не выйдет. Service worker работает только в безопасном контексте — это https:// или localhost, который браузеры считают безопасным по особому правилу, чтобы разработке не требовался сертификат.

Соблазнительный путь — сложить файлы доски в папку и открыть index.html двойным щелчком. Адрес получится file:///…, и вот что скажет на это браузер:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152, страница открыта по file://
Uncaught (in promise) TypeError: Failed to register a ServiceWorker: The URL protocol of the current origin ('null') is not supported.

Слово null в скобках — то самое непрозрачное происхождение, что и в песочнице: у файла на диске нет ни схемы с доменом, ни порта, значит, нет и области, к которой можно привязать воркера. Настройками это не лечится, нужен настоящий адрес.

Разложим доску по файлам. Разметка — из статьи «Проект: доска задач — разметка и план», состояние и сохранение — из «Сохранение и восстановление доски», а стили в board.css — любые: в проекте мы их не писали. Новый здесь только sw.js, и до конца статьи он останется почти пустым.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал
app.js
board.css
index.html
sw.js

Статический сервер на вашей машине уже есть — встроенный в Python. Запускаем его из папки с доской и окно не закрываем: он печатает каждый запрос, и этот журнал нам ещё пригодится.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · тот же терминал: команда не отдаёт приглашение обратно, а печатает запросы. Ниже — первое открытие http://localhost:8000/ в Chrome 152
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
::1 - - [12/Sep/2026 22:27:42] "GET / HTTP/1.1" 200 -
::1 - - [12/Sep/2026 22:27:42] "GET /board.css HTTP/1.1" 200 -
::1 - - [12/Sep/2026 22:27:42] "GET /app.js HTTP/1.1" 200 -
::1 - - [12/Sep/2026 22:27:42] code 404, message File not found
::1 - - [12/Sep/2026 22:27:42] "GET /favicon.ico HTTP/1.1" 404 -
::1 - - [12/Sep/2026 22:27:42] "GET /sw.js HTTP/1.1" 200 -

Что тут видно. Браузер попросил страницу, стили и код; favicon.ico он спрашивает сам, и 404 на него — обычное дело. А sw.js запрошен последним, уже после app.js: это наша регистрация, а не разбор разметки. ::1 в начале строк — адрес localhost по IPv6, так его записал Python.

Порт 8000 может быть занят — берите любой другой, хоть 8080. Важно одно: адрес начинается с http://localhost или http://127.0.0.1. А вот по адресу вашей машины в локальной сети (что-нибудь вроде 192.168.1.5) воркер уже не зарегистрируется: безопасным контекстом без сертификата считается только петля.

Слово worker здесь не метафора. Браузерный воркер — отдельный поток со своей глобальной областью, и document в ней просто нет. Ни дотянуться до страницы, ни поменять её он не может; единственный способ общения — сообщения. Проверим на обычном Worker: он в песочнице настоящий.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Здесь два урока сразу. Первый: typeof document внутри потока — undefined, и это не сбой, а устройство. Второй: строки напечатались не в том порядке, в каком написаны. Отправка в поток страницу не останавливает — ответ придёт позже, ровно как результат промиса из части I.

Service worker — воркер того же рода, но с двумя отличиями. Он переживает страницу: продолжает существовать, когда все вкладки сайта закрыты. И он не работает постоянно — браузер будит его событием, а когда дело сделано, гасит. Отсюда правило, о которое спотыкаются все: держать состояние в переменной воркера бессмысленно, между двумя событиями его может не быть вовсе.

СобытиеКогда браузер будит воркерЧто в нём обычно делают
installскрипт скачан и отличается от установленногоскладывают оболочку приложения в кеш
activateновая версия вступает в силуудаляют кеши прошлых версий
fetchстраница из области действия что-то запросилаотвечают из кеша или идут в сеть
messageстраница прислала postMessageпринимают команды вроде «включись немедленно»
pushпришло сообщение от службы доставкипоказывают уведомление

Воркер — не сервер, который «запущен». Это обработчик, которого зовут. Всё, что нужно помнить между вызовами, кладут в кеш или в IndexedDB, а не в переменную.

register принимает адрес скрипта, и этим адресом решено почти всё. Область действия (scope) по умолчанию — папка, в которой скрипт лежит. /sw.js накрывает весь сайт, /js/sw.js — только /js/ и то, что ниже. Поэтому воркер кладут в корень: из подпапки он не увидит ни главной страницы, ни запросов к API.

Совпадение адреса с областью — это совпадение префикса строки, буква в букву. Никаких «папок» при сравнении не разбирается, и на этом ловятся:

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Последняя строка — не выдумка ради примера. Мы зарегистрировали воркер с областью /app, без слэша на конце, и открыли три страницы подряд:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, три загрузки подряд на своём localhost
/app/ controller: http://127.0.0.1:3360/sw2.js
/application/ controller: http://127.0.0.1:3360/sw2.js
/other/ controller: null

Страница /application/ попала под управление воркера, которому её никто не отдавал: её адрес начинается с /app. Отсюда привычка всегда заканчивать область слэшем — /app/.

Вторым аргументом register область задают явно: register("/sw.js", { scope: "/app/" }) сузит её до раздела. А вот расширить нельзя — register("/js/sw.js", { scope: "/" }) отклонится. Разрешение даёт сервер: если он отдаёт скрипт с заголовком Service-Worker-Allowed: /, браузер примет и более широкую область.

Это защита, а не формальность. Чужой скрипт, залитый к вам в подпапку (файловый обменник, пользовательские загрузки), не должен получать право перехватывать запросы всего сайта. Заголовок ставит владелец сервера — значит, решение принимает он, а не автор скрипта.

В примерах вы не увидите ни new URL("/sw.js"), ни new Request("/api/tasks") — только полные адреса вида https://board.example/…. Причина в песочнице: базовый адрес её документа — about:srcdoc, и относительный адрес от такой базы не разрешается вовсе.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

На настоящей странице с обычным адресом всё это работает как всегда: в app.js нашей доски регистрация написана коротко — register("./sw.js").

У зарегистрированного воркера есть состояние, и меняется оно по строгой лестнице. installing — скрипт скачан, идёт обработчик install. installed — установка удалась, воркер готов, но ещё не у руля (в этом состоянии его называют ожидающим, waiting). activating — идёт обработчик activate. activated — воркер отвечает на запросы. И redundant — тупик: сюда попадает и сорвавшаяся установка, и старая версия, вытесненная новой.

Соберём эту лестницу миниатюрой. Она пригодится и дальше в главе, поэтому пусть будет отдельной функцией, а не парой переменных.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Таблица переходов здесь не ради красоты: она превращает «кажется, я что-то напутал» в явную ошибку. Обратной дороги из activated нет — новая версия скрипта не оживляет старого воркера, а ставится отдельным воркером и проходит лестницу с начала. Как они сменяют друг друга, разберём в статье про обновление воркера.

Теперь самое неочевидное в установке — waitUntil. Обработчик install сам по себе асинхронным быть не умеет: браузеру нужно знать, когда установка кончилась, и узнаёт он это из промиса, переданного в event.waitUntil(…). Пока промис не выполнен, воркер остаётся в installing. Если промис отклонится — установка сорвана, и воркер уходит в redundant.

Классический способ занять установку делом — cache.addAll со списком файлов оболочки. И классическая беда: один неверный адрес в списке роняет всю установку. Автомат из прошлого примера уже лежит на странице; добавим к нему кеш и установку.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй кеш остался пустым — и это не упрощение модели. Настоящий addAll ведёт себя так же: сначала забирает все ответы и только потом кладёт их в кеш. Вот проверка на живом браузере, где вторым адресом в списке стоит несуществующий файл:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 на http://localhost:8000/
addAll отказал: TypeError
в кеше осталось записей: 0

Ноль записей: index.html, который скачался успешно, в кеш тоже не попал. Обратите внимание и на имя ошибки — TypeError, а не что-нибудь про сеть: для addAll ответ 404 не «плохие данные», а невыполнимое требование.

Половина оболочки в кеше хуже, чем ничего: страница откроется без сети и развалится на первом же недостающем файле. Поэтому «всё или ничего» — правильное поведение, а не придирка.

Осталось главное недоразумение первой встречи. Страница вызвала register, воркер установился и активировался — и этой страницей он не управляет. Проверим на настоящей доске. Вот её sw.js, пока без всяких стратегий кеша:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

А вот app.js, который его регистрирует и рассказывает о каждом шаге. Открываем http://localhost:8000/ в чистом профиле браузера:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, первое открытие http://localhost:8000/ в чистом профиле
controller при загрузке: null
область действия: http://localhost:8000/
состояние сразу после register: installing
statechange: installed
statechange: activating
statechange: activated
воркер активен, а controller страницы: null

Прочитайте последние две строки ещё раз. Воркер прошёл всю лестницу до activated — а controller у страницы по-прежнему null. Ни одна её картинка, ни один её запрос через воркера не пойдёт.

Причина простая и, если подумать, правильная: браузер не подсовывает посредника посреди жизни документа. Страница уже загрузилась без воркера, часть её запросов ушла в сеть напрямую — и было бы странно, если бы остальные вдруг поехали другой дорогой. Управление достаётся следующей загрузке.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · та же консоль, вторая загрузка http://localhost:8000/
controller при загрузке: http://localhost:8000/sw.js

В той же второй загрузке register сразу отдал воркера в состоянии activated: устанавливать было нечего, регистрация уже существует. Повторный register с тем же адресом — не ошибка и не вторая копия, а способ получить ссылку на то, что уже есть.

Обойти ожидание можно: self.clients.claim() в обработчике activate забирает под управление уже открытые страницы из области действия — в том числе ту, которая его и зарегистрировала. Страницам, открытым позже, он для этого не нужен: к их загрузке регистрация уже активна, и управление они получают сами — так и вышло в опыте с областью /app. Но в настоящем приложении у claim есть цена: документ, начавший жизнь без воркера, вдруг оказывается под ним, и половина его запросов прошла мимо кеша, а половина — через. На первой установке это обычно безобидно, а при обновлении версии — источник самых неприятных ошибок главы; им и посвящена статья «Обновление воркера: старая версия держит вкладки».

controller — это не «воркер активен», а «этот документ ходит в сеть через воркера». Два разных вопроса, и путают их постоянно.

Автомат жизненного цикла понадобится снова в статье про обновление воркера — там он придёт готовым, собирать его заново не придётся. А следующая статья даст доске маршрутизатор запросов: три стратегии кеша и правило, какую из них применять к какому запросу.

  • Service worker — отдельный поток без document: он переживает страницу, просыпается по событиям install, activate, fetch, message и push, а общается только сообщениями. Состояние между вызовами держат в кеше, а не в переменной.
  • Работает он лишь в безопасном контексте — https или localhost. По file:// регистрация отказывает с TypeError, а в песочнице учебника — с SecurityError: у обоих происхождение непрозрачное, привязывать воркера не к чему.
  • Область действия задаётся адресом скрипта: по умолчанию это его папка. Совпадение с областью — совпадение префикса строки, поэтому /app накрывает и /application, а область принято заканчивать слэшем. Расширить её может только заголовок Service-Worker-Allowed.
  • Жизненный цикл — лестница installing → installed → activating → activated с тупиком redundant. Установку держит промис из waitUntil: его отказ (например, один 404 внутри addAll) уводит воркера в redundant, и в кеше не остаётся ничего.
  • Страница, зарегистрировавшая воркера, им не управляется: controller появляется только со следующей загрузки — или сразу, если воркер вызвал self.clients.claim() в activate.

1/4В консоли 'serviceWorker' in navigator вернуло true. Значит ли это, что register сработает?

2/4Страница зарегистрировала воркер, и он дошёл до состояния activated. Кто управляет этой страницей?

3/4Скрипт воркера лежит по адресу /js/sw.js. Какие запросы он увидит по умолчанию?

4/4В обработчике install вызвали event.waitUntil(cache.addAll(files)), и один из пяти адресов отвечает 404. Что будет?

Сорванная установка

Автомат жизненного цикла уже на странице — им можно пользоваться. А вот install написан беспечно: он ждёт промисы из waitUntil, но их отказ проглатывает и всё равно объявляет установку удавшейся. Сейчас обе регистрации доходят до installed; вторая обязана уйти в redundant.

Исправьте install так, чтобы состояние зависело от исхода промисов. Переход в installed должен случаться только при успехе — печатать что-либо дополнительно не нужно, автомат делает это сам.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибка здесь не в try/catch, а в том, что переход стоит после него — то есть выполняется в обоих случаях. Успешную ветку надо занести внутрь try, а в catch поставить тупик.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь вывод кончается строкой v2: redundant. Обратите внимание на Promise.all: обработчик install может позвать waitUntil несколько раз, и установка считается законченной, когда выполнены все переданные промисы; отказ любого из них рушит установку целиком — ровно как Promise.all из части I.

Кто из двух воркеров поймает запрос

На сайте зарегистрированы два воркера с разными областями. Когда под адрес подходят обе области, браузер отдаёт запрос воркеру с самой длинной подходящей областью — самой точной.

Напишите controllerFor(url): она возвращает имя скрипта подходящего воркера или null, если не подошёл ни один. Сравнивать адреса нужно так же, как это делает браузер, — префиксом полного адреса, включая схему и домен. Сейчас функция на все три вопроса отвечает null.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Три шага: привести адрес к полному виду, отобрать все подходящие области, выбрать из них самую длинную.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Ответы: app-sw.js, sw.js и null. Третий адрес отсекается сам собой, без отдельной проверки домена: сравнивается полный адрес, а https://other.example/… не начинается с https://board.example/. Это и есть причина, по которой воркер не может перехватывать чужие сайты.