Стратегии кеша: собираем маршрутизатор запросов

PWA≈ 14 минут

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

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

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

Браузер кеширует и без нас: сервер присылает заголовки вроде Cache-Control, браузер по ним решает, можно ли не переспрашивать. Этим кешем вы не распоряжаетесь — ни открыть, ни положить туда что-то из кода нельзя. Обычно сервер делит файлы на два сорта: файлу, чьё имя меняется с каждой правкой содержимого (как этого добиваются, увидим ниже), разрешает храниться хоть год и не переспрашивать — max-age=31536000, immutable, а HTML-странице ставит no-cache: копию держать можно, но перед показом её сверяют с сервером. Как расписать такие заголовки для настоящего сайта, покажет часть IV — в статье про статический экспорт.

Cache API — совсем другое устройство. Это склад пар «запрос → ответ», который наполняете и чистите вы сами. Имя складу даёте вы (caches.open("board-v2")), и лежит в нём то, что вы положили, — пока сами не удалите или пока браузер не выселит данные сайта целиком (об этом в следующей статье).

Внутри лежат настоящие Request и Response — те самые, что в части I. И первое, обо что все спотыкаются, — свойство самого ответа. Предскажите вывод, прежде чем запускать.

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

Тело ответа — поток, и вычитывается он один раз (в части I это уже встречалось у fetch). После первого text() флаг bodyUsed становится true, а второе чтение бросает TypeError — имя ошибки одинаковое везде, а текст у каждого браузера свой, полагаться на него не стоит. Спасает clone(), сделанный до чтения: один экземпляр уезжает в кеш, другой достаётся странице.

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, страница на своём localhost
после no-store лежит в кеше: true
тот же адрес с ?v=2: false
он же с ignoreSearch: true
запрос с чужим заголовком: true
put с POST: TypeError
тело ответа использовано: true
прочитать его ещё раз: TypeError
кеши этого сайта: проба-v1

Читаем по строкам. Заголовок Cache-Control: no-store складу безразличен: он заголовков не читает, что положили — то и лежит. Ключ — запрос, но match примет и строку: из неё он сам сделает GET-запрос. Адрес сравнивается целиком, вместе с ?v=2, — если это не нужно, есть ignoreSearch. А вот чужие заголовки запроса на совпадение не влияют (исключение — те, что ответ перечислил в своём Vary). Запрос методом POST в кеш не положить вовсе: put бросает TypeError. И последнее: put забирает тело себе — после него bodyUsed уже true.

HTTP-кеш браузераCache API воркера
Кто решает, что хранитьсервер заголовками, браузер — по своим правиламтолько ваш код
Ключадрес плюс заголовки из Varyзапрос: метод и адрес целиком, а из заголовков — только перечисленные в Vary ответа
Срок годностиmax-age, дальше перепроверка у серверабессрочно: пока не удалите сами или пока браузер не выселит хранилище
Что можно положитьто, что пришло по сетилюбой Response, хоть собранный в коде
Методыпо сути только GETтолько GET

Cache API — не «ускоритель», а склад. Что положили, то и будет отдано, хоть через год. Решение об устаревании принимает ваш код, и никто больше.

Он идёт обычным путём — через тот самый HTTP-кеш браузера. На стенде это видно прямо в журнале сервера: когда я поднял доску на python3 -m http.server, который никакого Cache-Control не присылает, запросы воркера до сервера либо доезжали коротким 304, либо не доезжали вовсе — в зависимости от того, насколько свежими браузер счёл файлы.

Поэтому на стенде ниже мой сервер отдаёт всё с Cache-Control: no-store: тогда в журнале видно каждый запрос, который воркер выпустил наружу, и опыт меряет работу нашего кеша, а не чужого. В настоящем приложении это не нужно, а знать полезно: «воркер сходил в сеть» и «запрос дошёл до сервера» — разные события.

