{ "index": 216, "slug": "editorial-2022-01-practice-ssr-csr", "title": "SSR и CSR без рассинхронизации: как принять snapshot и не запустить второй запрос", "excerpt": "Сервер уже показал данные, но клиент снова их загружает и меняет первый экран. Разбираем владельцев состояния, версию snapshot, проверку hydrate и явный путь для mismatch.", "contentHtml": "

Симптом появляется сразу после загрузки страницы: сервер уже отдал карточку с данными, а после запуска JavaScript браузер повторяет тот же запрос. Иногда ответы совпадают, и ошибка остаётся незаметной. При задержке или изменении записи пользователь видит один текст, затем другой. В DevTools появляются два запроса, а команда не может объяснить, какой из них владел первым экраном. Цена ошибки — лишний сетевой путь, более длинная критическая цепочка и риск тихого рассинхрона между HTML и клиентским состоянием.

\n

Тезис простой: SSR и CSR не должны конкурировать за начальное состояние. Сервер читает source, создаёт HTML и передаёт рядом сериализованный snapshot. Клиент принимает snapshot только после проверки его версии. При совпадении он пропускает начальный fetch. При несовпадении он сначала фиксирует проблему и останавливает неявную мутацию. Новый запрос допустим только как явно выбранное восстановление.

\n

Механизм: четыре фазы и четыре владельца

\n

SSR — это серверный запрос и HTML, который браузер получает первым. HTML показывает результат, но сам по себе не говорит клиентскому коду, из какого чтения он появился. Поэтому ответ должен передать и snapshot начального состояния. Snapshot — не общий кеш приложения. Это данные для конкретного server response.

\n

Hydrate связывает клиентскую логику с уже существующей разметкой. На этой границе нужно сравнить маркер версии HTML и версию snapshot. Версия может быть номером ревизии, ETag, версией набора фильтров или другим значением, которое сервер умеет получить вместе с данными. Нельзя сравнивать только время запроса: два чтения могут иметь одинаковую секунду, но разные права, locale или feature flags.

\n
Кто владеет данными на первом проходе
ФазаВладелецВходДопустимое действиеОшибка
SSR/sourceserver-requestЗапись и её версияПрочитать source и создать HTMLHTML без понятной версии
Ответserialized-snapshotPayload и версия SSRПередать initial state рядом с HTMLВерсия потерялась при сериализации
Hydratebrowser-transitionВерсия разметки и snapshotСравнить до изменения состоянияРазные версии приняты молча
Client fetchbrowser-transitionЯвное решение после проверкиПропустить запрос или начать recovery pathFetch запускается на каждый mount
\n

Минимальный пример с версией

\n

Ниже — учебная JavaScript-модель. Она работает только с объектами и JSON. В ней нет React, Next.js, DOM, сети, таймера и реального браузерного hydrate. Пример показывает порядок владения данными и помогает написать проверку границы. Он не доказывает LCP, latency или поведение конкретного hook.

\n
const source = {\n  id: 'entry-42',\n  version: 'entry-r7',\n  title: 'Training entry',\n  status: 'published',\n};\n\nconst snapshot = JSON.stringify({\n  schema: 'ssr-snapshot-v1',\n  version: source.version,\n  payload: {\n    id: source.id,\n    title: source.title,\n    status: source.status,\n  },\n});\n\nfunction planHydration(markupVersion, serializedSnapshot) {\n  const parsed = JSON.parse(serializedSnapshot);\n\n  if (markupVersion !== parsed.version) {\n    return {\n      outcome: 'mismatch-recorded-before-mutation',\n      diagnostic: {\n        expectedMarkupVersion: markupVersion,\n        serializedVersion: parsed.version,\n      },\n      clientFetch: { performed: false, action: 'not-started' },\n    };\n  }\n\n  return {\n    outcome: 'snapshot-accepted-without-second-fetch',\n    stateOwner: 'serialized-snapshot',\n    initialState: parsed.payload,\n    clientFetch: {\n      performed: false,\n      action: 'skipped-snapshot-version-matched',\n    },\n  };\n}\n\nconst matching = planHydration(source.version, snapshot);\nconsole.log(matching.outcome);\n// snapshot-accepted-without-second-fetch\n\nconst mismatch = planHydration('entry-r8', snapshot);\nconsole.log(mismatch.diagnostic);\n// { expectedMarkupVersion: 'entry-r8', serializedVersion: 'entry-r7' }
\n

В совпадающей ветке snapshot становится входом для initial state. Второй запрос не стартует только потому, что его запуск запрещает правило, а не потому, что эффект случайно выполнился в нужном порядке. В ветке mismatch код не подменяет старые данные новым ответом. Он сохраняет обе версии и оставляет recovery path следующему слою.

\n
Жизненный цикл SSR и CSR: server request создаёт HTML и snapshot, hydrate сравнивает версии, а client fetch пропускается при совпадении и останавливается при mismatch.
Граница между server request, сериализацией, hydrate и client fetch. Красная ветка означает диагностируемое несовпадение, а не автоматическое обновление.
\n

Диагностика: симптом → причина → проверка → действие

\n
Что делать при наблюдаемом симптоме
СимптомПричинаПроверкаДействие
После hydrate повторяется запрос за той же записьюКлиент создаёт пустой initial state и не читает snapshotСопоставить время HTML, snapshot и первый fetchПередать snapshot в initial state; fetch запускать только после явного условия
Текст меняется сразу после загрузкиHTML и snapshot пришли из разных чтений sourceЗаписать обе версии и входы: user, locale, flagsИсправить общий источник либо остановить переход при mismatch
Hydrate выдаёт предупреждениеКлиент строит другую разметкуСравнить server markup и первый client render без fetchУбрать нестабильное значение из рендера или передать его через snapshot
После «исправления» причина не виднаАвтоматический fetch маскирует расхождениеВременно записать markupVersion, serializedVersion и actionСначала сохранить diagnostic, затем выбрать refresh или сообщение
\n

Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы тоже входят в контракт. Одинаковый entry-r7 не означает одинаковый результат для двух пользователей. Полезный snapshot хранит либо нормализованные входы, либо достаточно данных, чтобы клиент мог проверить их отдельно.

\n

Порядок внедрения

\n
  1. Зафиксируйте симптом в браузере: URL, время первого HTML, повторный запрос, видимое изменение и входы страницы.
  2. Назовите владельца каждого значения: source, HTML, snapshot, client state и следующий fetch. Не называйте их одним словом «кеш».
  3. Выберите версию, которую сервер получает рядом с source. Передайте её в snapshot вместе с payload и схемой формата.
  4. Поставьте сравнение до mutation и до запуска client fetch. Запишите ожидаемую и фактическую версии.
  5. Для совпадения примите snapshot как initial state и проверьте, что повторный fetch не выполняется.
  6. Для mismatch остановите неявное обновление. Отдельно выберите recovery path: повторить чтение, показать ошибку или отрендерить управляемый fallback.
  7. Добавьте тесты на совпадение, mismatch и изменение входа. В каждом тесте проверяйте не только итоговый экран, но и количество запросов.
  8. Проверьте медленную сеть, отключённый JavaScript, устаревший HTML, разные locale и пользователя без права на часть данных.
\n

Отрицательный путь важнее счастливого

\n

Соблазнительный обход — всегда делать client fetch после mount. Он действительно может показать свежую запись, но стирает вопрос о рассинхроне. Причина может быть в кеше HTML, неправильном ключе сериализации, смене пользователя, locale или feature configuration. Автоматический запрос превращает диагностируемый mismatch в незаметную замену состояния.

\n

Другой обход — принять snapshot без проверки. Он экономит запрос, но может показать устаревшие или чужие данные, если документ и payload прошли разные границы кеширования. Третий обход — сравнивать только HTML-строку. Это дорого, хрупко и не объясняет, какие входы породили результат. Сравнивайте версионированный контракт, а не случайный побочный признак.

\n

Ограничения

\n

SSR не делает страницу автоматически быстрой. Сервер может дольше формировать ответ, а сериализованный payload увеличивает документ. CSR остаётся правильным выбором для данных, которые появляются только после действия пользователя, зависят от браузерного API или часто меняются. Не стоит передавать в snapshot секреты, которые нельзя отдавать клиенту. Не стоит принимать данные из snapshot для другого пользователя или другого набора прав.

\n

Версия snapshot не заменяет авторизацию, схему данных и обработку ошибок сети. Она только обозначает связь между двумя фазами одного чтения. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.

\n

Критерий готовности

\n

Решение готово, если на одном документе можно показать четыре значения: версию source, маркер HTML, версию snapshot и действие hydrate. При совпадении тест подтверждает, что snapshot принят, initial state построен из него, а повторный запрос не ушёл. При несовпадении тест подтверждает, что обе версии записаны, mutation не началась и recovery path назван явно. В браузере это видно в сетевом журнале и диагностической записи. Без этих доказательств «SSR без второго запроса» остаётся предположением.

\n

Проверяемые источники

\n" }