Service worker: посредник, который живёт без страницы
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска задач из прошлой главы умеет всё, о чём мы договорились, — и разваливается, стоит спуститься в метро. Глава двенадцатая идёт после проекта и дописывает его: доска научится открываться без сети, вставать на домашний экран и обновляться, не ломаясь. Начинается это с посредника между страницей и сетью — service worker. Первый прогноз главы будет необычным: код примера в песочнице учебника не заработает, и это не поломка, а самый короткий способ понять, к чему воркер привязан.
Service worker — это скрипт, который браузер запускает отдельно от страницы. У него нет ни document, ни доступа к вашим переменным, зато он стоит между страницей и сетью и видит её запросы раньше сервера. Поэтому им и открывают сайт без сети: на запрос отвечает не сервер, а посредник, у которого всё нужное уже лежит в кеше.
Регистрируют его одной строкой — navigator.serviceWorker.register(…). Прежде чем нажимать «Запустить», предскажите, что напечатает этот код здесь, в песочнице учебника. Ничего нового в нём нет: async-функция и try/catch вокруг await — из статьи «async/await» части I.
Первая строка — чистая правда: имя 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:///…, и вот что скажет на это браузер:
Слово null в скобках — то самое непрозрачное происхождение, что и в песочнице: у файла на диске нет ни схемы с доменом, ни порта, значит, нет и области, к которой можно привязать воркера. Настройками это не лечится, нужен настоящий адрес.
Разложим доску по файлам. Разметка — из статьи «Проект: доска задач — разметка и план», состояние и сохранение — из «Сохранение и восстановление доски», а стили в board.css — любые: в проекте мы их не писали. Новый здесь только sw.js, и до конца статьи он останется почти пустым.
Статический сервер на вашей машине уже есть — встроенный в Python. Запускаем его из папки с доской и окно не закрываем: он печатает каждый запрос, и этот журнал нам ещё пригодится.
Что тут видно. Браузер попросил страницу, стили и код; 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: он в песочнице настоящий.
Здесь два урока сразу. Первый: typeof document внутри потока — undefined, и это не сбой, а устройство. Второй: строки напечатались не в том порядке, в каком написаны. Отправка в поток страницу не останавливает — ответ придёт позже, ровно как результат промиса из части I.
Service worker — воркер того же рода, но с двумя отличиями. Он переживает страницу: продолжает существовать, когда все вкладки сайта закрыты. И он не работает постоянно — браузер будит его событием, а когда дело сделано, гасит. Отсюда правило, о которое спотыкаются все: держать состояние в переменной воркера бессмысленно, между двумя событиями его может не быть вовсе.
| Событие | Когда браузер будит воркер | Что в нём обычно делают |
|---|---|---|
install | скрипт скачан и отличается от установленного | складывают оболочку приложения в кеш |
activate | новая версия вступает в силу | удаляют кеши прошлых версий |
fetch | страница из области действия что-то запросила | отвечают из кеша или идут в сеть |
message | страница прислала postMessage | принимают команды вроде «включись немедленно» |
push | пришло сообщение от службы доставки | показывают уведомление |
Воркер — не сервер, который «запущен». Это обработчик, которого зовут. Всё, что нужно помнить между вызовами, кладут в кеш или в IndexedDB, а не в переменную.
register принимает адрес скрипта, и этим адресом решено почти всё. Область действия (scope) по умолчанию — папка, в которой скрипт лежит. /sw.js накрывает весь сайт, /js/sw.js — только /js/ и то, что ниже. Поэтому воркер кладут в корень: из подпапки он не увидит ни главной страницы, ни запросов к API.
Совпадение адреса с областью — это совпадение префикса строки, буква в букву. Никаких «папок» при сравнении не разбирается, и на этом ловятся:
Последняя строка — не выдумка ради примера. Мы зарегистрировали воркер с областью /app, без слэша на конце, и открыли три страницы подряд:
Страница /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, и относительный адрес от такой базы не разрешается вовсе.
На настоящей странице с обычным адресом всё это работает как всегда: в app.js нашей доски регистрация написана коротко — register("./sw.js").
У зарегистрированного воркера есть состояние, и меняется оно по строгой лестнице. installing — скрипт скачан, идёт обработчик install. installed — установка удалась, воркер готов, но ещё не у руля (в этом состоянии его называют ожидающим, waiting). activating — идёт обработчик activate. activated — воркер отвечает на запросы. И redundant — тупик: сюда попадает и сорвавшаяся установка, и старая версия, вытесненная новой.
Соберём эту лестницу миниатюрой. Она пригодится и дальше в главе, поэтому пусть будет отдельной функцией, а не парой переменных.
Таблица переходов здесь не ради красоты: она превращает «кажется, я что-то напутал» в явную ошибку. Обратной дороги из activated нет — новая версия скрипта не оживляет старого воркера, а ставится отдельным воркером и проходит лестницу с начала. Как они сменяют друг друга, разберём в статье про обновление воркера.
Теперь самое неочевидное в установке — waitUntil. Обработчик install сам по себе асинхронным быть не умеет: браузеру нужно знать, когда установка кончилась, и узнаёт он это из промиса, переданного в event.waitUntil(…). Пока промис не выполнен, воркер остаётся в installing. Если промис отклонится — установка сорвана, и воркер уходит в redundant.
Классический способ занять установку делом — cache.addAll со списком файлов оболочки. И классическая беда: один неверный адрес в списке роняет всю установку. Автомат из прошлого примера уже лежит на странице; добавим к нему кеш и установку.
Второй кеш остался пустым — и это не упрощение модели. Настоящий addAll ведёт себя так же: сначала забирает все ответы и только потом кладёт их в кеш. Вот проверка на живом браузере, где вторым адресом в списке стоит несуществующий файл:
Ноль записей: index.html, который скачался успешно, в кеш тоже не попал. Обратите внимание и на имя ошибки — TypeError, а не что-нибудь про сеть: для addAll ответ 404 не «плохие данные», а невыполнимое требование.
Половина оболочки в кеше хуже, чем ничего: страница откроется без сети и развалится на первом же недостающем файле. Поэтому «всё или ничего» — правильное поведение, а не придирка.
Осталось главное недоразумение первой встречи. Страница вызвала register, воркер установился и активировался — и этой страницей он не управляет. Проверим на настоящей доске. Вот её sw.js, пока без всяких стратегий кеша:
А вот app.js, который его регистрирует и рассказывает о каждом шаге. Открываем http://localhost:8000/ в чистом профиле браузера:
Прочитайте последние две строки ещё раз. Воркер прошёл всю лестницу до activated — а controller у страницы по-прежнему null. Ни одна её картинка, ни один её запрос через воркера не пойдёт.
Причина простая и, если подумать, правильная: браузер не подсовывает посредника посреди жизни документа. Страница уже загрузилась без воркера, часть её запросов ушла в сеть напрямую — и было бы странно, если бы остальные вдруг поехали другой дорогой. Управление достаётся следующей загрузке.
В той же второй загрузке 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 должен случаться только при успехе — печатать что-либо дополнительно не нужно, автомат делает это сам.
Ошибка здесь не в try/catch, а в том, что переход стоит после него — то есть выполняется в обоих случаях. Успешную ветку надо занести внутрь try, а в catch поставить тупик.
Теперь вывод кончается строкой v2: redundant. Обратите внимание на Promise.all: обработчик install может позвать waitUntil несколько раз, и установка считается законченной, когда выполнены все переданные промисы; отказ любого из них рушит установку целиком — ровно как Promise.all из части I.
Кто из двух воркеров поймает запрос
На сайте зарегистрированы два воркера с разными областями. Когда под адрес подходят обе области, браузер отдаёт запрос воркеру с самой длинной подходящей областью — самой точной.
Напишите controllerFor(url): она возвращает имя скрипта подходящего воркера или null, если не подошёл ни один. Сравнивать адреса нужно так же, как это делает браузер, — префиксом полного адреса, включая схему и домен. Сейчас функция на все три вопроса отвечает null.
Три шага: привести адрес к полному виду, отобрать все подходящие области, выбрать из них самую длинную.
Ответы: app-sw.js, sw.js и null. Третий адрес отсекается сам собой, без отдельной проверки домена: сравнивается полный адрес, а https://other.example/… не начинается с https://board.example/. Это и есть причина, по которой воркер не может перехватывать чужие сайты.