Сборщики (Vite, esbuild, webpack — любой) кладут собранный код не в app.js, а в файл вроде app.3f9a1c2b.js. Восемь символов в имени — хеш, короткая свёртка содержимого: поменялся в коде хоть один символ, и файл выйдет уже под другим именем. Следствие важнее самого приёма: по адресу /assets/app.3f9a1c2b.js содержимое не изменится никогда. Спрашивать про такой файл сеть попросту незачем.

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

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

Третий ответ снова v1, хотя сеть уже отдаёт v2, — и это не изъян стратегии, а её смысл: под именем с хешем другого содержимого не бывает. Заодно посмотрите на ключ: в нём метод и адрес вместе, как в настоящем Cache API.

«Сначала кеш» — единственная из трёх стратегий, которая способна показывать устаревшее вечно. Прикладывайте её только к адресам, уникальным для версии.

Тогда «сначала кеш» опасно. Положили в кеш style.css — и правка стилей не доедет до пользователя никогда, сколько бы он ни перезагружал страницу. Для таких файлов кеш заводят с номером версии в имени (board-v2) и чистят прошлые версии в activate, как в прошлой статье: тогда обновление приносит новая версия воркера, а не сеть.

А сам sw.js хешем не переименовывают и долгим кешем не отдают — иначе обновлять станет нечего. Почему именно, разберём в статье про обновление воркера.

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

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

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

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

В настоящем воркере свой withTimeout писать не нужно: у fetch есть готовый AbortSignal.timeout(ms) из части I — по истечении срока запрос обрывается ошибкой TimeoutError, и её ловит тот же catch.

Тайм-аут выбирают не «по-честному», а по терпению: для HTML это обычно полсекунды-секунда. Всё, что дольше, пользователь считает поломкой, и вчерашняя копия из кеша ему полезнее.

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

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

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

В настоящем воркере у этой стратегии есть обязательная деталь. Фоновое обновление нужно держать через event.waitUntil(...) — тот самый waitUntil, которым в прошлой статье держалась установка. Без него браузер вправе погасить воркер сразу после того, как тот отдал ответ: обновление оборвётся на полпути и до кеша не доедет.

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

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

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

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

Теперь то же самое по-настоящему. Вот sw.js доски целиком — вместо Map в нём caches.open, вместо строк Response, а в остальном это тот же маршрутизатор.

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

Три места стоят того, чтобы задержаться. В кеш уезжает fresh.clone(), а оригинал отдаётся странице — иначе тело достанется кому-то одному. Фоновое обновление держит event.waitUntil. А для запроса не методом GET мы просто не вызываем respondWith — и браузер везёт запрос сам, будто воркера нет вовсе.

Открываем доску четыре раза подряд. Между второй и третьей загрузкой я поменял на сервере api/tasks.json: там стало на одну задачу больше и version: 2. Код доски печатает, кто ей управляет и какая версия задач оказалась на экране.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, четыре загрузки доски на своём localhost; строки «===» печатает мой сценарий, а не страница
=== загрузка 1: http://localhost:3376/ ===
controller: нет
на экране версия задач: 1
=== загрузка 2: http://localhost:3376/ ===
controller: есть
на экране версия задач: 1
(на сервере теперь версия 2)
=== загрузка 3: http://localhost:3376/ ===
controller: есть
на экране версия задач: 1
=== загрузка 4: http://localhost:3376/ ===
controller: есть
на экране версия задач: 2

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

А вот тот же опыт со стороны сервера. Я взял свой статический сервер, а не python3 -m http.server, по одной причине: он отдаёт всё с Cache-Control: no-store, и в журнал попадает каждый запрос, который воркер выпустил наружу.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал того же прогона; строки второй и четвёртой загрузок убраны, чтобы было видно первую (23:08:32) и третью (23:08:41)
мой сервер слушает http://127.0.0.1:3376 из ./board
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET /assets/board.5c7d0a11.css HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET /assets/app.3f9a1c2b.js HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET /api/tasks.json HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET /favicon.ico HTTP/1.1" 404 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET /sw.js HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET /assets/app.3f9a1c2b.js HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:32] "GET /assets/board.5c7d0a11.css HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:41] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:41] "GET /api/tasks.json HTTP/1.1" 200 -
127.0.0.1 - - [12/Sep/2026 23:08:43] "GET /sw.js HTTP/1.1" 200 -

