Перейти к содержанию

Рендеринг веб-страниц

Рендеринг - сборка HTML страницы из шаблона или компонентов и данных. Его делают на сервере на каждый запрос, заранее при сборке или в браузере из JS-бандла. От этого зависит, что пользователь и поисковик увидят в первом ответе.

SSR (server-side rendering) - HTML первого ответа собран на сервере до того, как браузер выполнит клиентский JS. Кроме шаблонизаторов вроде Thymeleaf и Jinja и фреймворков поверх React, Vue и Angular есть и другие способы: компоненты рендерят через API самих UI-фреймворков, страницы собирают заранее (SSG), режимы смешивают в одном приложении, HTML отдают потоком. Статические страницы - отдельный случай (см. Что не является SSR).

Note

В узком смысле SSR - рендер на каждый запрос (runtime SSR), а HTML, собранный при сборке, называют SSG. Слово «prerendering» в доках значит разное. В Angular это сборка HTML при ng build, то есть SSG. В Next.js (Pages Router) это общее название SSG и SSR: оба готовят HTML до браузера. В App Router вместо него говорят static и dynamic rendering.


CSR и hydration

CSR (client-side rendering) - сервер отдаёт почти пустой HTML и JS-бандл, страницу собирает браузер.

<body>
<div id="root"></div>  <!-- сюда React нарисует приложение -->
<script type="module" src="/assets/index-B7f3a.js"></script>
</body>
  • Так работает классическое SPA: React или Vue на Vite, Angular без SSR.
  • Сервер раздаёт статику и JSON API. Данные страница запрашивает отдельно, уже после загрузки.
  • Пока бандл не загрузился и не выполнился, пользователь видит пустую страницу или спиннер.
  • Поисковики и превью ссылок в мессенджерах получают пустой <div id="root">. Google выполняет JS, но позже и не всегда. Большинство ботов превью JS не выполняют.

Диагностика: кто рендерил страницу, видно по HTML первого ответа.

# HTML первого ответа, как его видит поисковик
curl -s https://example.com/ | head -50

Пустой <div id="root"></div> - CSR, текст страницы в разметке - SSR или SSG. В браузере HTML ответа показывает View Source (Cmd+Option+U), а вкладка Elements в DevTools - DOM после работы JS. Второй способ: выключить JavaScript (DevTools -> Cmd+Shift+P -> Disable JavaScript). SSR-страница видна, но кнопки не работают, CSR-страница пустая.


Hydration (гидратация) - клиентский JS «оживляет» HTML, пришедший с сервера: строит то же дерево компонентов, сопоставляет его с готовым DOM и вешает обработчики событий, не пересоздавая разметку.

  • Нужна, когда HTML отрендерил компонентный фреймворк (SSR, SSG), а страница должна стать интерактивной.
  • Пока JS не загрузился и не выполнился, страница видна, но клики не работают.
  • Клиент всё равно грузит JS всех компонентов страницы. SSR ускоряет первый показ, но не уменьшает бандл.
  • Если клиент отрендерил не то, что сервер (текущее время, Math.random(), window в рендере), это hydration mismatch. React пишет ошибку в консоль и перерисовывает расхождение на клиенте.

Итого: SSR даёт быстрый первый показ, hydration - интерактивность. Между ними страница видна, но не реагирует.


Серверные шаблоны

Сервер берёт шаблон с плейсхолдерами, подставляет данные и отдаёт готовый HTML. Рендер на каждый запрос, JS-фреймворк на клиенте не нужен. Самый старый вид SSR: так работали CGI-скрипты, PHP и JSP задолго до SPA.

Шаблонизатор Язык и фреймворк
Thymeleaf Java, Spring MVC
FreeMarker Java
JSP Java, Jakarta EE. Legacy
Jinja Python, Flask
Django templates Python, Django
html/template Go, стандартная библиотека
ERB Ruby, Rails
Twig PHP, Symfony
Razor C#, ASP.NET Core
<!-- users/list.html: Thymeleaf подставит users из Model -->
<ul>
    <li th:each="user : ${users}" th:text="${user.name}">Имя</li>
