Children и слоты
Проп children и именованные JSX-слоты дают вызывающему компоновать контент, пока компонент владеет структурой — инвертируя управление, убивая взрыв пропсов и оставляя компоненты открытыми, если форма действительно изменчива.
Вот Card, который ты писал сотню раз. Он начинался с заголовка и тела. Потом футер, бейдж, иконка, флаг «закрываемый», ряд действий, вариант — и вот в его интерфейсе пропсов уже четырнадцать записей, и каждый новый запрос дизайна добавляет пятнадцатую. Каждый проп — это крошечный язык конфигурации, который ты сам придумал, а компонент — щиток из {badge && <Badge>…</Badge>}, который никому не в радость править.
Есть другой ход, и это самый важный ход в React: вместо того чтобы описывать контент пропсами, ты даёшь вызывающему передать контент внутрь. У этого хода есть имя — композиция через children — и как только ты его увидишь, половина твоих проблем «у этого компонента слишком много пропсов» растворится.
После этого урока ты умеешь использовать проп children, чтобы вызывающие компоновали произвольный контент в компонент; тянуться к именованным слотам (JSX, переданный через пропсы вроде header / footer), когда у раскладки несколько отдельных регионов; объяснять, почему композиция инвертирует управление — родитель решает что за контент, компонент решает структуру — и почему это убивает взрыв пропсов; и назвать провал: пере-слочивание компонента, чья форма фиксирована и проста, где слоты добавляют церемонию и не покупают гибкости.
children — это обычный проп, в котором лежит JSX, поэтому контент даёт вызывающий, а не компонент. Всё, что ты вкладываешь между тегами компонента, приходит как props.children. Компонент рисует обвязку (рамку, отступы, тень) и кладёт children в дырку. Ему никогда не надо знать, что внутри.
function Card({ children }: { children: React.ReactNode }) {
return <section className="card">{children}</section>;
}
// вызывающий компонует свободно — Card не знает ни о графике, ни о форме
<Card>
<h3>Выручка</h3>
<RevenueChart range="30d" />
</Card>React.ReactNode — это правильный тип для «любого отображаемого контента»: элементы, строки, числа, фрагменты, массивы, null. Ты не перечисляешь, что может быть внутри — в этом весь смысл.
Альтернатива — проп под каждый кусок контента — это «взрыв пропсов», и он становится хуже с каждым запросом дизайна. Смотри, как Card пытается описать свой контент через конфигурацию. Каждое новое требование — новый проп, новая ветка и новый способ для двух пропсов противоречить друг другу.
// жёстко: компонент владеет тем, ЧТО есть контент, через 8 конфиг-пропсов
type CardProps = {
title: string;
subtitle?: string;
bodyText?: string;
imageUrl?: string;
badgeText?: string;
footerText?: string;
ctaLabel?: string;
onCta?: () => void;
};
function Card(p: CardProps) {
return (
<section className="card">
{p.imageUrl && <img src={p.imageUrl} alt="" />}
<h3>{p.title}</h3>
{p.subtitle && <p className="sub">{p.subtitle}</p>}
{p.badgeText && <span className="badge">{p.badgeText}</span>}
{p.bodyText && <p>{p.bodyText}</p>}
{p.footerText && <footer>{p.footerText}</footer>}
{p.ctaLabel && <button onClick={p.onCta}>{p.ctaLabel}</button>}
</section>
);
}В первый же раз, когда дизайнеру нужны два бейджа, или тело, содержащее <Chart> вместо текста, или CTA-ссылка — ты добавляешь badgeText2, bodyNode, ctaHref, и интерфейс метастазирует. Компонент закрыт: каждый вид контента нужно предвидеть и закодировать заранее.
Замени контент-пропсы на children, и компонент снова открывается — число пропсов схлопывается, а гибкость растёт. Оставь только те пропсы, что действительно являются собственной заботой компонента (его вариант, его поведение), и пусть композиция несёт контент.
type CardProps = {
variant?: "default" | "outlined"; // собственная забота карточки
children: React.ReactNode; // контент принадлежит вызывающему
};
function Card({ variant = "default", children }: CardProps) {
return <section className={`card card--${variant}`}>{children}</section>;
}
// любой контент, скомпонованный вызывающим — новый проп никогда не нужен
<Card variant="outlined">
<CardImage src="/r.png" />
<h3>Выручка <Badge>live</Badge> <Badge>beta</Badge></h3>
<RevenueChart range="30d" />
<a href="/reports">Открыть отчёт</a>
</Card>Два бейджа, график в теле, CTA-ссылка — ничто из этого не потребовало менять Card. Компонент прошёл путь от восьми контент-пропсов до нуля и теперь может держать контент, который его автор и не представлял. Это выигрыш открытого компонента.
Когда у раскладки несколько отдельных регионов, используй именованные слоты: передавай JSX через именованные пропсы вроде header и footer. Один children идеален, когда контент — это один поток. Но у диалога или каркаса страницы есть фиксированные позиции — шапка сверху, футер снизу, основное тело между ними — и ты хочешь, чтобы компонент размещал каждый регион, пока вызывающий его наполняет. Именованные слоты — это просто пропсы типа React.ReactNode.
type PanelProps = {
header?: React.ReactNode; // слот: вызывающий даёт JSX, Panel размещает его
footer?: React.ReactNode; // ещё один регион
children: React.ReactNode; // основное тело
};
function Panel({ header, footer, children }: PanelProps) {
return (
<section className="panel">
{header && <header className="panel__head">{header}</header>}
<div className="panel__body">{children}</div>
{footer && <footer className="panel__foot">{footer}</footer>}
</section>
);
}
<Panel
header={<h2>Настройки <StatusDot ok /></h2>}
footer={<><Button>Отмена</Button><Button variant="primary">Сохранить</Button></>}
>
<SettingsForm />
</Panel>Вызывающий по-прежнему решает, что содержит каждый регион (заголовок со статус-точкой, две кнопки); Panel решает, где регионы стоят. Слоты масштабируются на многорегионные раскладки, которые один children не выразит, не заставляя вызывающего знать внутреннюю разметку.
Настоящий Card, до и после — восемь конфиг-пропсов против children плюс два слота. Card команды начинался просто и обрастал пропсами по мере роста продукта. Вот закрытая версия, которую приходится править на каждое изменение:
function Card({
title, subtitle, badgeText, bodyText,
imageUrl, footerLeft, footerRight, dismissable, onDismiss,
}: CardProps) {
return (
<section className="card">
{dismissable && <button className="x" onClick={onDismiss}>×</button>}
{imageUrl && <img src={imageUrl} alt="" />}
<header>
<h3>{title}</h3>
{badgeText && <span className="badge">{badgeText}</span>}
{subtitle && <p className="sub">{subtitle}</p>}
</header>
{bodyText && <p>{bodyText}</p>}
<footer>
<span>{footerLeft}</span>
<span>{footerRight}</span>
</footer>
</section>
);
}Теперь перепиши так, чтобы вызывающий компоновал контент. Тело — один поток (children); шапка и футер — отдельные регионы, поэтому они становятся именованными слотами. Пропсами остаются только dismissable/onDismiss, потому что закрытие — это собственное поведение карточки, а не контент:
type CardProps = {
header?: React.ReactNode;
footer?: React.ReactNode;
onDismiss?: () => void; // поведение остаётся пропом
children: React.ReactNode;
};
function Card({ header, footer, onDismiss, children }: CardProps) {
return (
<section className="card">
{onDismiss && <button className="x" onClick={onDismiss}>×</button>}
{header && <header className="card__head">{header}</header>}
<div className="card__body">{children}</div>
{footer && <footer className="card__foot">{footer}</footer>}
</section>
);
}
// вызывающий — и теперь два бейджа, тело-график, футер-ссылка просто «работают»
<Card
onDismiss={close}
header={<><h3>Выручка</h3><Badge>live</Badge><Badge>beta</Badge></>}
footer={<a href="/reports">Открыть полный отчёт →</a>}
>
<RevenueChart range="30d" />
</Card>Компонент сжался, потерял лес веток и стал переиспользуемым для контента, который никто не планировал. Следующий запрос дизайна меняет место вызова, а не Card. Это композиция инвертирует управление: родитель решает что, Card решает только структуру.
▸Почему это работает
Почему выталкивание контента в children так важно, помимо более короткого списка пропсов? Потому что это меняет, кому приходится предвидеть будущее. Компонент на конфиг-пропсах должен предсказать каждый вид контента, который он когда-либо будет держать; когда реальность превосходит предсказание, ты правишь компонент (пере-тестируешь его и рискуешь каждым существующим вызовом). Компонент на children не предсказывает ничего — новый контент это новый JSX в месте вызова, а компонент не тронут. Композиция меняет закрытый всезнающий компонент на открытый, и «открыт для расширения без модификации» — ровно то свойство, что держит общий UI-компонент стабильным, пока приложение растёт вокруг него.
▸Частая ошибка
Провал — это пере-слочивание: разбивание компонента, чья форма фиксирована и проста, в кучу именованных слотов, которые не покупают гибкости. <Badge slotIcon slotLabel slotTrailing> для штуки, которая всегда иконка и слово — чистая церемония (три пропа, три ветки рендера, более сложные места вызова) ради того, что <Badge icon="check">Готово</Badge> (или даже <Badge>✓ Готово</Badge>) сказал просто. Слоты окупаются, только когда регионы реально различаются между местами вызова. Если у компонента одна фиксированная компоновка и мелкий предсказуемый контент, пара обычных пропсов (или просто children) бьёт слот-API. Тянись к слотам, когда ощущаешь реальное давление взрыва пропсов от многорегионных раскладок — не превентивно.
Card сейчас принимает title, subtitle, badgeText, bodyText и footerText как пропсы, а дизайнеры всё просят новые формы контента (график в теле, два бейджа, футер-ссылку). Каков рефактор уровня senior?
Проп children — основной инструмент композиции в React: это обычный проп с React.ReactNode, поэтому вызывающий даёт контент, а компонент даёт только структуру. Один этот ход меняет «взрыв пропсов» — конфиг-проп и ветку рендера под каждый мыслимый кусок контента — на открытый компонент, держащий контент, который его автор и не представлял. Когда у раскладки несколько отдельных регионов, тянись к именованным слотам: JSX, переданный через пропсы вроде header и footer, которые компонент размещает, пока вызывающий наполняет. Оставляй пропсами только то, что действительно собственная забота компонента — его вариант, его поведение — а всё остальное пусть несёт композиция. Глубокая идея — инверсия управления: родитель решает что, компонент решает структуру, поэтому число пропсов падает, а гибкость растёт одновременно. Провал — пере-слочивание фиксированной простой формы, добавление слот-церемонии там, где ничто не меняется. Подбирай слоты под реальное многорегионное давление; иначе children или обычный проп — выбор уровня senior.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.