open atlas
↑ К треку
Паттерны React RXP · 01 · 01

Children и слоты

Проп children и именованные JSX-слоты дают вызывающему компоновать контент, пока компонент владеет структурой — инвертируя управление, убивая взрыв пропсов и оставляя компоненты открытыми, если форма действительно изменчива.

RXP Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Вот Card, который ты писал сотню раз. Он начинался с заголовка и тела. Потом футер, бейдж, иконка, флаг «закрываемый», ряд действий, вариант — и вот в его интерфейсе пропсов уже четырнадцать записей, и каждый новый запрос дизайна добавляет пятнадцатую. Каждый проп — это крошечный язык конфигурации, который ты сам придумал, а компонент — щиток из {badge && <Badge>…</Badge>}, который никому не в радость править.

Есть другой ход, и это самый важный ход в React: вместо того чтобы описывать контент пропсами, ты даёшь вызывающему передать контент внутрь. У этого хода есть имя — композиция через children — и как только ты его увидишь, половина твоих проблем «у этого компонента слишком много пропсов» растворится.

Цель

После этого урока ты умеешь использовать проп children, чтобы вызывающие компоновали произвольный контент в компонент; тянуться к именованным слотам (JSX, переданный через пропсы вроде header / footer), когда у раскладки несколько отдельных регионов; объяснять, почему композиция инвертирует управление — родитель решает что за контент, компонент решает структуру — и почему это убивает взрыв пропсов; и назвать провал: пере-слочивание компонента, чья форма фиксирована и проста, где слоты добавляют церемонию и не покупают гибкости.

1

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. Ты не перечисляешь, что может быть внутри — в этом весь смысл.

2

Альтернатива — проп под каждый кусок контента — это «взрыв пропсов», и он становится хуже с каждым запросом дизайна. Смотри, как 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, и интерфейс метастазирует. Компонент закрыт: каждый вид контента нужно предвидеть и закодировать заранее.

3

Замени контент-пропсы на 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. Компонент прошёл путь от восьми контент-пропсов до нуля и теперь может держать контент, который его автор и не представлял. Это выигрыш открытого компонента.

4

Когда у раскладки несколько отдельных регионов, используй именованные слоты: передавай 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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.