Без сети: оболочка, запасная страница и честный onLine

PWA≈ 13 минут

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

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

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

Маршрутизатор прошлой статьи делил запросы по адресу: /assets/ — из кеша, /api/ — старое и обновить фоном, а всё остальное — «сначала сеть». Страница попала в последнюю ветку просто потому, что её адрес не начинается ни с того, ни с другого. Но запрос за страницей особенный. В коде его никто не пишет: он случается, когда человек набирает адрес, жмёт ссылку или перезагружает вкладку, и делает его браузер — раньше, чем появятся код, стили и данные. Такой запрос называют навигационным.

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

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

У запроса, собранного в коде, режим cors — обычный запрос за ресурсом, а destination пуст: браузер не знает, подо что пойдёт ответ. У настоящих запросов страницы там стоит script, style, image, а у навигации — document. И режим navigate конструктор не принимает вовсе. Это не причуда песочницы: такой режим ставит только сам браузер, когда ведёт вкладку на новый адрес. Подделать навигацию из кода нельзя, поэтому в миниатюрах ниже мы будем изображать её обычным полем.

Теперь главное: что будет, если на навигацию воркер не ответит вовсе. Уберём из воркера прошлой статьи последнюю ветку, «всё остальное — сначала сеть». Так воркер часто и пишут, когда цель — ускорить загрузку, а не пережить метро: ветки для /assets/ и /api/ есть, для страницы нет:

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

Открываем доску дважды, чтобы воркер встал у руля и набил кеш, а потом выключаем сеть и просим страницу заново. В app.js ничего нового — регистрация и отрисовка из проекта:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, свой localhost на отдельном порту, чтобы не смешивать регистрации с доской. Строки «===» и «навигация не удалась» печатает мой сценарий, остальные — сама доска
=== стена: загрузка 1: http://127.0.0.1:3305/ (сеть: online) ===
controller: нет
задач на экране: 3
=== стена: загрузка 2: http://127.0.0.1:3305/ (сеть: online) ===
controller: есть
задач на экране: 3
=== стена: без сети, та же страница: http://127.0.0.1:3305/ (сеть: offline) ===
!! навигация не удалась: net::ERR_INTERNET_DISCONNECTED
=== стена: без сети, карточка: http://127.0.0.1:3305/task/17 (сеть: offline) ===
!! навигация не удалась: net::ERR_INTERNET_DISCONNECTED

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

С последней веткой, как в прошлой статье, главная страница без сети открылась бы: «сначала сеть» нашла бы её копию в кеше — это я проверил тем же стендом. А карточка /task/17, которой в кеше нет, всё равно кончается страницей ошибки, только другой: «сначала сеть» бросает ошибку дальше, и Chrome пишет net::ERR_FAILED. Нужна своя ветка — та, что знает, что перед ней страница, и умеет отдать хоть что-то.

Если на навигационный запрос воркер не вызвал respondWith, браузер едет в сеть сам. «Воркер установлен, кеш полон» и «сайт открывается без сети» — разные утверждения, и первое не влечёт второго.

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

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

ЧтоВ оболочку?Почему
Каркас страницы (/ или /index.html)дабез него открывать нечего
Код и стили с хешем в именидаадрес уникален для версии, старое по нему не прилипнет
Запасная страницадабез сети её уже не достать
Шрифты и иконки интерфейсада, если их немногооболочка должна оставаться короткой: один 404 рушит всю установку
Данные (/api/…)нетустареют раньше, чем пригодятся; их кладёт в кеш стратегия на лету

Вот sw.js доски. Главных правок с прошлой статьи три: в списке оболочки появилась запасная страница, у обработчика fetch — первая ветка, для навигации, а ветка «всё остальное — сначала сеть» ушла: страницы теперь ловит навигация, а прочим редким запросам воркер не нужен.

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

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

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Доска без сети</title>
<link rel="stylesheet" href="/assets/board.5c7d0a11.css">
</head>
<body>
<h1>Эта страница пока недоступна</h1>
<p>Сети нет, а в кеше этой страницы ещё не было.</p>
<p><a href="/">Открыть доску</a> — она сохранена и работает без сети.</p>
</body>
</html>

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

Что происходит при первой загрузке, видно в журнале сервера:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал моего статического сервера: строки первого открытия http://127.0.0.1:3304/ в чистом профиле Chrome 152
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /assets/app.3f9a1c2b.js HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /assets/board.5c7d0a11.css HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /api/tasks.json HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /favicon.ico HTTP/1.1" 404 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /sw.js HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /offline.html HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /assets/app.3f9a1c2b.js HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:45:47] "GET /assets/board.5c7d0a11.css HTTP/1.1" 200 -

