8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 215,
|
||
"slug": "editorial-2022-01-mechanism-ssr-csr",
|
||
"title": "SSR и CSR без двойного состояния: как сохранить границу данных",
|
||
"excerpt": "Первый экран уже содержит данные, но после гидрации браузер запрашивает их снова и может заменить интерфейс. Разбираем владельцев source, HTML, snapshot и client state, а затем проверяем mismatch до любого автоматического восстановления.",
|
||
"contentHtml": "<p>Пользователь открывает страницу. Сервер отдаёт HTML с карточкой товара и ценой. Через мгновение JavaScript запускает ещё один запрос. Цена меняется, кнопка на секунду исчезает, а в Network появляются два ответа. Иногда это выглядит как нормальная гидрация. Иногда второй ответ приходит из другого кеша или с другим правом доступа и подменяет первый экран.</p>\n<p>Ошибка стоит дороже одного запроса. Команда теряет доверие к SSR, увеличивает время до стабильного интерфейса и начинает чинить симптом: отключает серверный рендер, ставит таймер или всегда делает refetch. После этого причина рассинхронизации остаётся. Следующий баг возникает на другом locale, cookie или feature flag.</p>\n<p><strong>Тезис:</strong> HTML, сериализованный snapshot и состояние клиента — разные артефакты. У каждого должен быть владелец, версия и момент жизни. Hydrate принимает snapshot как initial state только после проверки контракта. Новый client fetch начинается только из отдельного условия. Если версии не совпали, сначала фиксируем mismatch. Не маскируем его свежим ответом.</p>\n<h2>Что происходит между сервером и браузером</h2>\n<p>Server request читает source. Это может быть база, API или серверный кеш. Renderer использует результат и строит HTML. Сам HTML показывает элементы, но не обязан содержать всю информацию, которая нужна клиентскому коду для продолжения работы. Поэтому приложение передаёт рядом сериализованный snapshot.</p>\n<p>Snapshot — не общий кеш приложения. Это переносимое начальное состояние для конкретного document response. Он должен быть связан с тем же контекстом, который сформировал HTML: route, locale, пользователь, права, feature flags и версия данных. Если эти входы влияют на разметку, их нельзя считать случайными деталями.</p>\n<p>Hydrate присоединяет логику к уже существующей разметке. В React это не второй независимый SSR. Клиентское дерево должно давать тот же первоначальный вывод, что и серверное. В противном случае браузер начинает исправлять несовпадение или сообщает о нём, а команда получает неясный переход между состояниями.</p>\n<p>После принятия snapshot владельцем initial state становится клиентское приложение. Это не означает, что snapshot владеет всей будущей историей данных. Он объясняет только первый экран. Следующий запрос должен иметь новый input и отдельную причину: действие пользователя, истёкший freshness window, подписка или согласованный recovery path.</p>\n<h2>Минимальный контракт</h2>\n<p>Назовём версию <code>entry-r7</code>. Сервер читает запись, использует её для HTML и кладёт ту же версию в snapshot. Bootstrap сравнивает marker разметки и версию snapshot до создания нового client state. В учебном примере ниже нет React, DOM и сети. Код показывает порядок переходов, а не готовую библиотеку.</p>\n<pre><code>const serverEnvelope = {\n source: { version: 'entry-r7', data: { price: 100 } },\n markupVersion: 'entry-r7',\n serializedSnapshot: JSON.stringify({\n schema: 1,\n version: 'entry-r7',\n data: { price: 100 }\n })\n};\n\nconst snapshot = JSON.parse(serverEnvelope.serializedSnapshot);\nconst sameVersion = serverEnvelope.source.version === serverEnvelope.markupVersion\n && serverEnvelope.markupVersion === snapshot.version;\n\nif (sameVersion) {\n clientState = snapshot.data;\n // Не запускаем fetch только из-за mount.\n} else {\n diagnostic = {\n kind: 'hydration-mismatch',\n expected: serverEnvelope.markupVersion,\n received: snapshot.version\n };\n // Recovery выбирается отдельно после записи причины.\n}</code></pre>\n<p>Важна не строка сравнения сама по себе. Важен момент, когда она выполняется. Если hook сначала создаёт пустой store, а затем effect читает snapshot, fetch уже получил право изменить экран. Guard оказался слишком поздним. Правильная граница находится до начальной мутации состояния.</p>\n<p>Версия также не должна быть декоративным timestamp. Если сервер формирует HTML из записи пользователя A, а snapshot берётся из общего кеша пользователя B, одинаковая строка времени не исправит контракт. Версия должна отвечать на вопрос: из какого чтения и какого контекста появились оба представления?</p>\n<h2>Владелец и срок жизни артефактов</h2>\n<div class=\"table-scroll\"><table><caption>Переход данных от SSR к CSR</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>source</td><td>server request</td><td>renderer</td><td>до формирования ответа</td><td>считать source доступным браузеру</td></tr><tr><td>HTML</td><td>server renderer</td><td>пользователь и hydrate</td><td>до изменения DOM</td><td>считать HTML полным client state</td></tr><tr><td>serialized snapshot</td><td>server response</td><td>bootstrap</td><td>до принятия initial state</td><td>считать его общим кешем всех страниц</td></tr><tr><td>client state</td><td>hydrate</td><td>интерактивный UI</td><td>по правилам приложения</td><td>создать пустым без проверки snapshot</td></tr><tr><td>client fetch</td><td>явный client trigger</td><td>браузер</td><td>новая операция</td><td>запускать на каждый mount</td></tr></tbody></table></div>\n<p>Таблица нужна для расследования. Если невозможно назвать владельца значения, нельзя надёжно объяснить, почему оно изменилось. Если неизвестен срок жизни, нельзя отличить устаревший snapshot от нового состояния. Эти два вопроса полезнее общего флага <code>loading</code>.</p>\n<figure><img src=\"/assets/editorial/2022/ssr-csr-state-ownership-2022.svg\" alt=\"Переход source через HTML и serialized snapshot к hydrate и client state; mismatch останавливается до нового client fetch\" loading=\"lazy\" /><figcaption>Граница данных: сервер передаёт HTML и snapshot из одного чтения, а браузер принимает snapshot только после проверки версии. Стрелка к client fetch обозначает новую операцию, а не продолжение server request.</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>Bootstrap не получил initial snapshot или hook его игнорирует</td><td>Найти serialized version и условие запуска fetch</td><td>Передать named snapshot и пропустить fetch при принятой версии</td></tr><tr><td>Первый экран меняется без действия пользователя</td><td>HTML и snapshot созданы из разных чтений</td><td>Сравнить source version, markup marker и snapshot version</td><td>Остановить скрытую мутацию, записать mismatch и выбрать recovery path</td></tr><tr><td>Есть warning о hydration mismatch</td><td>Разные данные, locale, timezone, browser API или markup</td><td>Сравнить server input и первый client render</td><td>Сделать вывод детерминированным или перенести различие после согласованного первого прохода</td></tr><tr><td>Fetch следует после фильтра</td><td>Пользователь создал новый input</td><td>Связать запрос с событием и параметрами фильтра</td><td>Оставить fetch, но не называть его исправлением гидрации</td></tr></tbody></table></div>\n<p>Один и тот же URL в Network не объясняет три первые строки. Для расследования сохраняйте порядок: какой ответ пришёл, какая версия была в markup, какую версию прочитал bootstrap и что запустило fetch. Без trigger второй запрос нельзя классифицировать.</p>\n<h2>Mismatch и отрицательный путь</h2>\n<p>Представим, что HTML помечен как <code>entry-r8</code>, а snapshot содержит <code>entry-r7</code>. Без guard приложение может принять snapshot, затем получить свежий ответ и показать его как исправление. Такой путь убирает свидетельство. Мы уже не знаем, почему HTML и данные разошлись: сработал кеш документа, сменился пользователь, неправильно собрался ключ или разные сервисы прочитали source в разные моменты.</p>\n<p>Отрицательный путь должен быть заметен. Сначала создаём diagnostic с двумя версиями. Затем не запускаем markup mutation, client state mutation и автоматический fetch. После записи выбираем recovery. Это может быть повторный запрос с тем же контекстом, показ ошибки или контролируемый client-only переход. Выбор зависит от продукта. Нельзя выдавать его за универсальное правило React.</p>\n<p>Для отсутствующего snapshot причина другая. Тут нечего сравнивать. Следует исправить передачу initial state или явно объявить страницу client-rendered. Для совпавшего snapshot повторный запрос тоже не всегда ошибка: freshness policy может требовать обновления. Но тогда policy должна быть названа, измерима и отделена от hydrate.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте один document response: route, locale, identity-контекст, permissions, feature flags и время формирования.</li><li>Запишите source version, marker в HTML, serialized snapshot version и место, где bootstrap создаёт client state.</li><li>Разделите причины повторного запроса: отсутствует snapshot, версии расходятся, hook игнорирует snapshot или пользователь создал новый input.</li><li>Поставьте сравнение версий до первой client state mutation. При совпадении используйте snapshot как initial state.</li><li>При mismatch сохраните обе версии и trigger. Запретите неявный fetch до выбора отдельного recovery path.</li><li>Проверьте первый client render на том же наборе данных, что и server render. Уберите nondeterministic output из общего прохода.</li><li>Добавьте browser-тест для совпадения разметки и отдельный тест для отрицательного пути. Учебная in-memory проверка не заменяет эти тесты.</li></ol>\n<h2>Ограничения модели</h2>\n<p>В примере версия и JSON придуманы для обучения. Они не являются production-данными. Fixture не запускает React, не открывает браузер, не измеряет latency и не подтверждает результат конкретного framework. Её задача — закрепить порядок: принять snapshot при совпадении и оставить mismatch видимым до mutation.</p>\n<p>Официальная документация React описывает серверный рендер HTML и гидрацию существующей разметки. Она не задаёт поля <code>source</code>, <code>markupVersion</code> или политику client fetch. Это проектный контракт. Не следует ссылаться на документацию React как на доказательство того, что конкретный кеш, serializer или recovery path безопасен.</p>\n<p>Сервер и клиент могут честно получить разные входы. Cookie, locale, timezone, permission set, random value, текущая дата и feature flag часто меняют вывод. Если различие неизбежно, первый render должен оставаться согласованным, а клиентская часть может изменить интерфейс после hydrate отдельным шагом. Такой двухпроходный путь добавляет работу и может быть заметен на медленной сети. Он не отменяет проверку.</p>\n<p>Не лечите mismatch через случайный <code>setTimeout</code>, отключение SSR или бездумный <code>suppressHydrationWarning</code>. Эти меры могут скрыть сообщение, но не назначают владельца данных и не объясняют цену расхождения. Сначала сохраните факты. Потом выберите минимальное обратимое изменение.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Решение готово, когда для одного документированного сценария можно показать четыре связанных факта: source и HTML созданы из согласованного контекста; snapshot содержит ту же версию; первый client render не меняет содержимое сам по себе; любой последующий fetch имеет названный trigger и новый input. Для mismatch есть отдельный наблюдаемый результат, а не тихий fallback.</p>\n<p>Проверка должна проходить в двух ветках. В matching case Network не содержит автоматического запроса только из-за mount, а initial state берётся из snapshot. В mismatch case система сохраняет expected и received, не выполняет mutation до recovery и позволяет найти владельца следующего действия. Только после этого можно оценивать UX, кеширование и производительность конкретного приложения.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://github.com/reactjs/react.dev/blob/b41b1dc35679c01c3252e7d512ce28c5e100d0a4/content/docs/reference-react-dom.md#hydrate\" target=\"_blank\" rel=\"noopener noreferrer\">ReactDOM.hydrate: исторический снимок документации</a> — официальное описание гидрации существующей server-rendered разметки и требования согласованного server/client content; схема version из этой статьи в API не задана.</li><li><a href=\"https://github.com/reactjs/react.dev/blob/b41b1dc35679c01c3252e7d512ce28c5e100d0a4/content/docs/reference-react-dom-server.md#rendertostring\" target=\"_blank\" rel=\"noopener noreferrer\">ReactDOMServer.renderToString: исторический снимок документации</a> — официальное описание получения initial HTML на сервере; поля source, markupVersion и client fetch в API не заданы.</li><li><a href=\"https://github.com/react/react/releases/tag/v17.0.2\" target=\"_blank\" rel=\"noopener noreferrer\">React 17.0.2: versioned release record</a> — официальная запись релиза от марта 2021 года; она фиксирует историческую границу материала и не является источником для учебного version protocol.</li></ul>"
|
||
}
|