
직접 디자인 시스템 패키지를 만드는 프로젝트를 진행하기 위해, radix-ui의 DX를 참고하기로 했다. 그래서 가장 간단한 Label 컴포넌트를 살펴보다가 API reference에서 asChild Prop이라는 것을 발견하게 되었다.
Radix-ui Composition 설명 공식문서 를 참고해보면 Radix ui의 모든 Primitive 컴포넌트는 asChild라는 prop을 받을 수 있는데, 이게 true일 때는 default DOM을 렌더하지 않고, children으로 받은 요소를 그대로 렌더링할 수 있다고 한다.
import * as React from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
export default () => (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a href="https://www.radix-ui.com/">Radix UI</a>
</Tooltip.Trigger>
<Tooltip.Portal>…</Tooltip.Portal>
</Tooltip.Root>
);
이런식으로 asChild를 사용하면 Tooltip.Trigger 컴포넌트에 default로 렌더링되는 Primitive.button 대신에 자식인 a가 대신 렌더링된다.
만약 asChild를 사용하지 않는다면
import * as React from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
export default () => (
<Tooltip.Root>
<a href="https://www.radix-ui.com/">
<Tooltip.Trigger>
Radix UI
</Tooltip.Trigger>
</a>
<Tooltip.Portal>…</Tooltip.Portal>
</Tooltip.Root>
);
이런식으로 Tooltip.Trigger의 default인 button이 a태그 내부에 위치하게 되고,
그러면 HTML 명세에 맞지 않고, 접근성 이슈도 있고 불필요한 태그도 늘어난다.
그래서 이런 문제를 해결하기 위해 Radix-ui는 asChild prop을 통해 렌더링 책임을 자식 컴포넌트에 위임하는 방식을 채택했는데, 이것을 Render Delegation 패턴이라고 부른다고 한다.
이걸 이해하기 위해선 먼저 Primitive.tsx 컴포넌트부터 살펴보아야 한다.
radix-ui의 모든 디자인 컴포넌트는 Primitive의 요소를 통해 만들어지기 때문이다.
// 핵심 로직만 남긴 Primitive.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Slot } from '@radix-ui/react-slot';
const NODES = [
'a',
'button',
'div',
'form',
'h2',
'h3',
'img',
'input',
'label',
'li',
'nav',
'ol',
'p',
'span',
'svg',
'ul',
] as const;
const Primitive = NODES.reduce((primitive, node) => {
const Node = React.forwardRef((props, forwardedRef) => {
const { asChild, ...pimitiveProps } = props;
const Comp: any = asChild ? Slot : node;
return <Comp {...primitiveProps} ref={forwardedRef} />;
});
return { ...primitive, [node]: Node };
}, {});
Primitive가 처음엔 컴포넌트처럼 보였지만, 사실 Primitive는 그냥 객체이며, NODES의 각 요소를 key로 가지고 해당 요소의 forwardRef 컴포넌트를 value로 갖는다
그래서 결국 객체의 value가 되는 Node가 리턴하는 Comp를 살펴보면 const Comp: any = asChild ? Slot : node 인데,
여기서 asChild가 true이면 default인 node 대신 Slot 컴포넌트를 리턴하는 것을 알 수 있다. 그리고 바로 이 Slot 컴포넌트에 Render Delegation을 구현하는 핵심 로직이 다 들어있다.
지금까지는 서론이였고, 앞으로 소개할 Slot 컴포넌트가 본론이고 핵심이다.
사실 실제 Slot 컴포넌트 내부에는 Render Delegation할 영역을 선택해주기 위한 Slottable 컴포넌트를 처리하는 로직만 있다. 이 부분은 좀 복잡해서 이번 글에서는 Slot 컴포넌트 내부의 실제로 Render Delegation하는 로직이 들어있는 SlotClone이라는 컴포넌트만 살펴볼 예정이다.
/* -------------------------------------------------------------------------------------------------
* SlotClone
* -----------------------------------------------------------------------------------------------*/
interface SlotCloneProps {
children: React.ReactNode;
}
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
const { children, ...slotProps } = props; // 1.
if (React.isValidElement(children)) { // 2.
const childrenRef = getElementRef(children); // 3.
return React.cloneElement(children, { //4.
...mergeProps(slotProps, children.props),
// @ts-ignore
ref: forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef,
});
}
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});
이 컴포넌트의 흐름은 다음과 같다.
이런 과정을 통해서 Slot 컴포넌트는 최종적으로 Slot이 직접 받은 모든 props 및 ref를 넘겨받은 children을 return하게 된다.
그래서 아래처럼 asChild를 사용할 때,
<Primitive.button asChild ref={ref1} {...butonProps}>
<a {...props} ref={ref2}>아무 요소나 상관없음</a>
</Primitive.button>
이렇게 부모에 props를 주던 children에 직접 ref를 주던 어디에 어떻게 주던지 관계없이 모두 children으로 합쳐져 들어가서 동작하게 되는 것이다.
오픈소스를 소스코드를 이렇게 깊게 파본 것은 처음이여서 쉽지 않았다. 특히나 내 글에서는 생략했지만 원본 소스코드의 type 관련 코드가 정말 복잡했다. 그래도 한참을 보니까 결국 익숙해져서 이해할 수 있었고, 덕분에 오픈소스와 복잡한 타입을 보는 것에 좀 더 친숙해지게 된 좋은 경험이었다.