Архитектура React Server Components (RSC) представляет собой фундаментальное переосмысление модели исполнения компонентно-ориентированных интерфейсов. До внедрения RSC в традиционных SPA и SSR приложениях существовал непреодолимый компромисс: любой компонент, написанный разработчиком, неизбежно включался в клиентский JavaScript-бандл. Если странице требовалось распарсить Markdown-документ или форматировать дату сложным набором локалей, библиотеки размером в сотни килобайт (marked, date-fns, highlight.js) отправлялись браузеру, замедляя фазы загрузки скриптов (Download), парсинга (Parse) и компиляции (Compile) движком V8.
React 19 закрепляет спецификацию компонентов с нулевым размером бандла (Zero-Bundle-Size):
Разделение компонентов на серверные и клиентские решает четыре фундаментальные проблемы фронтенд-инженерии:
pg), криптографии, парсинга Markdown или сложной бизнес-валидации исполняются на бэкенде и физически не загружаются браузером.async function ProductPage()) и обращаться напрямую к БД через await db.query(), устраняя необходимость создания сотен промежуточных эндпоинтов.В отличие от классического SSR, результатом работы которого является плоская статическая HTML-строка, серверный рендерер RSC генерирует поток JSONL-подобных инструкций — React Flight Stream.
Flight Protocol передает семантическую структуру виртуального DOM-дерева с точками монтирования клиентских компонентов (Client References).
Разделение сред обеспечивается механизмом Conditional Exports в package.json среды Node.js. При сборке серверного слоя активируется экспортное условие "react-server", которое полностью исключает из области видимости браузерные хуки (useState, useEffect, useLayoutEffect), превращая их импорт в ошибку компиляции.
Браузер отправляет HTTP-запрос. Сервер активирует среду react-server и запускает асинхронный рендеринг корневого RSC.
При обнаружении клиентского компонента сервер записывает в поток чанк M с метаданными бандла и оставляет маркер $L.
Чанки J0, J1 и промисы @ отправляются в браузер по мере их готовности без ожидания медленных запросов.
Браузер считывает строки M и начинает загрузку JS-файлов клиентских компонентов до завершения всего потока.
Клиентский движок десериализует Flight-граф и бесшовно монтирует его в активное дерево Fiber.
В чем фундаментальное отличие данных, передаваемых по протоколу Flight, от традиционного ответа классического SSR?
Парсинг сложных формул, математическая обработка массивов данных и генерация графов происходят на сервере. Библиотеки размером в десятки мегабайт полностью исключаются из клиентского JavaScript-бандла.
Переход на следующий маршрут запрашивает у сервера только Flight-поток нового экрана. Клиентский React сравнивает новое серверное дерево со старым и обновляет исключительно изменившиеся узлы DOM, не сбрасывая воспроизведение медиа, состояние форм или фокус.
Серверный компонент может передать неразрешенный промис напрямую через пропсы в клиентский компонент. Клиент распаковывает промис через хук use(promise) внутри границы <Suspense>, мгновенно отображая скелетон до прихода данных.
Клиентский компонент модального окна или табов управляет интерактивным открытием и закрытием, а тяжелый контент внутри передается как серверный слот children без утечки библиотек в бандл.
Вызов асинхронных серверных функций. Прямые запросы к БД. Построение виртуального дерева с маркерами Client Reference.
Генерация текстового потока: сериализация JSX-нод в префиксы J, модулей компонентов в строки M, промисов в дескрипторы @.
Передача чанков по HTTP с Transfer-Encoding: chunked. Браузер начинает обработку до завершения генерации всей страницы.
Сборка виртуального дерева на клиенте. Разрешение ссылок $L через вебпак/турбопак манифест. Монтирование в дерево Fiber.
Протокол Flight передает данные строками, разделенными символом перевода строки \n. Каждая строка начинается с идентификатора и типа записи:
| Префикс / Символ | Назначение | Пример строки протокола |
| :--- | :--- | :--- |
| M (Module) | Client Reference: путь к файлу, имя экспорта и список JS-чанков | M1:{"id":"./components/Cart.tsx","name":"default","chunks":["client1"]} |
| J (JSON Tree) | Сериализованное виртуальное дерево JSX-узлов и пропсов | J0:["$","div",null,{"className":"container","children":"$@2"}] |
| $ (React Element) | Маркер виртуального JSX элемента (аналог Symbol.for('react.transitional.element')) | ["$","span",null,{"children":"Hello"}] |
| $L (Lazy Client) | Подстановка клиентского компонента по ID из записи M | ["$","$L1",null,{"count":0}] |
| $S (Suspense) | Граница ожидания Suspense | "$Sreact.suspense" |
| @ (Promise) | Ссылка на отложенное значение (Thenable) | J0:{"analytics":"$@3"} |
| P (Promise Resolve) | Разрешение промиса с переданным значением | 3:{"views":1240,"conversions":42} |
Когда компилятор встречает директиву 'use client', он подменяет исходный модуль на так называемый Client Reference Proxy.
Исходный клиентский компонент:
'use client';
import React, { useState } from 'react';
export default function InteractiveCounter({ step = 1 }: { step?: number }) {
const [count, setCount] = useState(0);
return (
<button
onClick={() => setCount(c => c + step)}
className="px-3 py-1 bg-accent-violet text-white font-mono rounded cursor-pointer"
>
Клики: {count}
</button>
);
}Серверный компонент импортирует его, однако сборщик (Turbopack или Webpack) на сервере заменяет тело компонента на пустой ссылочный объект:
// Сгенерированный сборщиком прокси-объект в среде react-server
export default {
$$typeof: Symbol.for('react.client.reference'),
$$id: './src/components/InteractiveCounter.tsx#default',
$$async: false,
};Когда серверный рендерер встречает элемент с типом Symbol.for('react.client.reference'), он не выполняет функцию компонента. Вместо этого он эмитит в поток строку манифеста M, содержащую URL чанка для браузера, и оставляет маркер $L в JSON-структуре дерева.
Напрямую импортировать Server Component в Client Component запрещено (сборщик автоматически превратит импортируемый файл в клиентский). Канонический путь — передача через пропсы (Slot Pattern):
// 1. Client Component: интерактивная обертка-аккордеон
'use client';
import React, { useState } from 'react';
interface AccordionProps {
title: string;
children: React.ReactNode; // Серверный слот
}
export function Accordion({ title, children }: AccordionProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border border-border rounded-lg overflow-hidden my-4">
<button
onClick={() => setIsOpen(prev => !prev)}
className="w-full text-left p-4 bg-bg-elevated font-semibold flex justify-between items-center cursor-pointer"
>
<span>{title}</span>
<span>{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && <div className="p-4 bg-bg-surface">{children}</div>}
</div>
);
}
// 2. Server Component: тяжелый контент, собираемый в среде react-server
import { readFile } from 'node:fs/promises';
async function HeavyServerReport({ filename }: { filename: string }) {
// Прямой доступ к файловой системе на сервере
const rawData = await readFile(`./reports/${filename}`, 'utf-8');
return <div className="prose font-mono text-sm">{rawData}</div>;
}
// 3. Компоновка на сервере: Donut Pattern
export async function ReportPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-4">Финансовый отчет</h1>
{/*
Accordion исполняется на клиенте, но HeavyServerReport
рендерится на сервере и передается как готовый Flight-слот children!
*/}
<Accordion title="Показать детальный аудит">
<HeavyServerReport filename="q3-audit.log" />
</Accordion>
</main>
);
}Почему попытка передать стандартную стрелочную функцию onClick={() => alert('ok')} из Server Component в Client Component завершается фатальной ошибкой?
Для отладки сетевых ответов и валидации передаваемых в Client Components данных разработаем утилиту перехвата и инспекции Flight-чанков:
interface DecodedFlightRecord {
type: 'CLIENT_MODULE' | 'JSX_TREE' | 'PROMISE_RESOLVE' | 'UNKNOWN';
id: string;
rawPayload: string;
sizeBytes: number;
}
export async function inspectFlightStream(
stream: ReadableStream<Uint8Array>
): Promise<DecodedFlightRecord[]> {
const reader = stream.getReader();
const decoder = new TextDecoder();
const records: DecodedFlightRecord[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunkText = decoder.decode(value, { stream: true });
const lines = chunkText.split('\n').filter(l => l.trim().length > 0);
for (const line of lines) {
const colonPos = line.indexOf(':');
if (colonPos === -1) continue;
const tag = line.slice(0, colonPos);
const payload = line.slice(colonPos + 1);
let type: DecodedFlightRecord['type'] = 'UNKNOWN';
if (tag.startsWith('M')) type = 'CLIENT_MODULE';
else if (tag.startsWith('J')) type = 'JSX_TREE';
else if (!isNaN(Number(tag))) type = 'PROMISE_RESOLVE';
records.push({
type,
id: tag,
rawPayload: payload,
sizeBytes: new TextEncoder().encode(line).length,
});
}
}
} finally {
reader.releaseLock();
}
return records;
}Поскольку граница между RSC и Client Component проходит по сети через протокол Flight, все пропсы обязаны быть сериализуемыми. Попытка передать функции-обработчики (onClick={() => {}}), инстансы классов с методами или символы Symbol завершится фатальной ошибкой сериализатора Flight.
// ❌ ОШИБКА: передача анонимного замыкания из RSC в клиентский компонент
export async function ServerPage() {
const handleAction = () => {
console.log('Клик');
};
return <ClientButton onClick={handleAction} />; // Crash: Functions cannot be passed directly to Client Components
}
// ✅ ПРАВИЛЬНО: передача Server Action с директивой 'use server' или примитивов
export async function ServerPage() {
async function serverAction() {
'use server';
// Безопасное исполнение на сервере через RPC
}
return <ClientButton action={serverAction} />;
}Если модуль, содержащий приватные ключи API или обращения к базе данных, случайно импортируется цепочкой клиентских компонентов без защитного пакета server-only, сборщик либо аварийно включит секреты в публичный JS-бандл, либо упадет с ошибкой отсутствия Node.js API в браузере.
// ❌ ОПАСНО: модуль не защищен, может быть случайно импортирован на клиенте
import { db } from '@/lib/db';
export async function getPrivateUserData(userId: string) {
return db.user.findUnique({ where: { id: userId } });
}
// ✅ ПРАВИЛЬНО: использование poison-модуля server-only
import 'server-only'; // Вызовет ошибку сборки при попытке клиентского импорта
import { db } from '@/lib/db';
export async function getPrivateUserData(userId: string) {
return db.user.findUnique({ where: { id: userId } });
}Распространенное заблуждение: считать, что RSC заменяет SSR. На самом деле они дополняют друг друга. RSC формирует Flight-граф (структуру компонентов). Классический SSR берет этот Flight-граф на сервере и транслирует его в первичный HTML для мгновенной отрисовки краулерами и пользователями. Отсутствие понимания этой разницы приводит к попыткам читать HTML-парсером поток протокола Flight.
Компоненты по умолчанию в React 19 компилируются в среде react-server. Попытка вызвать useState, useEffect, useReducer или useRef в таком компоненте приведет к ошибке компилятора, так как пакет react в среде react-server не экспортирует данные хуки.