8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"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>"
|
||
}
|