8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 214,
|
||
"slug": "editorial-2022-01-field-ssr-csr",
|
||
"title": "SSR и CSR: как разобрать повторный fetch после первого экрана",
|
||
"excerpt": "HTML уже содержит данные, но после hydrate браузер снова обращается к API. Разбираем четыре причины симптома, порядок проверки и безопасный путь для mismatch.",
|
||
"contentHtml": "<p>Проблема заметна в браузере: сервер уже отдал список, пользователь видит первый экран, а сразу после hydrate в Network появляется второй запрос к тому же API. Иногда содержимое меняется, иногда экран мигает, иногда запрос только расходует соединение. Цена ошибки — не один лишний round trip. Команда может принять рассинхронизацию разметки за обычное обновление, скрыть её новым ответом и оставить причину в следующем релизе.</p>\n<p>Один симптом не доказывает одну причину. Initial snapshot мог не попасть в bootstrap. Разметка и состояние могли прийти из разных версий. Компонент мог проигнорировать переданные данные. Наконец, fetch мог быть правильным: пользователь сменил фильтр или явно обновил страницу. Сначала нужно назвать trigger и владельца состояния. Только после этого выбирают действие.</p>\n<h2>Тезис: SSR отдаёт начальное состояние, CSR продолжает работу</h2>\n<p>SSR формирует HTML на сервере. В него обычно попадает представление состояния, которое сервер получил для конкретного route, пользователя, locale и набора флагов. CSR подключает обработчики, восстанавливает состояние и обслуживает следующие действия в браузере. Эти этапы связаны, но не равны.</p>\n<p>Если браузер начинает обычную загрузку до того, как принял initial snapshot, он создаёт вторую операцию вместо продолжения первой. Поэтому вопрос «как убрать fetch» поставлен слишком широко. Правильный вопрос: «какой вход разрешил этот fetch и согласуется ли он с ответом SSR?» Ответ должен быть виден в данных и в порядке переходов, а не только в названии метода.</p>\n<h2>Механизм: четыре версии одного симптома</h2>\n<p>Первая ветка — snapshot отсутствует. Сервер отдал HTML, но bootstrap получил пустое состояние. Hook видит cache miss и запускает обычный запрос. Это дефект передачи initial data.</p>\n<p>Вторая ветка — snapshot есть, но контракт расходится. Сервер создал HTML для <code>entry-r7</code>, а сериализованное состояние или client code ожидает <code>entry-r8</code>. Причиной могут быть разные ответы upstream, cache boundary, locale, cookie, permission или feature flag. Нельзя исправить такой разрыв без выбора источника истины.</p>\n<p>Третья ветка — версии совпадают, но владелец состояния не использует snapshot. Например, effect запускает загрузку при mount без проверки, что данные уже приняты. Здесь запрос лишний, хотя серверный ответ согласован.</p>\n<p>Четвёртая ветка — явное действие пользователя. Новый фильтр, переход по странице, нажатие refresh или подписка на новую ревизию меняют вход. Такой fetch не является ошибкой hydrate. Ему нужны отдельный trigger, новый input и понятный владелец.</p>\n<p>Полезный контракт можно записать так: <code>serverVersion === serializedVersion</code> разрешает принять snapshot; mismatch сначала становится наблюдаемым событием; новый запрос запускается только после принятия snapshot или после named user trigger. Это проектное правило. React задаёт требования к согласованной разметке, но не знает вашу схему версий и не решает, кто владеет бизнес-состоянием.</p>\n<h2>Минимальный пример сравнения</h2>\n<p>Ниже — учебная функция. Она не запускает React, не читает DOM и не отправляет HTTP-запрос. Функция показывает только порядок: сначала разобрать snapshot, затем сравнить версии, затем выбрать действие. Значения <code>entry-r7</code> и <code>entry-r8</code> придуманы для примера.</p>\n<pre><code>function decideHydration({ markupVersion, snapshot, trigger }) {\n if (!snapshot) {\n return { action: 'record-missing-snapshot', fetch: false };\n }\n\n if (markupVersion !== snapshot.version) {\n return {\n action: 'record-mismatch-before-mutation',\n expected: markupVersion,\n received: snapshot.version,\n fetch: false,\n };\n }\n\n if (trigger === 'user-refresh' || trigger === 'filter-change') {\n return { action: 'start-explicit-refresh', fetch: true };\n }\n\n return { action: 'accept-snapshot', fetch: false };\n}\n\nconst result = decideHydration({\n markupVersion: 'entry-r7',\n snapshot: { version: 'entry-r7', items: ['A'] },\n trigger: 'mount',\n});\n\nconsole.log(result);\n// { action: 'accept-snapshot', fetch: false }</code></pre>\n<p>В mismatch-пути функция не пытается угадать свежий ответ. Она возвращает две версии и останавливается до изменения client state. Это не готовый recovery UX. Приложение должно отдельно решить, показать ли сообщение, повторить чтение по безопасному правилу или передать случай владельцу данных. Важен порядок: диагностика не должна исчезнуть внутри общего <code>loading</code>.</p>\n<div class='table-scroll'><table><caption>Диагностика повторного client fetch</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>HTML есть, snapshot отсутствует</td><td>Initial state не передали в bootstrap</td><td>Найти сериализованный payload и проверить его наличие до mount</td><td>Передать один именованный snapshot; повторить сценарий</td></tr><tr><td>Markup и payload несут разные версии</td><td>SSR и client получили разные входы или ответы</td><td>Сохранить обе версии, route, locale, cookie и флаги до mutation</td><td>Остановить неявное обновление; выбрать recovery path</td></tr><tr><td>Версии совпадают, fetch идёт на mount</td><td>Hook игнорирует initial state</td><td>Посмотреть owner состояния и условие запуска effect</td><td>Принять snapshot до запроса; оставить fetch только для нового trigger</td></tr><tr><td>Fetch следует за filter-change</td><td>Пользователь изменил input</td><td>Связать запрос с событием и новым параметром</td><td>Оставить запрос, но отделить его от hydration</td></tr><tr><td>Контент меняется без действия</td><td>Гонка snapshot и client response или скрытый refresh</td><td>Сопоставить timestamps, operation id и источник каждого ответа</td><td>Защитить порядок применения; не считать последний ответ автоматически верным</td></tr></tbody></table></div>\n<figure><img src='/assets/editorial/2022/ssr-csr-diagnosis-2022.svg' alt='Диагностика повторного client fetch: проверка snapshot и версии ведёт к принятию состояния, записи mismatch или отдельному пользовательскому обновлению.' loading='lazy' /><figcaption>Сначала определяется источник запроса. Совпавший snapshot принимается без второго fetch; mismatch фиксируется до mutation; явное действие пользователя создаёт отдельную операцию.</figcaption></figure>\n<h2>Что собрать до изменения кода</h2>\n<p>Начните с одного document response. Запишите route input, identity контекста, locale, source version, marker в HTML, serialized version, имя bootstrap и trigger запроса. Если значения нет, так и отметьте. Не подставляйте выдуманные LCP, время ответа или production-результат. Для этой диагностики важнее причинная цепочка, чем красивый waterfall.</p>\n<p>Проверьте, что marker и snapshot описывают один источник. Версия без связи с данными мало полезна: она может оставаться одинаковой при разных permission или feature flag. Если эти входы меняют HTML, включите их в контракт или разделите route variant. Если вход не должен влиять на SSR, не позволяйте ему незаметно менять state после hydrate.</p>\n<p>Затем найдите место, где создаётся запрос. Смотрите не только на URL. Нужны условие запуска, текущий state, operation id и причина перехода. Если effect вызывает fetch на каждый mount, он должен сначала проверить accepted snapshot. Если запрос приходит от пользовательского события, передайте это событие явно, а не выводите trigger из факта, что компонент уже смонтирован.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте симптом: какой HTML виден, когда появляется запрос, меняется ли содержимое и было ли действие пользователя.</li><li>Найдите initial snapshot в границе bootstrap. Проверьте, что он относится к тому же route и документу, что и HTML.</li><li>Сравните source version, markup marker и serialized version до любого изменения client state.</li><li>Если snapshot отсутствует, исправьте передачу данных и добавьте проверку, которая ловит пустой bootstrap.</li><li>Если версии различаются, сохраните expected и received, остановите неявный fetch и назначьте владельца recovery. Не закрывайте mismatch свежим ответом.</li><li>Если версии совпадают, проверьте effect и cache owner. Принятие snapshot должно предшествовать запросу на mount.</li><li>Если запрос вызвал пользовательский input, сохраните trigger и новый параметр. Такой запрос проверяйте как отдельную операцию.</li><li>Проверьте отрицательный путь: разный locale, cookie, permission, feature flag, медленный ответ и повторный клик не должны приводить к бесконтрольной гонке.</li><li>Сравните до и после число запросов, порядок применения ответа и состояние при mismatch. Считайте исправление завершённым только после проверки критерия готовности.</li></ol>\n<h2>Когда второй fetch уместен</h2>\n<p>Повторная загрузка уместна, если появилась новая причина. Пользователь сменил фильтр. Истёк явно заданный freshness window. Подписка сообщила о новой ревизии. UI вошёл в режим, которого не было в SSR. В каждом случае сохраняйте новый input и отдельную operation id. Initial snapshot остаётся состоянием первого ответа, а client fetch становится следующей операцией.</p>\n<p>Не отключайте SSR только потому, что не нашли владельца второго запроса. Не добавляйте <code>setTimeout</code>, чтобы «дать hydrate закончить». Не подавляйте warning без доказательства, что разметка безопасно различается. Такие меры меняют время симптома, но не объясняют, какой источник сформировал первый экран.</p>\n<h2>Ограничения</h2>\n<p>Сравнение строк версий не доказывает равенство всего HTML. Разметка может расходиться из-за timezone, случайных значений, даты, порядка элементов, разных прав или ответа внешней системы. Для каждого входа нужно решить, входит ли он в initial contract. Если нет, перенесите зависимый фрагмент в контролируемую клиентскую ветку.</p>\n<p>Учебный код не является React-компонентом и не заменяет browser test. Он не моделирует DOM mutation, concurrent rendering, cache headers, сеть, retry и восстановление после ошибки. Реальный тест должен проверить конкретный renderer, сериализацию и пользовательский сценарий. Нельзя объявлять production-эффектом то, что показала только эта функция.</p>\n<p>Материал ограничен историческим API React 17, доступным на январь 2022 года. Современные API и фреймворки могут менять детали bootstrap и hydration. Общий принцип остаётся проверяемым: сначала согласовать initial contract, затем применить snapshot, затем запускать новый запрос по названной причине.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Разбор готов, если для каждого повторного fetch можно назвать trigger, owner, source version и serialized version. Тест показывает, что совпавший snapshot не создаёт второй запрос на mount, отсутствующий snapshot остаётся видимым дефектом, mismatch фиксируется до mutation, а filter-change создаёт отдельный запрос с новым input. Если команда может сказать только «браузер обновил данные», причина ещё не установлена.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://github.com/reactjs/react.dev/blob/b41b1dc35679c01c3252e7d512ce28c5e100d0a4/content/docs/reference-react-dom.md#hydrate' target='_blank' rel='noopener noreferrer'>React documentation: ReactDOM.hydrate</a> — официальный снимок документации от 23 декабря 2021 года; описывает присоединение к существующей разметке и требование согласованного rendered content.</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'>React documentation: ReactDOMServer.renderToString</a> — официальный снимок документации от 23 декабря 2021 года; описывает получение HTML на сервере и не задаёт вашу схему initial snapshot.</li><li><a href='https://github.com/react/react/releases/tag/v17.0.2' target='_blank' rel='noopener noreferrer'>React 17.0.2 release record</a> — официальная запись релиза от 22 марта 2021 года; фиксирует историческую границу версии, используемой в примере.</li></ul>"
|
||
}
|