</ul>

В Spring MVC это @Controller, который кладёт данные в Model и возвращает имя шаблона (см. View и ViewResolver).

  • Hydration нет: на клиенте нет копии шаблона, перерисовать кусок страницы без запроса к серверу нельзя.
  • Интерактивность - отдельный JS поверх готового HTML или HTML-over-the-wire: HTMX, Hotwire Turbo. Сервер отвечает на AJAX-запрос куском HTML, библиотека вставляет его в страницу.

Когда брать: админки, CRUD, внутренние системы, проекты, где бэкенд-команда работает без фронтендеров.


Рендер компонентов на сервере

SSR через API фреймворка - те же компоненты React, Vue или Angular, что работают в браузере, рендерятся в HTML-строку на сервере, обычно в Node.js. Next.js и Nuxt для этого не обязательны: серверный API есть у самих фреймворков.

Фреймворк Сервер: компоненты -> HTML Клиент: hydration
React renderToString, renderToPipeableStream из react-dom/server hydrateRoot из react-dom/client
Vue 3 renderToString из vue/server-renderer createSSRApp(App).mount('#app')
Angular Пакет @angular/ssr (до v17 - Angular Universal), ставится ng add @angular/ssr provideClientHydration()
// server.js: Express отдаёт HTML, собранный из компонентов
import {renderToString} from 'react-dom/server';

app.get('*', (req, res) => {
    const html = renderToString(<App url={req.url}/>);
    res.send(`<div id="root">${html}</div><script src="/client.js"></script>`);
});

// client.js: тот же <App /> оживляет готовый HTML
import {hydrateRoot} from 'react-dom/client';

hydrateRoot(document.getElementById('root'), <App url={location.pathname}/>);
  • react-dom - рендерер React для браузера, в нём же лежат hydration и серверный API.
  • Роутинг, загрузку данных до рендера, передачу данных на клиент и сборку двух бандлов (серверного и клиентского) пишешь сам.

Когда брать: если SSR встраивают в свой Node-сервер или нужен полный контроль. В остальных случаях берут мета-фреймворк.


Мета-фреймворк - фреймворк поверх UI-фреймворка. Из коробки даёт роутинг по файлам, загрузку данных на сервере, SSR, SSG и hydration. Режим рендеринга выбирается для каждой страницы.

UI-фреймворк Мета-фреймворк
React Next.js, React Router v7 (бывший Remix)
Vue Nuxt
Svelte SvelteKit
Solid SolidStart
Angular Analog

Note

Angular SSR - не отдельный мета-фреймворк, а официальный пакет @angular/ssr в Angular CLI. Он закрывает то же, что Next.js для React: SSR, prerendering, hybrid rendering.

Путь запроса:

  1. Запрос приходит на Node-сервер фреймворка.
  2. Сервер загружает данные страницы: getServerSideProps в Next.js Pages Router, async-компонент в App Router, useFetch в Nuxt.
  3. Рендерит компоненты в HTML и встраивает данные в страницу JSON-ом в <script>, чтобы клиент не запрашивал их повторно.
  4. Браузер показывает HTML, грузит JS и делает hydration.
  5. Дальше переходы как в SPA, без полной перезагрузки. За следующей страницей клиент ходит к серверу за данными, а не за целым HTML.

Когда брать: публичные страницы, где важны SEO и быстрый первый показ, а интерактивности много: магазины, медиа, маркетплейсы.


Рендер при сборке

SSG (static site generation) - HTML всех страниц собирается заранее, при сборке, и лежит готовыми файлами. Его отдаёт nginx или CDN как статику, рендера на запрос нет. Angular называет это prerendering.

  • Пользователь сразу получает HTML, как при SSR. Разница в том, что рендер один раз, а не на каждый запрос.
  • Данные свежие на момент сборки. Изменились - пересобирай.
  • Компонентные SSG (Next.js, Nuxt) после загрузки делают hydration. Чистые генераторы (Hugo, MkDocs) отдают просто HTML.

