Откуда берётся HTML: CSR, SSR, Streaming, RSC, Astro
В предыдущих уроках мы собирали приложение, но молча предполагали, что HTML рисуется в браузере: открывается пустая страница, грузится JS, JS дорисовывает интерфейс. Это один из пяти возможных способов. У каждого свои сильные стороны и своя цена, и выбор сильно зависит от того, какую штуку ты строишь. Чат-сервис как Echo, лендинг продукта, документация и панель аналитики просят разные стратегии. Этот урок ставит их рядом и заканчивается простыми правилами выбора.
1. Пять способов накормить браузер
Сравнение начнём с метафоры. Браузеру нужен HTML. Кто его готовит, и в каком виде он приедет, и есть стратегия рендера.
- CSR (Client-Side Rendering), доставка из дома. Сервер шлёт почти пустой HTML-каркас, JS приезжает и собирает интерфейс на месте.
- SSR (Server-Side Rendering), горячий обед на дом. Сервер на каждый запрос рендерит готовый HTML, клиент сразу видит контент, потом догидрирует JS.
- SSG (Static Site Generation), холодный готовый обед. HTML собран один раз на этапе сборки, дальше просто отдаётся CDN. JS приклеивается опционально.
- Streaming SSR, стрим из кухни. Сервер пишет HTML не одним куском, а частями: верх страницы уезжает первым, остальное стримится по мере готовности.
- RSC (React Server Components), кафе с конструктором. Серверные компоненты не попадают в клиентский bundle; в HTML встроена разметка и точки, где живут клиентские островки.
Дальше каждый разберём по очереди и сведём на одном таймлайне.
Что взять с собой
- HTML может готовить клиент (CSR), сервер на каждый запрос (SSR), один раз при сборке (SSG) или стримом (Streaming SSR).
- RSC это серверные компоненты, не попадающие в bundle, плюс клиентские островки.
- Выбор стратегии диктует тип приложения, а не мода на фреймворк.
2. Таймлайн стратегий
Прежде чем читать код, посмотри, как четыре стратегии ведут себя на одной и той же странице. Кликни строку, чтобы сфокусироваться, и обрати внимание на FCP, LCP и TTI.
Три заметки. Во-первых, FCP, LCP и TTI ведут себя по-разному: CSR откладывает все три, SSR ставит контент рано, но TTI ждёт гидрацию, Streaming рисует первый кадр почти сразу. Во-вторых, у RSC гидрация локальная: не вся страница, а только островки. В-третьих, ось это условное время, не миллисекунды; речь о форме графика, не о точных значениях.
Что взять с собой
- CSR смещает FCP и LCP вправо, остальные ставят их рано.
- TTI после первого кадра нужен любому варианту, кроме чистого SSG без JS.
- Чем меньше клиентского JS, тем ближе TTI к FCP.
3. CSR: всё считает клиент
Классический SPA. HTML-каркас в три строки, плюс bundle.js. Браузер качает JS, React монтирует приложение, ленту сообщений запрашивает уже из Echo. Это самый простой пайплайн и самая поздняя отрисовка контента: всё, что видит пользователь, зависит от того, как быстро доехал и распарсился JS.
<!doctype html>
<html>
<body>
<div id="root"></div>
<script type="module" src="/bundle.js"></script>
</body>
</html>
Когда брать: приложения, где первое впечатление не критично, а интерактив интенсивный (Echo и есть такой случай: чат живёт в WebSocket, SEO не нужен). Цена: длинный пустой экран на слабом интернете и без SEO из коробки.
Что взять с собой
- CSR это пустой HTML плюс bundle: всё рисует JS на клиенте.
- Подходит для интерактивных приложений без SEO, плох для лендинга или блога.
- Метрики страдают на слабом соединении, иногда лечится preload плюс skeleton.
4. SSR и hydration
Сначала серверный React выполняет компоненты и строит HTML. Для простого
синхронного примера возьмём
renderToString.
Браузер может показать контент до загрузки React. Затем клиентский код
гидрирует
этот HTML через hydrateRoot. React-обработчики требуют клиентского JS;
обычные ссылки и нативные формы могут работать и без него.
// server.tsx
import { renderToString } from 'react-dom/server';
import { App } from './App';
export function handle(request: Request): Response {
const html = renderToString(<App />);
return new Response(`<!doctype html><div id="root">${html}</div><script src="/bundle.js"></script>`);
}
// client.tsx
import { hydrateRoot } from 'react-dom/client';
import { App } from './App';
hydrateRoot(document.querySelector('#root')!, <App />);
Главная боль SSR называется
hydration mismatch.
Сервер и браузер должны начать с одного снимка данных. Например, время можно
посчитать на сервере один раз и передать клиенту, а не вызывать new Date()
в обоих рендерах. Если значение имеет смысл только в браузере, начни с
одинакового заполнителя и обнови его в useEffect:
// плохо: SSR и клиент посчитают разное время
function Greeting() {
return <p>{new Date().toLocaleTimeString()}</p>;
}
// хорошо: на сервере пусто, на клиенте дорисуется
function Greeting() {
const [time, setTime] = useState('');
useEffect(() => setTime(new Date().toLocaleTimeString()), []);
return <p>{time}</p>;
}
Для заведомо неизбежного расхождения одного узла есть suppressHydrationWarning.
Он подавляет предупреждение только на один уровень, не исправляет дерево,
а React не будет пытаться исправить несовпавший текст этого узла. Это не
замена общему снимку данных и не способ чинить разные ID.
Полный путь от HTTP-ответа до работающей кнопки, безопасную передачу JSON,
useId и диагностику ошибок разберём в отдельном уроке
07-frontend/19 · SSR и гидратация.
Что взять с собой
- SSR готовит HTML на сервере;
hydrateRootподключает к нему клиентский React. - Контент может появиться до загрузки JS, React-обработчики ждут гидратации.
- Начальные данные и дерево должны совпадать. Браузерные отличия добавляют после гидратации; подавление предупреждения не устраняет причину.
5. Streaming SSR (Suspense)
Если обработчик сначала ждёт все данные, а затем строит строку HTML, самый
медленный запрос задерживает всю страницу. renderToString сам не ожидает
приостановленные компоненты. Streaming SSR позволяет отправить готовую
оболочку, а содержимое под <Suspense> дослать по мере готовности.
import { Suspense } from 'react';
export function ChannelPage() {
return (
<>
<Header />
<ChannelMeta />
<Suspense fallback={<MessagesSkeleton />}>
<Messages />
</Suspense>
</>
);
}
На сервере используют renderToReadableStream (или renderToPipeableStream в
Node) вместо renderToString. Ожидающая Suspense-граница оставляет fallback
в оболочке, а готовое содержимое сервер досылает вместе со скриптом вставки.
Источник данных должен поддерживать Suspense: fetch в эффекте не
приостанавливает серверный рендер. React может гидрировать доступные границы
выборочно, не ожидая всего дерева. Ранний показ зависит и от сети, и от буферизации.
Что взять с собой
- Streaming SSR отправляет оболочку и готовые части под
<Suspense>. - Сервер:
renderToReadableStreamилиrenderToPipeableStream; клиент может гидрировать границы выборочно. - Стриминг позволяет раньше показать часть страницы, но не гарантирует мгновенный FCP.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.