{ "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. При несовпадении он сначала фиксирует проблему и останавливает неявную мутацию. Новый запрос допустим только как явно выбранное восстановление.
\nSSR — это серверный запрос и HTML, который браузер получает первым. HTML показывает результат, но сам по себе не говорит клиентскому коду, из какого чтения он появился. Поэтому ответ должен передать и snapshot начального состояния. Snapshot — не общий кеш приложения. Это данные для конкретного server response.
\nHydrate связывает клиентскую логику с уже существующей разметкой. На этой границе нужно сравнить маркер версии HTML и версию snapshot. Версия может быть номером ревизии, ETag, версией набора фильтров или другим значением, которое сервер умеет получить вместе с данными. Нельзя сравнивать только время запроса: два чтения могут иметь одинаковую секунду, но разные права, locale или feature flags.
\n| Фаза | Владелец | Вход | Допустимое действие | Ошибка |
|---|---|---|---|---|
| SSR/source | server-request | Запись и её версия | Прочитать source и создать HTML | HTML без понятной версии |
| Ответ | serialized-snapshot | Payload и версия SSR | Передать initial state рядом с HTML | Версия потерялась при сериализации |
| Hydrate | browser-transition | Версия разметки и snapshot | Сравнить до изменения состояния | Разные версии приняты молча |
| Client fetch | browser-transition | Явное решение после проверки | Пропустить запрос или начать recovery path | Fetch запускается на каждый mount |
Ниже — учебная JavaScript-модель. Она работает только с объектами и JSON. В ней нет React, Next.js, DOM, сети, таймера и реального браузерного hydrate. Пример показывает порядок владения данными и помогает написать проверку границы. Он не доказывает LCP, latency или поведение конкретного hook.
\nconst 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После 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 или сообщение |
Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы тоже входят в контракт. Одинаковый entry-r7 не означает одинаковый результат для двух пользователей. Полезный snapshot хранит либо нормализованные входы, либо достаточно данных, чтобы клиент мог проверить их отдельно.
Соблазнительный обход — всегда делать client fetch после mount. Он действительно может показать свежую запись, но стирает вопрос о рассинхроне. Причина может быть в кеше HTML, неправильном ключе сериализации, смене пользователя, locale или feature configuration. Автоматический запрос превращает диагностируемый mismatch в незаметную замену состояния.
\nДругой обход — принять snapshot без проверки. Он экономит запрос, но может показать устаревшие или чужие данные, если документ и payload прошли разные границы кеширования. Третий обход — сравнивать только HTML-строку. Это дорого, хрупко и не объясняет, какие входы породили результат. Сравнивайте версионированный контракт, а не случайный побочный признак.
\nSSR не делает страницу автоматически быстрой. Сервер может дольше формировать ответ, а сериализованный payload увеличивает документ. CSR остаётся правильным выбором для данных, которые появляются только после действия пользователя, зависят от браузерного API или часто меняются. Не стоит передавать в snapshot секреты, которые нельзя отдавать клиенту. Не стоит принимать данные из snapshot для другого пользователя или другого набора прав.
\nВерсия snapshot не заменяет авторизацию, схему данных и обработку ошибок сети. Она только обозначает связь между двумя фазами одного чтения. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.
\nРешение готово, если на одном документе можно показать четыре значения: версию source, маркер HTML, версию snapshot и действие hydrate. При совпадении тест подтверждает, что snapshot принят, initial state построен из него, а повторный запрос не ушёл. При несовпадении тест подтверждает, что обе версии записаны, mutation не началась и recovery path назван явно. В браузере это видно в сетевом журнале и диагностической записи. Без этих доказательств «SSR без второго запроса» остаётся предположением.
\n