Files

8 lines
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 216,
"slug": "editorial-2022-01-practice-ssr-csr",
"title": "SSR и CSR без рассинхронизации: как принять snapshot и не запустить второй запрос",
"excerpt": "HTML уже содержит данные, но после гидрации клиент снова обращается к API и может заменить первый экран. Разбираем контракт snapshot, контекст чтения и явный recovery path для mismatch.",
"contentHtml": "<p>Симптом появляется сразу после загрузки страницы: сервер уже отдал карточку с данными, а после запуска JavaScript браузер повторяет тот же запрос. Иногда ответы совпадают, и ошибка остаётся незаметной. При задержке или изменении записи пользователь видит один текст, затем другой. В DevTools появляются два запроса, а команда не может объяснить, какой из них владел первым экраном. Цена ошибки — лишний сетевой путь, более длинная критическая цепочка и риск тихого рассинхрона между HTML и клиентским состоянием.</p>\n<p>Тезис этой статьи уже, чем общая дискуссия о SSR и CSR: начальное чтение должно иметь одного владельца. Сервер формирует HTML, а приложение может передать рядом сериализованный snapshot. Клиент принимает его только после проверки schema, версии чтения и контекста. При совпадении он пропускает начальный fetch. При несовпадении сначала сохраняет диагностику и не меняет состояние молча. Новый запрос допустим только как явно выбранный recovery path.</p>\n<h2>Граница ответственности: SSR, CSR и hydrate</h2>\n<p>SSR означает, что HTML формируется на сервере; CSR — что HTML строит клиентский JavaScript. Эти подходы можно совмещать: сервер отдаёт содержимое первого экрана, а клиент добавляет интерактивность. В этой статье под SSR подразумевается серверный рендер динамического ответа, а не любой HTML, подготовленный на этапе сборки. Это ограничение важно: способ получения HTML определяет, какие данные можно связать с ним в snapshot.</p>\n<p>Hydrate — граница, на которой клиент присоединяет логику к уже существующей разметке. React требует, чтобы первый клиентский результат совпадал с серверным HTML, и считает расхождения ошибкой. React не обязан исправлять все отличия атрибутов, поэтому автоматический fetch после hydrate не лечит проблему, а может скрыть её. Snapshot и запрос за ним — не встроенное свойство SSR. Это контракт конкретного приложения или фреймворка.</p>\n<h2>Контракт начального чтения</h2>\n<p>Для одного server response достаточно зафиксировать четыре поля: schema формата, версию исходной записи, контекст чтения и payload. Контекст нельзя свести только к версии записи. HTML может зависеть от пользователя, прав, locale, часового пояса или feature flags. Одинаковая версия записи при разных входах не гарантирует одинаковый результат.</p>\n<div class='table-scroll'><table><caption>Кто владеет данными на первом проходе</caption><thead><tr><th scope='col'>Фаза</th><th scope='col'>Владелец</th><th scope='col'>Проверяем</th><th scope='col'>Допустимое действие</th><th scope='col'>Ошибка</th></tr></thead><tbody><tr><td>SSR/source</td><td><code>server-request</code></td><td>Запись, версия и контекст</td><td>Прочитать source и создать HTML-маркер</td><td>HTML не связан с чтением</td></tr><tr><td>Ответ</td><td><code>serialized-snapshot</code></td><td>schema, version, contextKey, payload</td><td>Передать initial state рядом с HTML</td><td>Поле потерялось при сериализации</td></tr><tr><td>Hydrate</td><td><code>browser-transition</code></td><td>Маркер HTML и snapshot</td><td>Сравнить до изменения состояния</td><td>Разные версии приняты молча</td></tr><tr><td>Client fetch</td><td><code>browser-transition</code></td><td>Явное решение после проверки</td><td>Пропустить запрос или начать recovery path</td><td>Fetch запускается на каждый mount</td></tr></tbody></table></div>\n<p><code>contextKey</code> — это не секрет и не замена авторизации. Это стабильный отпечаток входов, которые влияют на представление. Его можно передавать как opaque-строку, например <code>customer-42|ru-RU|recommendations:off</code>. В production-коде значение нужно экранировать при вставке в HTML и не включать в него чувствительные данные.</p>\n<h2>Воспроизводимый пример с проверкой schema</h2>\n<p>Ниже — учебная JavaScript-модель. Она не вызывает React, DOM или сеть: строка <code>markup</code> имитирует HTML-атрибуты, а счётчик запроса представлен полем <code>clientFetch.performed</code>. Модель показывает порядок проверки и не доказывает LCP, latency или поведение конкретного hook.</p>\n<pre><code>const source = {\n id: 'entry-42',\n version: 'entry-r7',\n contextKey: 'customer-42|ru-RU|recommendations:off',\n title: 'Training entry',\n status: 'published',\n};\n\nconst snapshot = JSON.stringify({\n schema: 'ssr-snapshot-v1',\n version: source.version,\n contextKey: source.contextKey,\n payload: {\n id: source.id,\n title: source.title,\n status: source.status,\n },\n});\n\nfunction readMarkupContract(markup) {\n const version = markup.match(/data-snapshot-version=['\"]([^'\"]+)['\"]/)?.[1];\n const contextKey = markup.match(/data-snapshot-context=['\"]([^'\"]+)['\"]/)?.[1];\n\n if (!version || !contextKey) return null;\n return { version, contextKey };\n}\n\nfunction parseSnapshot(serialized) {\n try {\n const parsed = JSON.parse(serialized);\n const valid = parsed.schema === 'ssr-snapshot-v1'\n && typeof parsed.version === 'string'\n && typeof parsed.contextKey === 'string'\n && parsed.payload\n && typeof parsed.payload === 'object';\n\n return valid\n ? { ok: true, value: parsed }\n : { ok: false, diagnostic: 'invalid-snapshot-contract' };\n } catch {\n return { ok: false, diagnostic: 'invalid-snapshot-json' };\n }\n}\n\nfunction planHydration(markup, serializedSnapshot, expectedContextKey) {\n const markupContract = readMarkupContract(markup);\n const snapshotResult = parseSnapshot(serializedSnapshot);\n\n if (!markupContract || !snapshotResult.ok) {\n return {\n outcome: 'recovery-required-before-mutation',\n diagnostic: snapshotResult.ok ? 'missing-markup-contract' : snapshotResult.diagnostic,\n clientFetch: { performed: false, action: 'not-started' },\n };\n }\n\n const parsed = snapshotResult.value;\n const mismatch = markupContract.version !== parsed.version\n || markupContract.contextKey !== parsed.contextKey\n || parsed.contextKey !== expectedContextKey;\n\n if (mismatch) {\n return {\n outcome: 'mismatch-recorded-before-mutation',\n diagnostic: {\n markupVersion: markupContract.version,\n snapshotVersion: parsed.version,\n markupContextKey: markupContract.contextKey,\n snapshotContextKey: parsed.contextKey,\n expectedContextKey,\n },\n clientFetch: { performed: false, action: 'recovery-not-selected' },\n };\n }\n\n return {\n outcome: 'snapshot-accepted-without-second-fetch',\n stateOwner: 'serialized-snapshot',\n initialState: parsed.payload,\n clientFetch: { performed: false, action: 'skipped-contract-matched' },\n };\n}\n\nfunction assert(condition, message) {\n if (!condition) throw new Error(message);\n}\n\nconst markup = 'data-snapshot-version=\"entry-r7\" data-snapshot-context=\"customer-42|ru-RU|recommendations:off\"';\nconst matching = planHydration(markup, snapshot, source.contextKey);\nconsole.log(matching.outcome);\nassert(matching.clientFetch.performed === false, 'matching snapshot triggered a fetch');\n\nconst staleMarkup = 'data-snapshot-version=\"entry-r8\" data-snapshot-context=\"customer-42|ru-RU|recommendations:off\"';\nconst mismatch = planHydration(staleMarkup, snapshot, source.contextKey);\nconsole.log(mismatch.diagnostic);\nassert(mismatch.outcome === 'mismatch-recorded-before-mutation', 'mismatch was not recorded');\nassert(mismatch.clientFetch.performed === false, 'mismatch triggered an implicit fetch');</code></pre>\n<p>В совпадающей ветке snapshot становится входом для initial state только после трёх проверок: маркер HTML совпал с версией snapshot, contextKey совпал между ними, а сам snapshot относится к текущему контексту браузера. Второй запрос не стартует из-за явного результата проверки. В ветке mismatch код сохраняет обе версии, не подменяет старые данные и оставляет recovery path следующему слою.</p>\n<figure><img src='/assets/editorial/2022/ssr-csr-lifecycle-2022.svg' alt='Жизненный цикл SSR и CSR: server request создаёт HTML-маркер и snapshot, hydrate сравнивает версии, а client fetch пропускается при совпадении и останавливается при mismatch.' loading='lazy' /><figcaption>Граница между server request, сериализацией, hydrate и client fetch. Красная ветка означает диагностируемое несовпадение, а не автоматическое обновление.</figcaption></figure>\n<h2>Диагностика: симптом → причина → действие</h2>\n<div class='table-scroll'><table><caption>Что проверять при наблюдаемом симптоме</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>После hydrate повторяется запрос за той же записью</td><td>Клиент создаёт пустой initial state и не читает snapshot</td><td>Сопоставить время HTML, наличие snapshot и первый fetch</td><td>Передать snapshot в initial state; fetch запускать только после явного условия</td></tr><tr><td>Текст меняется сразу после загрузки</td><td>HTML и snapshot пришли из разных чтений source</td><td>Записать version, contextKey, user, locale и flags</td><td>Исправить общий источник либо остановить переход при mismatch</td></tr><tr><td>Hydrate выдаёт предупреждение</td><td>Клиент строит другую разметку</td><td>Сравнить server markup и первый client render без fetch</td><td>Убрать нестабильное значение из рендера или передать его через snapshot</td></tr><tr><td>После «исправления» причина не видна</td><td>Автоматический fetch маскирует расхождение</td><td>Проверить diagnostic и количество запросов</td><td>Сначала сохранить evidence, затем выбрать refresh или сообщение</td></tr></tbody></table></div>\n<p>Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы входят в контракт. Одинаковый <code>entry-r7</code> не означает одинаковый результат для двух пользователей. Snapshot может содержать нормализованный contextKey, но не должен расширять права пользователя и не заменяет серверную авторизацию.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Зафиксируйте симптом в браузере: URL, время первого HTML, повторный запрос, видимое изменение и входы страницы.</li><li>Назовите владельца каждого значения: source, HTML, snapshot, client state и следующий fetch. Не называйте их одним словом «кеш».</li><li>Выберите стабильную версию чтения и contextKey, которые сервер получает рядом с source. Передайте их в snapshot вместе с schema и payload.</li><li>Поставьте проверку schema, маркера HTML, version и contextKey до mutation и до запуска client fetch. Запишите ожидаемые и фактические значения.</li><li>Для совпадения примите snapshot как initial state и проверьте в Network, что повторный fetch не выполняется.</li><li>Для mismatch остановите неявное обновление. Отдельно выберите recovery path: повторить чтение с тем же контекстом, показать ошибку или отрендерить управляемый fallback.</li><li>Добавьте тесты на совпадение, mismatch, пропущенный маркер и некорректный JSON. В каждом тесте проверяйте не только итоговый экран, но и количество запросов.</li><li>Проверьте медленную сеть, отключённый JavaScript, устаревший HTML, разные locale и пользователя без права на часть данных.</li></ol>\n<h2>Отрицательный путь важнее счастливого</h2>\n<p>Клиентский fetch после hydrate сам по себе не ошибка: он подходит для намеренного stale-while-revalidate, если команда принимает смену данных и измеряет её. Ошибка — запускать его без условия, которое объясняет, зачем повторять чтение. Иначе причина может быть в кеше HTML, неправильном ключе сериализации, смене пользователя, locale или feature configuration, а новый ответ превратит диагностируемый mismatch в незаметную замену состояния.</p>\n<p>Другой обход — принять snapshot без проверки. Он экономит запрос, но может показать устаревшие или чужие данные, если документ и payload прошли разные границы кеширования. Третий обход — сравнивать только HTML-строку. Это дорого, хрупко и не объясняет, какие входы породили результат. Сравнивайте версионированный контракт и контекст, а не случайный побочный признак.</p>\n<h2>Ограничения</h2>\n<p>SSR не делает страницу автоматически быстрой. Сервер может дольше формировать ответ, а сериализованный payload увеличивает документ. CSR остаётся правильным выбором для данных, которые появляются только после действия пользователя, зависят от браузерного API или часто меняются. Не стоит передавать в snapshot секреты, которые нельзя отдавать клиенту. Не стоит принимать данные из snapshot для другого пользователя или другого набора прав.</p>\n<p>Версия snapshot не заменяет авторизацию, схему данных и обработку ошибок сети. <code>ETag</code> может участвовать в HTTP-проверке свежести, но для связи HTML с payload лучше иметь явное поле контракта, смысл которого понятен приложению. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.</p>\n<h2>Критерий готовности</h2>\n<p>Решение готово, если на одном документе можно показать пять значений: schema, версию source, маркер HTML, contextKey и действие hydrate. При совпадении тест подтверждает, что snapshot принят, initial state построен из него, а повторный запрос не ушёл. При несовпадении тест подтверждает, что фактические значения записаны, mutation не началась и recovery path назван явно. В браузере это видно в сетевом журнале и диагностической записи. Без этих доказательств «SSR без второго запроса» остаётся предположением.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://react.dev/reference/react-dom/server' target='_blank' rel='noopener noreferrer'>React: Server React DOM APIs</a> — официальная документация описывает серверные API, которые формируют начальный HTML; передачу snapshot приложение организует отдельно.</li><li><a href='https://react.dev/reference/react-dom/client/hydrateRoot' target='_blank' rel='noopener noreferrer'>React: hydrateRoot</a> — официальная документация о присоединении React к серверной разметке, требовании совпадения первого результата и диагностике recoverable errors.</li><li><a href='https://developer.mozilla.org/en-US/docs/Glossary/SSR' target='_blank' rel='noopener noreferrer'>MDN: Server-side rendering (SSR)</a> — официальный глоссарий различает SSR и CSR, отмечает их совместное применение и ограничения интерактивности без JavaScript.</li></ul>"
}