8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 216,
|
||
"slug": "editorial-2022-01-practice-ssr-csr",
|
||
"title": "SSR и CSR без рассинхронизации: как принять snapshot и не запустить второй запрос",
|
||
"excerpt": "Сервер уже показал данные, но клиент снова их загружает и меняет первый экран. Разбираем владельцев состояния, версию snapshot, проверку hydrate и явный путь для mismatch.",
|
||
"contentHtml": "<p>Симптом появляется сразу после загрузки страницы: сервер уже отдал карточку с данными, а после запуска JavaScript браузер повторяет тот же запрос. Иногда ответы совпадают, и ошибка остаётся незаметной. При задержке или изменении записи пользователь видит один текст, затем другой. В DevTools появляются два запроса, а команда не может объяснить, какой из них владел первым экраном. Цена ошибки — лишний сетевой путь, более длинная критическая цепочка и риск тихого рассинхрона между HTML и клиентским состоянием.</p>\n<p>Тезис простой: SSR и CSR не должны конкурировать за начальное состояние. Сервер читает source, создаёт HTML и передаёт рядом сериализованный snapshot. Клиент принимает snapshot только после проверки его версии. При совпадении он пропускает начальный fetch. При несовпадении он сначала фиксирует проблему и останавливает неявную мутацию. Новый запрос допустим только как явно выбранное восстановление.</p>\n<h2>Механизм: четыре фазы и четыре владельца</h2>\n<p>SSR — это серверный запрос и HTML, который браузер получает первым. HTML показывает результат, но сам по себе не говорит клиентскому коду, из какого чтения он появился. Поэтому ответ должен передать и snapshot начального состояния. Snapshot — не общий кеш приложения. Это данные для конкретного server response.</p>\n<p>Hydrate связывает клиентскую логику с уже существующей разметкой. На этой границе нужно сравнить маркер версии HTML и версию snapshot. Версия может быть номером ревизии, ETag, версией набора фильтров или другим значением, которое сервер умеет получить вместе с данными. Нельзя сравнивать только время запроса: два чтения могут иметь одинаковую секунду, но разные права, 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>Payload и версия SSR</td><td>Передать initial state рядом с HTML</td><td>Версия потерялась при сериализации</td></tr><tr><td>Hydrate</td><td><code>browser-transition</code></td><td>Версия разметки и 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<h2>Минимальный пример с версией</h2>\n<p>Ниже — учебная JavaScript-модель. Она работает только с объектами и JSON. В ней нет React, Next.js, DOM, сети, таймера и реального браузерного hydrate. Пример показывает порядок владения данными и помогает написать проверку границы. Он не доказывает LCP, latency или поведение конкретного hook.</p>\n<pre><code>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' }</code></pre>\n<p>В совпадающей ветке snapshot становится входом для initial state. Второй запрос не стартует только потому, что его запуск запрещает правило, а не потому, что эффект случайно выполнился в нужном порядке. В ветке 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>Записать обе версии и входы: 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>Временно записать markupVersion, serializedVersion и action</td><td>Сначала сохранить diagnostic, затем выбрать refresh или сообщение</td></tr></tbody></table></div>\n<p>Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы тоже входят в контракт. Одинаковый <code>entry-r7</code> не означает одинаковый результат для двух пользователей. Полезный snapshot хранит либо нормализованные входы, либо достаточно данных, чтобы клиент мог проверить их отдельно.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Зафиксируйте симптом в браузере: URL, время первого HTML, повторный запрос, видимое изменение и входы страницы.</li><li>Назовите владельца каждого значения: source, HTML, snapshot, client state и следующий fetch. Не называйте их одним словом «кеш».</li><li>Выберите версию, которую сервер получает рядом с source. Передайте её в snapshot вместе с payload и схемой формата.</li><li>Поставьте сравнение до mutation и до запуска client fetch. Запишите ожидаемую и фактическую версии.</li><li>Для совпадения примите snapshot как initial state и проверьте, что повторный fetch не выполняется.</li><li>Для mismatch остановите неявное обновление. Отдельно выберите recovery path: повторить чтение, показать ошибку или отрендерить управляемый fallback.</li><li>Добавьте тесты на совпадение, mismatch и изменение входа. В каждом тесте проверяйте не только итоговый экран, но и количество запросов.</li><li>Проверьте медленную сеть, отключённый JavaScript, устаревший HTML, разные locale и пользователя без права на часть данных.</li></ol>\n<h2>Отрицательный путь важнее счастливого</h2>\n<p>Соблазнительный обход — всегда делать client fetch после mount. Он действительно может показать свежую запись, но стирает вопрос о рассинхроне. Причина может быть в кеше 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 не заменяет авторизацию, схему данных и обработку ошибок сети. Она только обозначает связь между двумя фазами одного чтения. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.</p>\n<h2>Критерий готовности</h2>\n<p>Решение готово, если на одном документе можно показать четыре значения: версию source, маркер HTML, версию snapshot и действие 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> — официальная документация описывает серверный рендер начального HTML и границу server APIs.</li><li><a href='https://react.dev/reference/react-dom/client/hydrateRoot' target='_blank' rel='noopener noreferrer'>React: hydrateRoot</a> — официальная документация о присоединении React к серверной разметке и необходимости согласованного HTML.</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 и их совместное применение.</li></ul>"
|
||
}
|