open atlas
↑ К треку
React с нуля до senior RCT · 11 · 02

Граница сериализации: use client режет граф модулей

use client помечает разрез графа модулей: всё, что импортирует клиентский файл, едет в бандл. Пропсы обязаны сериализоваться — простые данные, Date, Map да; функции и экземпляры классов нет. Children вкладывают серверный вывод в клиентскую оболочку; use server — путь назад.

RCT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Секьюрити-ревью нашло это за пятнадцать минут через view-source. Страница профиля — серверный компонент — забирала текущего пользователя через ORM и передавала строку таблицы прямо в клиентский компонент: <ProfileCard user={user} />. ORM вернула простой объект, сериализация прошла без единого звука, и Flight-пейлоад, встроенный в каждую страницу профиля, содержал строку целиком: email, phone, stripeCustomerId, passwordHash, mfaSecret. Никто ничего не взламывал; сервер вежливо публиковал колонки своей базы каждому, кто нажмёт Ctrl+U. Тот же аудит неделей раньше поймал обратный отказ: общий хелпер format.ts импортировали в файл с 'use client', а format.ts импортировал config.ts, который читал process.env.STRIPE_SECRET_KEY в экспортируемую константу — один переход по графу, и секрет лежал в публичном чанке. Ни один из багов не экзотика. Оба — одно и то же непонимание: 'use client' — не аннотация компонента, а разрез графа модулей, и пропсы, пересекающие разрез, сериализуются и публикуются. У границы точные правила, и этот урок — про них.

За следующие десять минут ты узнаешь, какие значения могут пересекать границу сервер→клиент, какие молча публикуют колонки базы данных — и почему оба бага из Hook полностью предотвратимы, если знать правила.

Директива на модуле, а не флаг на компоненте

'use client' ставится в начале файла и означает: отсюда этот модуль и всё, что он импортирует, транзитивно принадлежат клиентскому бандлу. Это разрез графа, а не свойство компонента. Пометив 40-строчный Tabs.tsx, вы добавили в бандл не 40 строк — вы добавили Tabs.tsx плюс его импорты, плюс их импорты, до самого дна графа. Один неосторожный import { format } from "../lib/format", где format.ts тянет утилиту для графиков, притянет и библиотеку графиков. Поэтому же серверный компонент, импортированный клиентским файлом, молча становится клиентским: на самом компоненте нет маркера, есть только положение его модуля в графе относительно ближайшего разреза 'use client'. Дисциплина отсюда: держите клиентские файлы маленькими листьями и относитесь к каждому добавленному в них импорту как к решению о размере бандла и о секретности.

Защитный инструмент для секретной половины — пакет server-only: импортируйте его в любом модуле, который никогда не должен достичь клиента (db.ts, config.ts, всё, что трогает process.env), и сборка упадёт в момент, когда какой-нибудь рефакторинг утащит модуль через разрез. Цена — одна строка импорта; молчаливая утечка превращается в ошибку компиляции. Зеркальный пакет client-only существует для модулей, которым нельзя выполняться на сервере.

Что может пересекать границу: правила сериализации

Пропсы из серверного компонента в клиентский сериализуются в Flight-пейлоад. Разрешённый список шире JSON, но строг: примитивы (string, number, bigint, boolean, undefined, null, символы только через Symbol.for), простые объекты и массивы, Date, Map, Set, TypedArray и ArrayBuffer, FormData, JSX, произведённый серверными компонентами, Promise (клиент разворачивает их через use() — так через границу едут стримящиеся данные) и ссылки на серверные функции. Список отказов — то, что определяет архитектуру: функции (кроме функций с 'use server'), экземпляры классов (всё, чей прототип не Object.prototype, — сущность ORM, объект Temporal, ваша доменная модель), незарегистрированные символы. Отвергнутое значение бросает ошибку сериализации в момент рендера с именем пропса.

Опасен случай, который не бросает. Большинство ORM возвращают простые объекты — и полная строка базы проплывает сериализацию и ложится, колонка за колонкой, в пейлоад, который может прочитать любой пользователь. Утечка passwordHash из Hook — ровно эта форма. Когда ты видишь в ревью пропс, принимающий объект модели целиком, спроси себя: какие колонки ты готов публиковать всем, у кого есть Ctrl+U? Правило, переживающее аудиты: считайте поверхность пропсов сервер→клиент публичным API. Маппируйте строки в явные DTO (Data Transfer Object — объект передачи данных с ровно теми полями, которые нужны UI) на границе — { id, name, avatarUrl } — так же, как вы никогда не вернули бы SELECT * из публичного эндпоинта.

Викторина

Серверный компонент передаёт клиентскому четыре пропса: Date, Map с настройками, функцию formatRow и полную ORM-строку пользователя (простой объект с passwordHash). Что произойдёт с каждым?