Первые шесть строк — обычная загрузка: страница, код, стили, данные, favicon.ico с честным 404 и sw.js, который запросила регистрация. А дальше те же файлы едут по второму разу, и с ними offline.html, которого страница не просила ни разу. Это addAll в install набивает кеш: запасная страница скачана заранее, в этом весь смысл.

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

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

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

Второй запрос — доска из кеша: её адрес лежал в оболочке. Третий — карточка, которую никогда не открывали: в кеше её нет, и вместо страницы ошибки приезжает запасная. Четвёртый напоминает, что остальные ветки никуда не делись и код по-прежнему берётся из кеша молча.

Порядок двух последних проверок стоит проговорить. Сначала спрашиваем кеш про этот самый адрес и только потом достаём общую заглушку: человеку, который вчера открывал /task/17, лучше показать вчерашнюю карточку, чем честное «ничего нет». А если в кеше пусто и запасной страницы не нашлось, отдавать нечего: в настоящем воркере respondWith получит undefined, и браузер покажет страницу ошибки. Обратите внимание, что это не то же самое, что вовсе не вызвать respondWith, как в начале статьи: там запрос возвращается браузеру и при живой сети доезжает, а здесь он уже отдан воркеру, и ответ «ничего» кончается ошибкой даже при работающей сети (в Chrome — net::ERR_FAILED). Потому запасную и кладут в оболочку.

Теперь то же самое по-настоящему. Доска с новым sw.js открывается дважды с сетью, потом я гашу сервер и вдобавок выключаю сеть самой вкладке и воркеру — уйти наружу некуда, отвечать может только кеш.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · drive.mjs — мой сценарий: открывает вкладку, выключает сеть ей и воркеру, ведёт на адрес и печатает консоль страницы. Строки «===» и «заголовок…» печатает он, «controller» и «задач на экране» — сама доска. Chrome 152 без окна
=== загрузка 3, доска без сети: http://127.0.0.1:3304/ (сеть: offline) ===
controller: есть
задач на экране: 3
заголовок вкладки: "Доска задач"
заголовок в разметке: "Доска задач"
=== загрузка 4, карточка без сети: http://127.0.0.1:3304/task/17 (сеть: offline) ===
заголовок вкладки: "Доска без сети"
заголовок в разметке: "Эта страница пока недоступна"

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

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

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

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

Две строки — и обе правдивы. navigator.onLine отвечает на вопрос про интерфейс: подключена ли машина хоть к какой-нибудь сети. За этим подключением может быть роутер без интернета, гостиничный Wi-Fi с формой входа, упавший сервер, сломанный DNS или прокси, который режет ваш домен, — во всех случаях свойство скажет true, а запрос не дойдёт.

Обратное значение надёжнее, но и оно не приговор. Как решать, «в сети» ли машина, каждая система придумывает сама: в Windows, например, это значит «удалось достучаться до проверочного сервера Microsoft», и файрвол или VPN, которые его режут, дают false при работающем интернете. А до вашего собственного localhost запрос доходит и вовсе без сети. Поэтому флаг годится на подсказку — значок в углу, надпись «похоже, связи нет», — но не на решение: решает ответ на настоящий запрос.

Надпись «нет сети» ставят после неудачного запроса, а не по значению флага. Иначе получается интерфейс, который врёт в обе стороны: обещает работу там, где её нет, и пугает пользователя там, где всё в порядке.

Браузер сообщает о смене состояния событиями online и offline на window. Настоящие события шлёт он сам, когда интерфейс появляется или пропадает; здесь мы отправляем их руками через dispatchEvent — тем же приёмом, каким в статье про хранилище изображали событие storage от соседней вкладки.

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

Свойство не поменялось ни разу, и это важнее самого примера: событие мы подделали, а navigator.onLine подделке не подчиняется — его ставит браузер по состоянию интерфейса. У настоящего offline в обработчике было бы false. Годятся эти события ровно на то же, на что и флаг: обновить подсказку в интерфейсе и лишний раз попробовать отправить накопившееся, когда связь, похоже, вернулась.

В прошлой статье мы сказали: запись в кеше живёт, пока её не удалит ваш код. Пора добавить вторую половину: или пока браузер не выселит данные сайта целиком.

Про своё хранилище можно спросить у navigator.storage: persisted() — защищено ли оно от выселения, persist() — просьба защитить, estimate() — сколько занято и сколько разрешено. В песочнице учебника все три отклоняются TypeError (происхождение непрозрачное, хранилища нет), поэтому дальше — листинг с выводом, снятым на доске с localhost.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна на http://127.0.0.1:3304/, чистый профиль
уже защищено: false
попросили защиту: false
занято, байт: 8676
разрешено, МБ: 10240
кеши сайта: board-v3