Первая загрузка шумная: браузер просит страницу, стили, код и данные, потом спрашивает sw.js — а следом те же три файла оболочки приезжают ещё раз. Это addAll в install: воркер набивает свой кеш и качает всё заново, своими запросами.

Третья загрузка — три строки. Ни board.5c7d0a11.css, ни app.3f9a1c2b.js в журнале нет: их отдал кеш, «сначала кеш» работает. Страница пришла из сети — «сначала сеть». Данные тоже видны, но это не ожидание страницы, а фоновое обновление, которое доедет до экрана следующей загрузкой. А sw.js браузер просит сам, на каждой навигации: он проверяет, не изменился ли воркер, — этому посвящена статья про обновление.

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

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

  • Cache API — не HTTP-кеш, а ваш склад: заголовков он не читает, ключ — запрос целиком (метод, адрес и строка запроса), кладут в него только GET, а живёт запись, пока её не удалят.
  • Тело Response читается один раз: cache.put его забирает, поэтому в кеш кладут clone(), сделанный до чтения, а оригинал отдают странице.
  • «Сначала кеш» — для адресов, уникальных для версии: файл с хешем содержимого в имени под старым именем уже не изменится.
  • «Сначала сеть» с тайм-аутом — для страницы: ждать сеть можно, но не бесконечно, иначе пустой экран вместо годной копии из кеша. В настоящем коде срок ставит AbortSignal.timeout(ms).
  • Stale-while-revalidate — для данных: ответ из кеша сразу, обновление фоном под event.waitUntil. Плата — отставание ровно на один запрос.
  • Выбор стратегии держат в одном месте, по виду запроса; запросы не методом GET пропускают мимо себя, не вызывая respondWith.

1/4Сервер прислал ответ с заголовком Cache-Control: no-store. Получится ли положить его в кеш воркера через cache.put?

2/4Почему в кеш кладут response.clone(), а не сам ответ?

3/4Сборщик выдал файл app.3f9a1c2b.js. Какая стратегия ему подходит?

4/4Страница дважды подряд просит /api/tasks по схеме stale-while-revalidate, а на сервере данные уже обновились. Что вернёт второй запрос?

Клон для кеша

«Сначала кеш» написана почти правильно: первый раз ответ приходит из сети и кладётся в кеш, дальше берётся оттуда. Беда в том, что в кеш уезжает тот самый объект, который уже отдали странице.

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

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

Клонировать нужно до того, как тело кто-нибудь тронул, — и в кеш класть именно клон.

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

Теперь вторая строка вывода — из кеша: код доски для /assets/app.3f9a1c2b.js. Но если позвать cacheFirst третий раз, снова получится TypeError: из Map каждый раз достаётся один и тот же объект, а тело у него уже вычитано. Настоящий Cache API этим не страдает — cache.match на каждый вызов отдаёт новый Response. В самодельном кеше приходится отдавать клон и при попадании: if (hit) return hit.clone();

Правило выбора

Маршрутизатор начинается с функции, которая по запросу называет стратегию. Напишите её. Правила такие: не GET — "мимо кеша"; адрес из /assets/ — "сначала кеш"; адрес из /api/ — "старое и обновить фоном"; всё остальное — "сначала сеть".

Сейчас функция на все четыре запроса отвечает одинаково. Путь берите из new URL(request.url).pathname — сравнивать адрес целиком тут ни к чему.

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

Порядок проверок здесь — часть правила: метод отсекается первым, иначе POST /api/tasks уедет по ветке данных и попадёт в кеш, которого для него не бывает.

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

Вывод становится разным: сначала кеш, старое и обновить фоном, сначала сеть, мимо кеша. Обратите внимание, что последняя строка — не стратегия кеша, а решение не вмешиваться: в настоящем воркере на этом месте просто не вызывают respondWith.