Переплетение без импорта: паттерн children

Кажется, что разрез графа требует: всё ниже клиентского компонента — тоже клиентское. Это не так, и лазейка — старейший проп в React. Клиентский компонент не может импортировать серверный, но может получить отрендеренный на сервере вывод как children (или любой проп типа JSX), потому что серверный JSX есть в списке сериализуемого:

// Tabs.tsx — 'use client': владеет состоянием activeTab, ~2 кБ
export function Tabs({ children }: { children: React.ReactNode }) {
  const [active, setActive] = useState(0);
  return <div>{/* заголовки вкладок */}{children[active]}</div>;
}

// page.tsx — серверный компонент
<Tabs>
  <ReportPanel data={await db.reports.q3()} />   {/* остаётся серверным */}
  <AuditPanel rows={await db.audit.recent()} />  {/* остаётся серверным */}
</Tabs>

Панели рендерятся на сервере — прямой доступ к базе, ноль веса в бандле — и приезжают внутри пейлоада уже разрешёнными деревьями; Tabs лишь решает, какую показать. Правило композиции одной строкой: среду определяет импортирующий, а не рендерящий. Клиентская оболочка с серверными children — рабочая лошадь каждой серьёзной RSC-кодовой базы, и привычка тянуться к ней — то, что отличает хирургический лист 'use client' от директивы на уровне страницы, утаскивающей весь маршрут в бандл.

Обратный канал: серверные функции

Данные текут сервер→клиент через пропсы; единственный санкционированный путь обратно — серверная функция. Пометьте функцию (или целый модуль) директивой 'use server', и React заменит её в клиентском бандле сериализуемой ссылкой; вызов из браузера выполнит POST под капотом: аргументы сериализуются по тем же правилам Flight, а возвращаемое значение стримится обратно. Отсюда два продакшен-следствия. Первое: каждая серверная функция — публичный, по умолчанию неаутентифицированный эндпоинт — фреймворк сам прокладывает маршрут, и атакующему не нужен ваш UI, чтобы её вызвать; валидируйте и авторизуйте внутри функции, каждый раз, как в любом обработчике. Второе: типы аргументов и возврата подчиняются тем же правилам сериализации, что и пропсы, — ни экземпляров классов на входе, ни на выходе, — так что DTO-дисциплина работает в обе стороны.

Викторина

Клиентский файл импортирует formatPrice из lib/format.ts; format.ts импортирует config.ts, который читает process.env.STRIPE_SECRET_KEY в экспортируемую константу. Сборка прошла успешно. Что произошло и что бы это предотвратило?

Вспомните перед уходом
  1. 01
    Сформулируйте правила сериализации пропсов сервер→клиент: что проходит, что бросает ошибку, и какой случай не бросает, но и не безопасен?
  2. 02
    Почему use client — утверждение о графе модулей, а не флаг компонента, и какие два паттерна держат под контролем бандл и секреты?
Итог

Граница между сервером и клиентом — разрез графа модулей, объявленный директивой use client в начале файла. Всё, что этот файл импортирует, транзитивно, попадает в клиентский бандл — поэтому каждый импорт, добавленный в клиентский файл, есть решение о размере бандла и о секретности, а ухоженная кодовая база держит клиентские файлы маленькими интерактивными листьями. Непомеченные модули нейтральны к среде и попадают в тот граф, который их импортирует; эта нейтральность и позволяет общему хелперу утащить конфиг со Stripe-секретом в публичный чанк, и однострочный страж — пакет server-only, превращающий такое перетаскивание в ошибку сборки. Пропсы через разрез сериализуются в Flight-пейлоад по точным правилам: примитивы, простые объекты и массивы, Date, Map, Set, TypedArray, FormData, серверный JSX, Promise и ссылки на серверные функции проходят; функции и экземпляры классов бросают ошибку при рендере. Случай, который не бросает и не безопасен, — ORM-строка как простой объект: она сериализуется молча и публикует каждую колонку в view-source, поэтому поверхность пропсов трактуется как публичный API с явным DTO-маппингом. Переплетение не требует импорта: клиентская оболочка получает серверные children как пропсы, оставляя тяжёлые панели на сервере, пока двухкилобайтный Tabs владеет состоянием. А единственная дорога назад через разрез — функция use server: настоящий POST-эндпоинт с Flight-сериализованными аргументами, публичный по построению, так что валидация и авторизация живут внутри неё — каждый раз. Теперь, когда встретишь в PR пропс, принимающий объект целиком, у тебя будет готовый вопрос: что из этого я хочу видеть в view-source — и DTO-маппинг появится раньше, чем придёт секьюрити-ревью.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.