Просьбу отклонили. Ничего личного: профиль чистый, сайт открыт второй раз в жизни. Chrome решает по своим признакам — установлено ли приложение на устройство, в закладках ли сайт, разрешены ли ему уведомления, часто ли им пользуются. Safari с 17-й версии тоже решает сам, и один из его признаков — открыт ли сайт как приложение с экрана Домой. Firefox вместо эвристики спрашивает разрешение у пользователя. Общее правило одно: persist() — это просьба, а не команда, и отказ — нормальный ответ, к которому код обязан быть готов.

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

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

Из этого следует вещь, которую стоит запомнить раньше, чем начнёте обещать заказчику «работает без сети». Выселение уносит не только кеш, но и регистрацию воркера — то есть сам механизм. Сайт после этого не «показывает старые данные», он просто снова требует сеть, как обычная страница. Надёжный офлайн на iPhone получается у приложения на экране Домой, а не у вкладки; как туда попасть, разберём в статье про манифест и установку.

И последнее, чего доска пока не умеет, — работать без сети на запись. Пока сети нет, новую карточку некуда отправить, и путей два. Отложить отправку до возвращения связи умеет Background Sync, но он есть только в браузерах на Chromium, и строить на нём единственную дорогу нельзя. Значит, остаётся второй путь: складывать изменения в очередь у себя и досылать их самому, разбираясь с порядком, повторами и чужими правками. Это отдельная большая тема, и разберём мы её там, где без неё совсем никак, — в пятой части, про мобильные приложения.

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

  • Навигационный запрос делает браузер, а не код: у него mode === "navigate", и вручную такой Request не собрать. Если воркер не вызвал для него respondWith, браузер едет в сеть сам — и без сети показывает свою страницу ошибки, каким бы полным ни был кеш.
  • Оболочка приложения — каркас страницы, код, стили и запасная страница — кладётся в кеш в install: это единственный момент, когда сеть заведомо есть.
  • Ветка навигации: сначала сеть с тайм-аутом, потом тот же адрес из кеша, потом общая запасная страница. Если не нашлось ничего, respondWith получает undefined — и это ошибка, а не возврат в сеть: отдать запрос браузеру можно, только не вызвав respondWith вовсе.
  • navigator.onLine отвечает про подключение к сети, а не про связь с сервером: true ничего не обещает, да и false бывает ложным. Флаг — подсказка для интерфейса, а решение принимают по результату запроса.
  • Кеш живёт, пока его не удалит ваш код — или пока браузер не выселит данные сайта. Выселение уносит и кеши, и регистрацию воркера; Safari чистит забытые сайты после семи дней пользования браузером, а у приложения на домашнем экране счёт дней свой. persist() — просьба, отказ нормален.
  • Запись без сети — отдельная задача: Background Sync есть только в Chromium, и очередь изменений приходится вести самому.

1/4Воркер ловит запросы к /assets/ и /api/, оболочка в кеше. Сети нет, пользователь перезагружает страницу. Что он увидит?

2/4navigator.onLine вернул true. Что из этого следует?

3/4Почему запасную страницу кладут в кеш при install, а не тогда, когда сеть пропала?

4/4Браузер выселил данные сайта. Что потерялось?

Запасная страница

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

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

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

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

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

Вторая строка вывода становится такой: /task/17 → сети нет, но доска сохранена | запасная страница. Последнюю строку с капитуляцией не выбрасывайте: запасной страницы может не оказаться в кеше — например, установка сорвалась и воркер поднялся на старой версии оболочки. В настоящем воркере эта ветка означает, что respondWith получит undefined и браузер покажет свою ошибку — причём ошибкой всё кончится и при живой сети: назад в сеть браузер запрос, уже отданный воркеру, не повезёт.

Честное «связь есть»

Доска собирается отправить накопившиеся правки на сервер и спрашивает у canSync, стоит ли пробовать. Сейчас ответ строится на одном navigator.onLine, поэтому вторая строка вывода врёт: сервер молчит, а функция обещает связь.

Перепишите canSync так, чтобы решение принималось по результату запроса, а не по флагу. Пробный запрос уже написан — это ping; он бросает исключение, если сеть не ответила за 200 мс.

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

Флаг из решения уходит совсем. Ложным бывает не только его true, но и false, и отсечка по нему заперла бы синхронизацию у человека, у которого за VPN всё работает.

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

Теперь вторая строка — false. В настоящем приложении у такой проверки есть цена: это лишний запрос перед каждой отправкой. Поэтому пробный запрос обычно не делают вовсе, а просто отправляют данные и смотрят на результат — неудача и есть ответ «связи нет». Отдельный ping оправдан там, где до отправки нужно показать состояние: кнопку, значок, надпись в углу.