Обновление воркера: старая версия держит вкладки
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска открывается без сети, и у этой удачи есть обратная сторона: всё, что лежит у человека в кеше, само не обновится. Вы выкладываете на сервер новый sw.js, а открытые вкладки живут на старой версии — иногда неделями. В этой статье разберёмся, когда браузер вообще узнаёт о новой версии, почему не включает её сразу, чем расплачивается за спешку skipWaiting и как устроено обновление, которое не ломает открытую доску.
Установленный воркер не ходит на сервер за новостями по таймеру. Проверку запускают события, и их три. Главное — навигация: всякий раз, когда человек открывает или перезагружает страницу из области действия, браузер заново скачивает sw.js и сравнивает его с установленным. Второй повод — всё, что будит воркер без навигации: push, sync и даже обычные запросы страницы за данными и картинками. Таких событий бывает очень много, поэтому по ним проверка идёт, только если с прошлой прошло больше суток, — так записано в спецификации. Третий повод — явный вызов registration.update() из кода страницы.
Что считать новой версией, браузер решает без вашей помощи: номер в константе и дата файла ему безразличны, он сравнивает байты. Предскажите пять строк, прежде чем запускать.
Последняя строка — ловушка из жизни. Если сборка вписывает в sw.js дату или номер сборки, новой версией становится каждая сборка, даже когда в коде не поменялось ни символа, и пользователи будут получать «Доступна новая версия» после каждой выкладки. Обратное тоже бывает: код, который воркер подключает через importScripts, поменялся, а сам sw.js — нет. Такое отличие проверка тоже замечает, но с оговоркой про HTTP-кеш; её разберём в последнем разделе.
На настоящей доске проверку видно в журнале сервера. Воркер уже установлен; доску перезагружают в первой вкладке, а потом открывают во второй:
На каждую навигацию — свой GET /sw.js, причём не одновременно со страницей, а на пару секунд позже: так Chrome 152 ставит эту проверку. Кода и стилей в журнале нет, их отдал кеш воркера. А sw.js идёт мимо кеша воркера всегда, иначе старый воркер сам отвечал бы на вопрос о своей замене. Мимо HTTP-кеша браузера он тоже идёт: при настройках регистрации по умолчанию браузер, проверяя обновление, обязательно спрашивает сервер, какими бы заголовками файл ни отдавался. Это тоже проверим в последнем разделе.
Сам по себе воркер о новой версии не узнает. Доску, которую держат открытой весь рабочий день и ни разу не перезагружают, проверяет только update() — поэтому для долгоживущих страниц его зовут сами, например раз в час.
Допустим, браузер нашёл отличие. Новый воркер скачан и идёт по знакомой лестнице: installing, installed. Дальше он останавливается. Пока у старой версии есть хоть одна страница, новая ждёт в состоянии installed, а в API лежит в registration.waiting. Причина та же, по которой воркер не забирает страницу посреди её жизни: открытая доска загружалась со старым кешем и по старым правилам, и подменить их у неё под ногами значит получить страницу, собранную из двух версий.
Правило короткое, а следствия неочевидные. Возьмём автомат жизненного цикла из первой статьи и добавим к нему счётчик открытых страниц. Предскажите вывод, особенно строки после второй перезагрузки, когда вкладка осталась одна.
Всё решает порядок внутри reload. Перезагрузка — это навигация, и запрос новой страницы уходит, пока старая ещё открыта: новый документ получает воркера раньше, чем выгружается прежний. Страниц у старой версии на миг становится две, а не ноль, и новая остаётся ждать. Выпускает её только момент, когда не осталось ни одной: вкладку закрыли или увели на другой сайт.
Проверим на настоящей доске. app.js в этом опыте короткий: страница сообщает о найденной новой версии и спрашивает у служебного адреса /sw-version, кто обслуживает её запросы. На этот адрес отвечает сам воркер: в его обработчик fetch для опыта добавлена ветка if (path === "/sw-version") event.respondWith(new Response(VERSION)), а без воркера сервер отдаёт 404. Остальное — проверка, которую я вставлял в консоль DevTools. Между шагами на сервер выкладывается sw.js, в котором поменялась одна строка — VERSION стала board-v4.
Читаем по шагам. При первом открытии updatefound тоже пришёл: для браузера первая установка — такая же «новая версия», запомним это до задач. После update() новый воркер нашли обе вкладки: updatefound приходит во все открытые страницы сайта. Его установка уже прошла, в хранилище лежат оба кеша, board-v3 и board-v4, но activate новой версии не запускался, и запросы по-прежнему обслуживает старая.
Перезагрузка при двух вкладках ничего не меняет — это ожидаемо. Но и после того как вторую вкладку закрыли, перезагрузка единственной оставшейся тоже ничего не меняет: ровно то перекрытие, которое мы собрали в миниатюре. Сработал только уход на другой сайт: вернувшись, доска получила board-v4, а старый кеш исчез — его убрал activate новой версии.
Перезагрузка не выпускает новую версию. Она включается, когда закрыты или уведены с сайта все вкладки: у одного человека через минуту, а у того, кто держит доску открытой неделями, через неделю.
У ожидания есть лекарство в одну строку — self.skipWaiting() в обработчике install. Новая версия перестаёт ждать и сразу проходит в activating, а открытые страницы переходят под её управление прямо на ходу. clients.claim() для этого не нужен: страницы и так принадлежат этой регистрации, у неё просто сменился активный воркер.
Чем это грозит, видно на доске, у которой есть ленивая часть. Пусть в этом варианте у неё появился график, и грузится он через import() — динамический импорт из части I: код графика лежит отдельным файлом chart.1a2b3c4d.js, в SHELL версии board-v3 он стоит рядом с app.3f9a1c2b.js, но страница просит его только по нажатию. Выходит board-v4. Сборщик дал файлам новые имена, старые при выкладке удалены, а воркер написан наспех:
Прежде чем смотреть на живой браузер, соберём это миниатюрой. Маршрутизатор со всеми ветками из прошлых статей лежит на странице примера. Старая страница, которой управлял board-v3, просит график, а меняются два условия: вызван ли skipWaiting и остались ли старые файлы на сервере. Предскажите три строки.
Первая строка — то, ради чего новая версия ждала: старая страница живёт под старым воркером, и всё, что может ей понадобиться, лежит в его кеше. Во второй платим за skipWaiting: activate новой версии уже убрал board-v3, в board-v4 файла со старым хешем нет и быть не может, а на сервере его удалили. Третья строка уточняет диагноз: ломает не сам skipWaiting, а его сочетание с удалёнными файлами. Если старые файлы какое-то время полежат на сервере, страница дотянется до них сетью — правда, только пока сеть есть.
Теперь то же на живом браузере, три прогона с чистым профилем. В каждом доску открывают дважды, на сервер выкладывают board-v4, в консоли зовут update(), печатают список кешей и нажимают «График». Код страницы — старый, из board-v3:
Первый прогон — без skipWaiting: график нарисовался, хотя на сервере его файла уже нет, — его отдал кеш старой версии. Во втором страница получила controllerchange и тут же лишилась своего кеша, а в журнале сервера на нажатие осталась одна строка:
В настоящем приложении такое выглядит как кнопка, которая молча не работает, или белый экран при переходе в другой раздел — и только у тех, у кого вкладка была открыта в момент выкладки. Воспроизвести это у себя трудно, а в отчётах об ошибках оно похоже на случайный сбой сети. Похожая ловушка ждёт и приложения для телефона: обновление в обход магазина привозит новый JavaScript, а часть приложения, установленная из магазина, остаётся старой. Как от неё защищаются, разберём в пятой части, в статье «runtimeVersion: замок, который бережёт установленные приложения».
skipWaiting не ускоряет обновление, а меняет договор: страница, собранная для одной версии, дальше работает с кешем и правилами другой. Безопасен он, только если страница об этом узнаёт и сразу перезагружается.
Правильная схема берёт у skipWaiting скорость, но убирает переключение под ногами. Воркер не включает себя сам, а ждёт команды. Страница, узнав, что новая версия ждёт, показывает «Доступна новая версия». Человек нажимает — страница посылает ждущему воркеру сообщение, и тот вызывает skipWaiting. Когда страница перейдёт к новому воркеру, придёт событие controllerchange, и страница перезагрузится уже целиком новой версией. В sw.js для этого нужен один обработчик:
Имя сообщения — не часть API, а договорённость воркера со своей же страницей; SKIP_WAITING — просто привычное имя.
Остаются соседние вкладки. controllerchange придёт во все, но уже после переключения, когда соседняя страница оказалась под новым воркером с той самой бедой из прошлого раздела. Предупредить их можно раньше, через BroadcastChannel — он уже встречался в статье про хранилище как способ переговариваться вкладкам одного сайта. Между вкладками в песочнице его не показать: у каждого примера своё непрозрачное происхождение, и два таких кадра друг друга не слышат. Поэтому соберём маленькую шину в памяти и сверим её с настоящим каналом там, где он работает, — внутри одного документа.
Шина повторяет два свойства канала, на которых стоит схема. Отправитель своего сообщения не получает — вкладке, нажавшей «Обновить», не нужно узнавать об этом от самой себя. А получатель узнаёт о сообщении не посреди кода отправителя, а отдельной задачей, когда тот уже дописал свои строки.
Теперь вся схема миниатюрой: автомат и шина лежат на странице, две вкладки видят плашку, нажимают в A. Предскажите, в какой момент о переключении узнаёт B и где в выводе встанет controllerchange.
Предупреждение по каналу пришло в B раньше, чем что-то произошло: у соседки было время сохранить черновик, пока у руля старый воркер. Сохранять черновик доска умеет с проекта части II: это flushSave из задачи статьи «Сохранение и восстановление доски». Задержка в 20 мс здесь условная, и порядок этот стандарт не обещает: канал и воркер — независимые очереди, и на стенде ниже канал выиграл гонку, а не воспользовался гарантией. Черновику проигрыш не страшен: flushSave пишет в localStorage, и воркер для этого не нужен. Потом — порядок самого переключения: старая версия уходит в redundant, новая проходит activating, страницы получают controllerchange, и только после activate наступает activated. A перезагружается, потому что нажимали в ней. B человека не перебивает: вдруг он там что-то печатает, поэтому она лишь просит перезагрузиться.
На настоящей доске добавим одно правило: невидимой вкладке спрашивать некого, и она перезагружается молча. Разметка плашки — <p id="update-banner" hidden>Доступна новая версия <button id="update">Обновить</button></p>, код страницы такой:
Всё по плану. Плашку показали обе вкладки. B узнала о переключении раньше, чем оно началось. Дальше порядок из миниатюры: redundant, activating, controllerchange, activated. A перезагрузилась, потому что нажимали в ней, B — потому что невидима. Обе вернулись уже под board-v4, а в хранилище остался один кеш.
Схема работает, только если она уже есть в старой странице: плашку показывает код, загруженный сейчас, а не тот, что лежит на сервере. Поэтому её выпускают заранее. Версия, в которой схемы ещё нет, обновится по-старому — когда закроют все вкладки.
Файлы с хешем в имени — хорошая привычка для кода и стилей, и её хочется по инерции приложить к самому воркеру: sw.5d1e8a42.js, при следующей сборке sw.b3c9f071.js. Проверим, что будет. Воркер в этом опыте отдаёт из кеша всё, включая саму страницу, — как в первой статье главы; так часто делают ради мгновенного открытия. Главное, что меняется между сборками, — строка регистрации:
Это тупик, и выхода из него изнутри нет. Страницу отдаёт кеш старого воркера, в ней старый код, и он регистрирует старое имя. Браузер проверяет старый адрес, получает 404, а неудачная проверка ничего не меняет: работает прежний воркер. Новый sw.b3c9f071.js лежит на сервере, но знает о нём только новая index.html, до которой старый воркер не пускает. Выбраться человек может, очистив данные сайта, а вы — выложив новый воркер по старому адресу, который браузер продолжает проверять. С постоянным именем sw.js такой истории не бывает: браузер спрашивает тот же адрес и находит там новые байты.
Второе правило — про HTTP-кеш, и в нём есть кажущееся противоречие. Мы видели, что при проверке браузер идёт за sw.js на сервер всегда. Зачем тогда запрещать долгий кеш? Ответ — в файлах, которые воркер подключает через importScripts. Регистрация по умолчанию создаётся с updateViaCache: "imports": HTTP-кеш обходится для самого sw.js, но не для того, что он подключает.
Воркер подключает router.js, и сервер отдаёт оба файла с Cache-Control: max-age=31536000, то есть «хранить год». Во второй выкладке поменялись оба: в sw.js — VERSION, в router.js — строка ROUTER.
Годовой кеш sw.js не помешал: его запрашивали на каждой проверке, новую версию нашли и поставили. А router.js сервер после первой загрузки больше не видел — новый воркер board-v4 собрался со старым router 1 из HTTP-кеша. Ещё два прогона показывают, как выглядит правка одного только подключаемого файла:
С годовым кешем правку не заметил никто: проверка сравнила sw.js, не нашла отличий, а router.js взяла из кеша. Без кеша тот же файл перепроверялся на каждой навигации, отличие нашлось, и новая версия поставилась, хотя sw.js не менялся. При годовом кеше сервер увидит запрос router.js, только если с прошлой проверки регистрации прошло больше суток: тогда, по спецификации, и подключаемые файлы перепроверяются в обход кеша. У человека, который заходит каждый день, этого не случится.
| Файл | Имя | HTTP-кеш | Почему |
|---|---|---|---|
sw.js | постоянное | без долгого кеша | старые страницы знают только этот адрес; браузер сам кеш для него обходит, но по дороге ответ могут хранить кеширующие посредники — например, CDN, сеть серверов, раздающих копии файлов ближе к пользователю; они смотрят на заголовки |
файлы из importScripts | с хешем в имени | долгий — только вместе с хешем | по умолчанию идут через HTTP-кеш: без хеша новый воркер соберётся со старой копией, а новое имя меняет и байты sw.js |
| код и стили страницы | с хешем в имени | долгий | адрес уникален для версии, старое содержимое к нему не прилипнет |
Доска теперь обновляется честно: новая версия ждёт своей очереди, спрашивает человека, переключается по команде и не оставляет соседние вкладки со сломанной страницей. Осталось сделать её приложением, которое ставят на экран, — этим займёмся в следующей статье, про манифест и установку.
- Браузер проверяет обновление при навигации на страницу из области действия, при
update()и при прочих поводах вродеpush,syncи запросов страницы — если с прошлой проверки прошло больше суток. Новая версия — любое отличие в байтах, хоть дата сборки в комментарии. - Новая версия ставится и ждёт в
registration.waiting, пока у старой не останется ни одной страницы. Перезагрузка единственной вкладки её не выпускает: новый документ получает воркера раньше, чем выгружается старый. skipWaitingвinstallпереводит открытые страницы под новый воркер на ходу:activateуже удалил их кеш, и ленивыйimport()старого файла падает, если того нет и на сервере.- Правильная схема: плашка «Доступна новая версия» (только если у страницы уже есть
controller), по нажатию — сообщениеSKIP_WAITINGждущему воркеру, наcontrollerchange— перезагрузка. Соседние вкладки предупреждают поBroadcastChannelдо переключения. - Схема должна быть в старой странице заранее: плашку показывает загруженный код, а не лежащий на сервере.
sw.js— постоянное имя и без долгого кеша: старая страница из кеша знает только старый адрес. Файлы изimportScriptsпо умолчанию идут через HTTP-кеш, поэтому им нужен хеш в имени.
1/4На сервер выложили новый sw.js. У пользователя открыта одна вкладка доски, и он жмёт F5. Какой воркер обслуживает перезагруженную страницу?
2/4В install новой версии стоит self.skipWaiting(), при выкладке старые файлы с хешем удалены с сервера. Открытая старая страница по нажатию делает import("/assets/chart.1a2b3c4d.js"). Что будет?
3/4Зачем соседним вкладкам сообщение по BroadcastChannel, если controllerchange и так придёт в каждую?
4/4Воркер подключает router.js через importScripts, и оба файла сервер отдаёт с max-age на год. В новой выкладке поменяли и sw.js, и router.js. Что получит пользователь, который заходит каждый день?
Первая установка — не обновление
Воркер дошёл до installed, и страница решает, показывать ли плашку «Доступна новая версия». Сейчас shouldOfferUpdate смотрит только на состояние и предлагает обновление даже при самом первом открытии доски, когда обновлять нечего.
Допишите функцию так, чтобы плашка появлялась только при настоящем обновлении. controller — версия, которая сейчас обслуживает страницу, или null, если воркера у страницы нет. Сейчас вывод — true, true, false, а нужен false, true, false.
Отличает обновление от первой установки не состояние нового воркера, а наличие старого у страницы.
В настоящем коде это fresh.state === "installed" && navigator.serviceWorker.controller. На стенде видно, почему проверка нужна: при первом открытии доски updatefound пришёл так же, как при обновлении. А без проверки при загрузке (registration.waiting уже есть) плашку не увидит тот, кто открыл доску, когда новая версия давно ждала: updatefound в этом документе уже не случится.
Невидимой вкладке спрашивать некого
Автомат, шина и сторона воркера из статьи уже на странице. Открыты три вкладки: A и C на виду (скажем, в разных окнах), B — фоновая. Нажимают в A. Сейчас на controllerchange перезагружается только A, а B и C показывают плашку «перезагрузите», причём в B её никто не увидит.
Поменяйте onControllerChange так, чтобы невидимая вкладка перезагружалась сама, а видимая соседка по-прежнему спрашивала человека.
Условие перезагрузки расширяется одним «или»:
Строка B меняется на B: controllerchange, перезагружаюсь, A и C остаются прежними. В настоящем коде видимость читают в момент события — document.visibilityState, а не запоминают при открытии: человек мог переключиться на вкладку минуту назад. Черновик к этому моменту уже сохранён по сообщению из канала, так что молчаливая перезагрузка ничего не отнимет.
Уборка только своего
В activate новая версия удаляет «все кеши, кроме своего». Но кеши принадлежат не воркеру, а происхождению: на том же сайте есть долгоживущий кеш аватаров доски board-avatars, который не зависит от версии, и кеши чата chat-v7 со своим воркером в другой области действия.
Сейчас cachesToDelete сносит и их. Сделайте так, чтобы удалялись только прошлые версии оболочки доски — кеши вида board-v и номер.
Отбирать надо по своему признаку, а не по «всё, что не текущее»:
Вывод становится удалить: board-v3. Регулярное выражение строже, чем startsWith("board-v"): префикс задел бы и какой-нибудь board-vendor. В настоящем activate этот фильтр встаёт на место names.filter((name) => name !== VERSION), остальное не меняется.