Примеры: MkDocs (этот конспект: properdocs build складывает HTML в site/), Hugo, Jekyll, Eleventy, Docusaurus, VitePress, Astro, Next.js (output: 'export'), Nuxt (nuxt generate), Angular (prerender при ng build).

Когда брать: документация, блоги, лендинги. Контент меняется редко и одинаков для всех.


ISR (Incremental Static Regeneration) - SSG с пересборкой отдельных страниц на работающем сервере. Страница отдаётся из кеша и пересобирается в фоне раз в N секунд или по событию. Пока идёт пересборка, отдаётся старая версия (stale-while-revalidate).

// Next.js App Router: страница пересобирается не чаще раза в 60 с
export const revalidate = 60;
  • По событию: revalidatePath('/blog') после правки в CMS.
  • Термин из Next.js. В Nuxt похожее задаётся в routeRules опциями isr и swr.

Когда брать: страниц много или контент меняется чаще, чем удобно пересобирать весь сайт: каталог товаров, новости.


Streaming SSR

Сервер отправляет HTML кусками по мере готовности, а не целиком в конце: сначала каркас страницы, потом блоки, которые ждали медленные данные. В React 18+ это renderToPipeableStream (Node.js) и renderToReadableStream (Web Streams), в Next.js App Router - loading.js и <Suspense>.

  1. Сервер сразу отдаёт shell: всё, что вне <Suspense>, а на месте медленных блоков - fallback, например спиннер.
  2. Соединение остаётся открытым. Когда данные блока готовы, сервер дописывает в тот же ответ его HTML и маленький <script>, который ставит этот HTML на место fallback.
  3. Hydration тоже идёт по частям (selective hydration): блок оживает, как только пришли его HTML и JS. Блок, по которому кликнули, гидрируется первым.
<Layout>
    <Product/> {/* в shell, приходит сразу */}
    <Suspense fallback={<Spinner/>}>
        <Reviews/> {/* медленный запрос, придёт следующим куском */}
    </Suspense>
</Layout>
  • Транспорт - обычный HTTP: Transfer-Encoding: chunked в HTTP/1.1, поток DATA-фреймов в HTTP/2.
  • Статус и заголовки уходят с первым куском. Если блок упал после shell, поменять 200 на 500 или сделать redirect уже нельзя, ошибку показывают внутри страницы.
  • Идея старая: flush() в PHP, BigPipe у Facebook (2010). Новое - поддержка в React и мета-фреймворках из коробки.

Диагностика:

# -N выключает буферизацию curl: видно, как HTML приходит частями
curl -N https://example.com/

Когда брать: страница зависит от медленных источников данных, а каркас нужно показать сразу.


Гибридный рендеринг

Разные части приложения рендерятся разными способами: часть статическая, часть рендерится на сервере на запрос, часть оживает только на клиенте. Смешивают на трёх уровнях: по маршрутам, внутри страницы и по компонентам.

По маршрутам - каждая страница выбирает свой режим. Angular называет это hybrid rendering:

// app.routes.server.ts, Angular 19+
export const serverRoutes: ServerRoute[] = [
    {path: '', renderMode: RenderMode.Prerender},      // при сборке, SSG
    {path: 'account', renderMode: RenderMode.Client},  // только в браузере, CSR
    {path: '**', renderMode: RenderMode.Server},       // остальное на каждый запрос, SSR
];

В Next.js и Nuxt режим тоже задаётся на уровне страницы, в Nuxt - через routeRules.


Внутри страницы: Partial Prerendering (PPR) - режим Next.js. Статический каркас страницы собран при сборке и отдаётся сразу, динамические части в <Suspense> рендерятся на запрос и стримятся в тот же ответ. Это SSG и streaming SSR на одной странице.


