radix-ui와 같은 라이브러리는 컴파운드 컴포넌트 패턴을 uncontrolled component 패턴과 조합한 구현을 채택하고 있습니다.
위 다이얼로그 컴포넌트의 재미있는 점은, children으로 전달받은 컴포넌트에 dialog를 열기 위한 핸들러를 전달할 필요가 없다는 점입니다.
<Dialog.Trigger asChild>
<button className="Button violet" size="large">
Edit profile
</button>
</Dialog.Trigger>
컴파운드 컴포넌트는 내부적으로 context를 활용하니,
Dialog 컨텍스트를 활용해 해당 버튼이 클릭되는 것을 어떻게든 감지한다는 것을 알 수 있습니다.
그런데 일반 <div/>와 버블링을 활용하면 span을 div로 감싸다보니 css(스타일) 관점에서 원하지 않는 동작이 발생할 수 있습니다.
따라서 가장 보수적인 방법은 button으로 open, close 이벤트 핸들러를 "몰래" 전달해 주는 건데요,
이 "몰래 전달하기"를 어떻게 구현할 수 있을까요?
1. Dialog 소스 보기
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L88
위 소스 코드의 88번째 라인을 보면 아래와 같은 코드가 있습니다.
일단 우리가 알 수 있는 것은 DiaolgTrigger의 prop으로 전달받은 정보와 context값을 바꾸는 context.onOpenToggle 핸들러를
Primitive.button으로 전달함을 알 수 있습니다.
const TRIGGER_NAME = 'DialogTrigger';
type DialogTriggerElement = React.ElementRef<typeof Primitive.button>;
type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef<typeof Primitive.button>;
interface DialogTriggerProps extends PrimitiveButtonProps {}
const DialogTrigger = React.forwardRef<DialogTriggerElement, DialogTriggerProps>(
(props: ScopedProps<DialogTriggerProps>, forwardedRef) => {
const { __scopeDialog, ...triggerProps } = props;
const context = useDialogContext(TRIGGER_NAME, __scopeDialog);
const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef);
return (
<Primitive.button
type="button"
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
data-state={getState(context.open)}
{...triggerProps}
ref={composedTriggerRef}
onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}
/>
);
}
);
DialogTrigger.displayName = TRIGGER_NAME;
2. Primitive 소스 보기
https://github.com/radix-ui/primitives/blob/main/packages/react/primitive/src/Primitive.tsx#L42
언급하지 않은 부분이 있는데요,
DialogTrigger는 다음과 같은 기능을 갖고 있습니다.
- asChild prop을 사용하면, 자식 컴포넌트를 버튼으로 동작하게 할 수 있습니다.
- asChild prop이 없으면 DialogTrigger 자체를 버튼으로 렌더링하도록 할 수 있습니다.
42L을 봅시다.
우리의 관심사는 asChild가 전달되었을 때입니다.
리턴 부분에서는 DialogTrigger를 통해 전달한 prop들이 primitiveProps로 전달되고 있음을 알 수 있습니다.
최종적으로 Slot을 봐야겠네요
const Primitive = NODES.reduce((primitive, node) => {
const Node = React.forwardRef((props: PrimitivePropsWithRef<typeof node>, forwardedRef: any) => {
const { asChild, ...primitiveProps } = props;
const Comp: any = asChild ? Slot : node;
React.useEffect(() => {
(window as any)[Symbol.for('radix-ui')] = true;
}, []);
return <Comp {...primitiveProps} ref={forwardedRef} />;
});
Node.displayName = `Primitive.${node}`;
return { ...primitive, [node]: Node };
}, {} as Primitives);
3. Slot 소스 보기
https://github.com/radix-ui/primitives/blob/main/packages/react/slot/src/Slot.tsx#L12
해당 소스를 분석하기 위해선 아래 글을 읽고 오시는 것이 좋습니다.
https://beta.reactjs.org/reference/react/cloneElement
interface SlotProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
}
const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
// children 자료구조를 JS 배열로 만듭니다. children이 중첩 배열일 경우 평탄화 합니다. 단일 요소여도 배열화 합니다
const childrenArray = React.Children.toArray(children);
// 뭔가 찾음 ❌
const slottable = childrenArray.find(isSlottable);
// 뭔가 조건을 판단함 ❌
if (slottable) {
// the new element to render is the one passed as a child of `Slottable`
const newElement = slottable.props.children as React.ReactNode;
const newChildren = childrenArray.map((child) => {
if (child === slottable) {
// because the new element will be the one rendered, we are only interested
// in grabbing its children (`newElement.props.children`)
if (React.Children.count(newElement) > 1) return React.Children.only(null);
return React.isValidElement(newElement)
? (newElement.props.children as React.ReactNode)
: null;
} else {
return child;
}
});
return (
<SlotClone {...slotProps} ref={forwardedRef}>
{React.isValidElement(newElement)
? React.cloneElement(newElement, undefined, newChildren)
: null}
</SlotClone>
);
}
// 실제 로직
return (
<SlotClone {...slotProps} ref={forwardedRef}>
{children}
</SlotClone>
);
});
Slot.displayName = 'Slot';
❌ 표시를 사용한 이유는, Radix-ui 내부와 공식문서에서 isSlottable과 Slotable을 활용하는 곳을 찾지 못했기 때문입니다.
따라서 우리는 SlotClone 코드만 확인하면 되겠습니다.
https://github.com/radix-ui/primitives/blob/main/packages/react/slot/src/Slot.tsx#L56
interface SlotCloneProps {
children: React.ReactNode;
}
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
if (React.isValidElement(children)) {
return React.cloneElement(children, {
...mergeProps(slotProps, children.props),
ref: composeRefs(forwardedRef, (children as any).ref),
});
}
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});
SlotClone.displayName = 'SlotClone';
이제 로직을 정리할 수 있겠네요.
- DialogTrigger의 children으로 버튼을 전달하고, primitive.button의 prop으로 다이얼로그을 열고 닫는 함수를 전달합니다
- primitive.button에서 Slot으로 다시 한번 prop들을 넘겨줍니다.
- cloneElement를 사용해 DialogTrigger로 전달된 버튼(children)을 복사한 뒤, children.props에서 children으로 전달된 prop들과, primitive.button으로 전달된 prop들을 결합하여 복사된 버튼에 prop으로 전달한 후, 자식을 렌더링 합니다.
사실 그냥 훅을 외부로 노출하면 되지 뭐 이리 복잡하게 하냐라고 물어볼 수도 있지만,
개발자가 신경써서 관리할 상태가 한두개만 줄어도 앱 개발이 상당히 편해집니다.
컴포넌트의 기능, 라이프사이클과 밀접한 있는 상태는, 컴포넌트 내에 캡슐화 하는게 좋은 것 같습니다.
cloneElement와 Children과 같은 레거시 API의 일반적인 사용은 권장하지 않지만,
Dialog와 같이 UI 기능과 밀접한 컴포넌트를 만들 때에는 여러모로 쓸모가 많은 것 같습니다.
참고 : https://beta.reactjs.org/reference/react/cloneElement
'FrontEnd' 카테고리의 다른 글
빅테크 프론트엔드 기술 인터뷰 : JS 편 (0) | 2023.02.17 |
---|---|
React의 cloneElement API, 기존 엘리먼트를 기반으로 새로운 엘리먼트 생성하는 방법 알아보기 (0) | 2023.02.16 |
[React] 리액트 Children, React Children API의 모든 것 (0) | 2023.02.15 |
Stitches와 Radix를 이용해 디자인 시스템 만들기 (0) | 2023.02.14 |
나만의 CSS Reset(리셋) 만들기 [번역] (0) | 2023.02.13 |