Полиморфные компоненты: проп as против asChild и Slot
Полиморфизм двумя путями: проп as меняет рендеримый элемент ценой полиморфных типов ref и взрыва инстанцирования типов; asChild сливает пропсы в существующего ребёнка через Slot — конкатенация className, цепочка обработчиков, композиция ref. as — примитивам, asChild — поведению.
Команда дизайн-системы выкатила полностью типизированный полиморфный проп as на Box, Text и Button — из тех, которыми гордятся: с инференсом ComponentPropsWithoutRef и полиморфным ref. PR был красив. Через три недели платформенный канал наполнился жалобами: автодополнение на Box в редакторе занимало секунды, опечатка в один символ в href рождала стену ошибки на 60 строк с типами, которых никто не писал, а время tsc монорепы выросло на полминуты — у Box было 4000 колл-сайтов, и каждый теперь инстанцировал условный тип по всей карте JSX-элементов. Тем временем продуктовая команда упёрлась в рантайм-версию той же проблемы с другой стороны: им нужна была стилистика Button на роутерном Link, они написали <Button as={Link}> — и обнаружили, что логика disabled у Button на анкоре молча не значит ничего. Итоговый фикс — два инструмента вместо одного: as остался, но только на стилевых примитивах, где собственных пропсов мало и инференс мелкий; поведенческие компоненты сменили его на asChild, где Button вообще не рендерит элемент, а сливает свои пропсы в ребёнка, которого вы ему дали. Одна цель — один компонент, много элементов — и два механизма с противоположными профилями стоимости.
Одно поведение, много элементов
Нужда универсальна для дизайн-систем: Button должен быть иногда button, иногда a, иногда роутерным Link — те же отступы, то же кольцо фокуса, те же состояния нажатия. Наивные ответы все неверны. Вложенность (a вокруг button) — невалидный HTML со сломанной семантикой: интерактивное внутри интерактивного путает любую вспомогательную технологию. Копирование стилей во второй компонент дрейфует за квартал. Навесить onClick со стилями на div — потерять активацию с клавиатуры, фокус и дерево доступности целиком. Реальных опций две: сменить то, что компонент рендерит (проп as) или усыновить элемент, который вызывающий уже написал (asChild).
Проп as и счёт, который выставляет TypeScript
В рантайме as — почти ничто:
function Box({ as, ...props }) {
const Comp = as ?? "div";
return <Comp {...props} />;
}
// <Box as="a" href="/docs">Docs</Box> → рендерит <a href="/docs">Вся стоимость живёт в типах. Корректный полиморфный компонент обязан сказать: «мои пропсы — это мои собственные, плюс пропсы элемента, названного в as, минус коллизии — и мой ref типизирован под тот элемент». Последняя оговорка — самая печально известная: тип ref выводится на каждый колл-сайт (ComponentPropsWithRef<E>["ref"]), а тип пропсов — условный/отображённый тип по E extends ElementType. Это работает, и каждая утилитарная библиотека с такой фичей несёт страницы оговорок — потому что стоимость инстанцирования растёт с колл-сайтами. Каждый <Box as={...}> заставляет компилятор разрешать условный тип по полной карте элементов; на тысячах колл-сайтов это измеримое время tsc и видимая задержка редактора, а сообщения об ошибках перестают называть ваш код и начинают называть типовую машинерию. Есть и семантическая ловушка, которую типы не ловят: поведенческие пропсы компонента могут ничего не значить на новом элементе — disabled на анкоре нативно не делает ничего, и компонент должен либо транслировать его (aria-disabled, заблокированные обработчики), либо честно не поддерживать. Честный вердикт: as — правильный инструмент для стилевых примитивов — Box, Text, Stack, — где собственных пропсов мало, поведения нет, а полиморфизм — чисто про то, какой тег несёт стили.
После того как Box получает полностью типизированный полиморфный as и используется на ~4000 колл-сайтов, время tsc и задержка редактора заметно растут. Что на самом деле выросло?
asChild и Slot: композиция вместо типовой гимнастики
Альтернативу популяризовал Radix. С asChild компонент не рендерит собственного элемента — он рендерит Slot, а Slot клонирует своего единственного ребёнка, вливая в него пропсы компонента:
<Button asChild>
<Link to="/billing">Upgrade</Link>
</Button>
// Рендерится ОДИН элемент: <a> от Link, теперь несущий
// className, data-атрибуты, обработчики и ref от Button.Внутри Button делает const Comp = asChild ? Slot : "button". Slot берёт элемент-ребёнка и производит клон со слитыми пропсами — механика важна, потому что вы будете её отлаживать:
- className: конкатенируется — выживают классы и слота, и ребёнка.
- style: объекты сливаются, свойства ребёнка побеждают при коллизии.
- обработчики событий: цепочкой, ребёнок первым — сначала
onClickребёнка, потом слота. Хорошо построенные примитивы компонуют с проверкойdefaultPrevented, так что ребёнок, вызвавшийevent.preventDefault(), может остановить собственную логику примитива; наивное слияние выполняет оба безусловно — так и уезжают в прод баги двойного срабатывания. - ref: компонуется — callback-ref, заполняющий и родительский, и детский. React 19 заметно это упростил:
refтеперь обычный проп функциональных компонентов, и слияние больше не требует обёртокforwardRefили доступа кelement.ref(устаревший путь, предупреждавший ещё в React 18).
Что покупает этот контракт: никакой типовой гимнастики — у Button остаются свои простые пропсы, у ребёнка свои; TypeScript проверяет стороны раздельно. Цена — два новых режима отказа. Несколько детей бросают исключение: Slot обязан клонировать ровно один элемент (React.Children.only), поэтому <Button asChild><Icon /><span>Save</span></Button> — ошибка рантайма; оберните их в один элемент. И ребёнок должен сотрудничать: Slot вливает пропсы в элемент ребёнка, но если ребёнок — кастомный компонент, игнорирующий незнакомые пропсы (рендерит свой span без спреда), каждый слитый обработчик, ARIA-атрибут и ref молча испаряются. Виджет выглядит правильно и не делает ничего; отладочный ход — инспекция DOM на ожидаемые атрибуты data-state/aria-*.
Button asChild оборачивает роутерный Link. Оба вешают onClick, и обработчик ребёнка-Link вызывает event.preventDefault() для клиентской навигации. Что выполняется и в каком порядке?
Выбор: as примитивам, asChild поведению
Два инструмента чисто разделяются по тому, чем компонент является:
as— стилевым примитивам. Box, Text, Stack, Grid: поведения нет, собственных пропсов мало, инференс мелкий. Полиморфизм — это «какой тег несёт эти стили»:as="section",as="label". Держите допустимое объединение узким (intrinsic-элементы, может пара компонентов) — и счёт TypeScript останется маленьким.asChild— поведенческим обёрткам. Button, Tooltip.Trigger, Dialog.Trigger, DropdownMenu.Trigger: богатое поведение (обработчики, ARIA, фокус), и элемент вызывающего часто сам компонент (роутерный Link) со своими пропсами. asChild целиком обходит взрыв типов и компонует поведения —Tooltip.Trigger asChildповерхDialog.Trigger asChildповерх вашегоButtonскладывает три набора поведения на один отрендеренный элемент, чегоasне выражает вовсе.
Остающаяся дисциплина — документация: asChild-API обязан громко проговаривать контракт ребёнка — ровно один элемент-ребёнок; ребёнок должен спредить незнакомые пропсы и принимать ref. Дизайн-системы, пропустившие этот абзац, порождают стабильный поток тикетов «тултип молча не открывается» от команд, чей компонент-ребёнок проглотил слияние.
▸Почему это работает
Почему правила слияния пропсов у Slot легли именно так? Каждое правило отвечает на конкретный конфликт. Порядок «ребёнок первым» для обработчиков существует потому, что ребёнок — код приложения, а слот — инфраструктура: приложение должно получить событие первым и сохранить право вето (через preventDefault, когда примитив компонует с проверкой defaultPrevented) на реакцию инфраструктуры. Конкатенация className — потому что классы аддитивны по замыслу: уронить любую сторону — сломать либо стилизацию состояний примитива, либо бренд приложения. Слияние style с победой ребёнка — потому что более конкретное намерение должно перекрывать общее. Композиция ref — потому что узел по-настоящему нужен обеим сторонам: примитиву для фокуса и измерений, приложению для того, что оно измеряет. React 19, сложив ref в обычные пропсы, убрал последний кусок магии: слияние ref теперь — просто слияние ещё одного пропа, без церемонии forwardRef и без устаревшего доступа к element.ref.
- 01Сопоставьте, где платят свои издержки проп as и asChild, и сформулируйте итоговое правило размещения для дизайн-системы.
- 02Пройдите правила слияния пропсов у Slot и режимы отказа, которые должен проверять ревьюер.
Один компонент, рендерящийся как много элементов, — структурная нужда любой дизайн-системы, и два механизма, отвечающие на неё, платят на противоположных концах. Проп as подменяет рендеримый тег — тривиально в рантайме, дорого в TypeScript, где корректная полиморфная сигнатура — условный тип по карте элементов с ref на элемент, инстанцируемый на каждом колл-сайте; на тысячах сайтов это реальное время tsc, видимая задержка редактора и сообщения об ошибках, называющие типовую машинерию вместо вашего кода. Его честный дом — стилевой примитив: Box, Text, Stack — с малым числом собственных пропсов, без поведения и с намеренно узким допустимым объединением. asChild переворачивает подход: компонент рендерит Slot, который клонирует единственный элемент-ребёнок и сливает наборы пропсов — className конкатенируются, style сливается с победой ребёнка, обработчики сцепляются ребёнком вперёд (с проверкой defaultPrevented в хорошо построенных примитивах, отсутствие которой рождает двойные срабатывания), ref компонуются в один callback — шаг, который React 19 упростил, сделав ref обычным пропом. Система типов остаётся простой, потому что каждая сторона хранит свои пропсы, а поведения компонуются — несколько asChild-триггеров складываются на один элемент, чего as не выражает. Цена — новые контракты: Slot бросает на нескольких детях, а компонент-ребёнок, не спредящий незнакомые пропсы или не принимающий ref, молча глотает слияние — виджет выглядит правильно и не делает ничего, и диагностика — проверка отрендеренного DOM на data-атрибуты, которые должны были прийти. Разделяйте соответственно: as — стилевым примитивам, asChild — поведенческим обёрткам, и контракт ребёнка — задокументирован громко. Теперь, когда встретишь тикет «тултип молча не открывается», твой первый шаг — инспекция DOM: если ожидаемых data-state и aria-*-атрибутов нет на дочернем элементе — ребёнок проглотил слияние, и ты знаешь ровно, что чинить.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.