
# Сброс кэша: почему ваш ?v= скорее всего ничего не делает

Полдня навигация в продакшене отрисовывалась вообще без стилей. HTML был правильный, файл CSS на сервере был правильный, деплой прошёл без ошибок. Диагноз поставили неверно дважды, прежде чем кто-то посмотрел в нужное место.

`app.css` уезжал вообще без `?v=`. Cloudflare держал его в кэше четыре часа, браузеры — заметно дольше. Тот релиз переименовывал целую пачку CSS-классов, так что новый HTML приходил в браузер, всё ещё хранивший таблицу стилей предыдущей сборки: ни один класс из разметки ни с чем не совпадал.

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

У `app.js` `?v=` был, и это оказалось хуже, чем его отсутствие. Он подставлял версию из `package.json`:

```html
<script src="/js/app.js?v=<%= appVersion %>" defer></script>
```

Это число между релизами не двигается, а выкладываем мы гораздо чаще, чем поднимаем версию. То есть JS нёс токен, который любому читателю шаблона выглядел как сброс кэша и не сбрасывал ничего. Тот же баг, что и с CSS, в одном релизе от срабатывания, да ещё с приманкой впереди.

## Считайте хэш файла

У токена сброса кэша одна задача: меняться, когда меняется файл, и не меняться в остальное время. Единственное значение, которое так себя ведёт, выводится из самих байтов файла.

```js
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';

function assetVersion(relativePath) {
  const buf = readFileSync(join(__dirname, 'public', relativePath));
  return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}

const cssVersion = assetVersion('css/app.css');
```

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

И шаблон перестаёт врать:

```html
<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">
```

Три токена, которые берут вместо этого:

**Номер версии** (`?v=1.4.2`) меняется, когда человек вспомнил его поменять, а это не то же самое, что «когда изменился файл». Это и был наш `app.js`.

**Метка времени или id сборки** действительно меняется на каждом деплое, так что устаревшее не отдаётся никогда, но меняется и тогда, когда ничего не менялось. Каждый деплой выбрасывает закэшированный CSS у всех посетителей и копию на каждой точке CDN, сдвинулся хоть один байт или нет, и релиз, тронувший только бэкенд, всё равно стоит каждому пользователю одной загрузки.

**Случайное значение** (`?v=<%= Math.random() %>`, и такое действительно пишут) — это новый URL на каждый просмотр страницы. Это не сброс кэша, это его выключение навсегда и для всех.

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

## Query string или имя файла

`app.a1b2c3d4.css` даёт то же обещание, помещая хэш в имя файла, а не в query string. Для этого нужен шаг сборки, переписывающий все ссылки, — поэтому в проектах с бандлером так обычно и сделано, а в серверном рендеринге обычно нет.

Старый довод в пользу имени файла: некоторые кэши игнорируют query string при вычислении ключа, и тогда `?v=` превращается в украшение. Это до сих пор настройка, которую можно включить по недосмотру: в уровне кэширования Cloudflare есть пункт «ignore query string», и если он включён, все `?v=` на вашем сайте молча перестают работать. Проверьте, прежде чем на это полагаться.

## `immutable` — обещание про URL, а не про файл

`Cache-Control: public, max-age=31536000, immutable` говорит браузеру не перепроверять ресурс даже при перезагрузке. Это верно, только если URL является адресом содержимого, и в такой формулировке правило пишется само:

```js
if (/\.(css|js)$/.test(name)) {
  const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
  res.setHeader('Cache-Control', versioned
    ? 'public, max-age=31536000, immutable'
    : 'public, max-age=3600');
}
```

Тот же файл, запрошенный без токена, получает час. Обещание даёт токен, и ничто не должно кэшироваться на год по обещанию, которого никто не давал.

Проверьте, что ваша статика не переписывает это. `@fastify/static` по умолчанию шлёт `max-age=0`; выставьте заголовки, не отключив его собственный `cacheControl`, и он соберёт заголовок из этого умолчания и тихо затрёт ваш. Заголовок на месте, выглядит по-прежнему осмысленно и неверен.

Шрифты мы помечаем immutable по имени. Собранный сабсет заменяется переименованием, а не правкой, так что имя файла уже является адресом содержимого. С библиотеками, положенными в репозиторий, так же: `alpine-3.14.8.min.js` несёт версию в имени, и никто не правит её на месте.

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

## Файл, который обязан жить недолго

Мы отдаём скрипт бейджа, который работает внутри чужих страниц. Ни их HTML, ни URL, который они встраивают, нам не подчиняются. Год `immutable` там означал бы баг, до которого мы не дотянемся год: инвалидировать нечем, попросить каждый сайт с этим тегом поменять его невозможно. Он получает час, и это правильно, хотя файл почти никогда не меняется.

Всё, что живёт на URL, который вы не можете версионировать, устроено так же. Срок его жизни в кэше задаёт то, как быстро вам нужно суметь его починить, а не то, как часто он меняется.

## Проверка

Совпадает ли токен в HTML с файлом на диске?

```bash
curl -s https://вашсайт.ru/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8
```

Строка должна быть той же, иначе ваш HTML указывает на то, чего не описывает. Если токен не сдвинулся за два деплоя, каждый из которых менял CSS, у вас наш `app.js`.

Затем спросите сам ресурс, что он отвечает:

```bash
curl -sI 'https://вашсайт.ru/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'
```

Устаревшую точку выдаёт `last-modified`. Если он раньше вашего последнего деплоя этого файла, что-то между сервером и браузером всё ещё держит старую копию, а навредит вам это или нет, решает токен в вашем HTML.

[Проверить сайт](/ru) · [Чек-лист готовности к ИИ](/ru/tools/ai-readiness-checklist) · [Что мы измеряем](/ru/about)