По компонентам: Islands (архитектура островов) - страница из статического HTML без JS с отдельными интерактивными «островами»: поиск, корзина, карусель. JS грузится и hydration идёт только для островов, каждый независимо (partial hydration).

<!-- Astro -->
<Header/>                  <!-- только HTML, JS на клиент не уходит -->
<Search client:visible/>   <!-- остров: hydration, когда попал в экран -->

Примеры: Astro, Fresh (Deno), Eleventy с <is-land>.

Когда брать: контентные сайты с небольшой интерактивностью: блоги, документация, витрины магазинов.


По компонентам: React Server Components (RSC) - компоненты React, которые выполняются только на сервере или при сборке. Их код не попадает в бандл клиента и не гидрируется. Интерактивные компоненты помечаются 'use client': их рендерят на сервере в HTML и гидрируют в браузере, как при обычном SSR.

// ProductPage.jsx - серверный компонент: async, читает базу напрямую, в браузер не уходит
import {AddToCart} from './AddToCart';

export default async function ProductPage({id}) {
    const product = await db.product.find(id);
    return <AddToCart id={id} title={product.name}/>;
}
// AddToCart.jsx - клиентский компонент: гидрируется, можно useState и onClick
'use client';

export function AddToCart({id, title}) { /* ... */
}
  • Результат серверных компонентов - не HTML, а RSC payload: сериализованное дерево, которое React на клиенте встраивает в страницу.
  • RSC и SSR - разные механизмы и работают вместе. RSC решает, чей код едет в браузер, SSR - как получить HTML первого ответа.
  • В Next.js App Router компоненты серверные по умолчанию.

Когда брать: Next.js App Router, когда нужно меньше JS на клиенте и загрузка данных прямо в компоненте.


Что не является SSR

  • Рукописный статический HTML. index.html, свёрстанный руками, - не SSR и не SSG. Сервер ничего не генерирует, просто отдаёт файл.
  • HTML, сгенерированный заранее. Это SSG. Для пользователя эффект как у SSR, но рендер один раз при сборке, а не на запрос.
  • CSR. HTML собирает браузер. Даже если сервер отдаёт index.html динамически, контента в нём нет.

Сравнение

Способ Где собирается HTML Когда Hydration Первый показ и SEO Примеры
CSR Браузер После загрузки JS Не нужна, весь рендер на клиенте Пустая страница до JS, SEO плохое React или Vue на Vite, Angular без SSR
Серверные шаблоны Сервер На каждый запрос Нет, свой JS поверх HTML Сразу Thymeleaf, Jinja, Razor
SSR компонентов Сервер (Node.js) На каждый запрос Вся страница Сразу, клики после hydration Next.js, Nuxt, Angular SSR
SSG Сборщик Один раз при сборке Вся страница у Next.js и Nuxt, нет у Hugo и MkDocs Сразу, файл с CDN MkDocs, Hugo, Astro
ISR Сервер в фоне При сборке, потом по таймеру или событию Вся страница Как у SSG Next.js, Nuxt
Streaming SSR Сервер На запрос, кусками По частям Каркас сразу, медленные блоки позже React 18+, Next.js App Router
Islands Сборщик или сервер При сборке или на запрос Только острова Сразу, минимум JS Astro, Fresh
RSC Сервер или сборщик На запрос или при сборке Только компоненты 'use client' Сразу, меньше JS Next.js App Router

Итого: больших лагеря два -- серверные шаблоны и серверный рендер компонентных приложений (React, Vue, Angular на сервере). Остальное - вариации по осям:

  • когда рендерить: на каждый запрос, при сборке, в фоне по таймеру
  • сколько рендерить на сервере: всю страницу, каркас, отдельные компоненты
  • гидрировать ли клиент: всю страницу, только острова, ничего
  • отдавать HTML целиком или кусками