디자인 시스템 개발 일지 4 ( 타입 관련 유틸리티 )
타입 관련 유틸리티의 필요성
컴포넌트의 다형성
- 같은 컴포넌트가 서로 다른 형태와 역할을 가질 수 있는 능력을 말한다.
- 디자인 시스템을 적용할 떄, 컴포넌트의 다형성에 대해서 생각해볼 수 있다.
< 다음 예시는 다형성을 지원하지 않는 구조의 컴포넌트이다 >
export const exampleButton = ({children, ...props}: Props) => {
return (<button {...props}>
{children}
</button>)
}
이런 구현 방식은 다음과 같은 단점이 있다.
- 확장성 제약, span, button 태그로 바꿔서 경우 제약 존재.
- 버튼 스타일을 그대로 받아서 next의 링크 태그로 바꿔서 넣는 방식이 불가능하다
- children으로 LInk 태그를 넘겨줘도 button 태그안에 a 태그가 들어오게 되어 태그 제약에 걸린다.
다형성을 지원하는 패턴
- 다음과 같이 닫혀 있는 구조의 컴포넌트를 만드는 것이 아니라, 사용하는 측에서 쉽게 변환이 가능하면서도 디자인적인 틀을 제공하는 열린 구조의 컴포넌트를 만들기 위해서 다음과 같은 패턴들이 필요하다.
- 이번 디자인 시스템에서는 크게 as패턴, asChild 패턴을 활용할 것이다.
as 패턴 정의
- 컴포넌트가 직접 DOM을 렌더링하면서, 렌더링할 HTML 태그를 교체할 수 있게 해주는 패턴이다.
- 렌더링 주체는 항상 props를 받는 해당 컴포넌트가 된다. ( 이 부분이 asChild 패턴에서는 달라진다 )
as 패턴의 사용 예시
- 사용자가 시멘틱 태그만 바꿔서 렌더링하고 싶을 떄 사용한다.
- 스타일과 동작은 동일한데 의미만 다른 경우 사용한다.
- ex) Text, HStack, VStack에서 주로 사용될 것으로 예상된다.
< 다음과 같이 as라는 props를 통해 넘겨줄 예정이다 >
<Text as="h1">제목</Text>
<Text as="p">문단</Text>
<Text as="a" href="/docs">문서</Text>
as 패턴의 구현 과정
< 다음은 예시 버튼 컴포넌트이다 >
export const ExampleComponent = ({
as,
children,
...props
}: Props) => {
const Component = as || 'span';
return React.createElement(
Component,
props,
children
);
};
- 이렇게 기본은 버튼으로 두고 as로 다른 태그를 props로 준 경우에는 해당 태그를 렌더링해준다.
- 이제 다음으로 타입까지 지정을 해줄 것이다.
< 다음은 타입 지정이 추가된 버전이다 >
export const ExampleComponent = ({
as,
children,
...props
}: ComponentProps<"span">) => {
const Component = as || 'span';
return React.createElement(
Component,
props,
children
);
};
- 이렇게 ComponentProps를 활용해서 타입을 지정을 해줬다.
- 하지만 이렇게 하면 span 태그에 대한 props만 받을 수 있고 다른 props들에 대한 정적인 타입 분석이 안된다.
<문제가 생기는 구체적인 예시는 다음과 같다.>
// Property href does not exist on type
<ExampleComponent
as="a"
href="/home"
>
Home
</ExampleComponent>
// Property 'type' does not exist on type
<ExampleComponent
as="button"
type="submit"
>
Submit
</ExampleComponent>
- 다형적인 컴포넌트를 지원한다면, as 패턴을 통해 바뀌는 props도 지원해야하는데, 타입적으로 지원하지 못한다는 한계가 존재한다.
- props가 누락되는 경우가 생길 수도 있고, 넣어야하는 props가 타입 체킹에서 막힐 가능성도 있다.
- HT
< 해결 방식 >
// util type which includes "as" type
type PolymorphicProps<E extends React.ElementType> = {
as?: E;
} & React.ComponentProps<E>;
export const ExampleComponent = <E extends React.ElementType = "span">({
as,
children,
...props
}: PolymorphicProps<E>) => {
const Component = as || 'span';
return React.createElement(
Component,
props,
children
);
};
- as props를 포함하는 PolymorphicProps 타입을 제너릭을 활용해서 선언해줌으로써 해결하였다.
- as props로 지정해주는 ElementType과 ComponentProps타입의 인자로 들어가는 ElementType이 같음을 보장해주기 때문에 타입 안전하게 처리가 가능하다.
- 다형성을 지원하는 타입을 더 좁히고 싶은 경우에 사용하는 컴포넌트에서 더 좁혀서 활용할 수도 있다.
< 결과 >
// pass
<ExampleComponent
as="a"
href="/home"
>
Home
</ExampleComponent>
// Property href does not exist on type => right type checking
<ExampleComponent
as="span"
href="/home"
>
Home
</ExampleComponent>
- 이전에 나왔던 예시들 모두 타입 통과하게 되고, 이렇게 잘못된 타입을 입력하는 경우에도 안전하게 처리해준다.
asChild 패턴 정의
- 컴포넌트가 DOM을 렌더링하지 않고 children에게 렌더링 책임을 위임하는 패턴
- 자식 컴포넌트에게 속성 그대로 다 넘겨줌
- 이 때 className, 이벤트 핸들러를 비롯한 props들이 특정 기준에 의해 합쳐지게 된다.
asChild 사용 예시
- DOM 중첩을 피하면서 다른 컴포넌트와 합성해야 할 때 활용한다.
- 대표적인 예시가 next.js에서 링크 태그를 안전하게 감쌀 떄 활용한다.
- 보통 버튼 컴포넌트로 Link컴포넌트 감쌀 때 사용
Slot의 기본 동작 개념
<코드 작성>
// 코드 작성
<Slot {...parentProps}>
<Child {...childProps} />
</Slot>
// 결과
<Child {...mergedProps} />
- Slot은 DOM을 만들지 않음
- 자식 하나만 받아서 props를 합쳐줌
Slot 패턴의 병합 규칙
<이벤트 핸들러>
// 코드 작성
<Slot onClick={parentClick}>
<button onClick={childClick} />
</Slot>
// 결과
onClick = (...args) => {
childClick?.(...args);
parentClick?.(...args);
};
- 이벤트 핸들러의 경우 체이닝된다.
- 둘 다 실행됨.
- 자식 -> 부모 순서로
< className >
```tsx
// 코드 작성
// 결과
```
- 병합됨
- 순서는 자식 -> 부모
< style >
<Slot style={{ color: "red" }}>
<div style={{ fontSize: 12 }} />
</Slot>
// 결과
{ fontSize: 12, color: "red" }
< 이외의 props >
<Slot disabled>
<button disabled={false} />
</Slot>
// 결과
disabled={false}
- 자식 우선으로 병합됨
Slot 패턴을 디자인 시스템 컴포넌트에 적용하기
다음과 같이 유틸리티로 추상화하여 적용하였다. 당근 디자인 시스템을 많이 참고하여 구성하였다.
< Primitive utility의 구성 >
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
export interface PrimitiveProps {
asChild?: boolean;
}
type PrimitivePropsWithRef<E extends React.ElementType> =
React.ComponentProps<E> & PrimitiveProps;
function createPrimitive<E extends React.ElementType>(node: E) {
const Node = (props: PrimitivePropsWithRef<E>) => {
const { asChild, ...primitiveProps } = props;
const Comp = asChild ? Slot : node;
return <Comp {...primitiveProps} />;
};
Node.displayName = `Primitive.${node}`;
return Node satisfies React.FC<PrimitivePropsWithRef<E>>;
}
export const Primitive = {
div: createPrimitive('div'),
span: createPrimitive('span'),
img: createPrimitive('img'),
button: createPrimitive('button'),
label: createPrimitive('label'),
input: createPrimitive('input'),
textarea: createPrimitive('textarea'),
a: createPrimitive('a'),
p: createPrimitive('p'),
h2: createPrimitive('h2'),
ul: createPrimitive('ul'),
li: createPrimitive('li'),
svg: createPrimitive('svg'),
circle: createPrimitive('circle'),
nav: createPrimitive('nav'),
};
- asChild라는 props를 추가로 받을 수 있는 PrimitiveProps를 둔다.
- createPrimitive 함수는 컴포넌트를 반환하는 고차 함수인데, 인자로 받는 ElementType과 같은 타입의 ComponentProps를 받는 컴포넌트를 반환한다.
- Primitive 객체의 멤버 변수는 각 멤버변수 명에 맞는 html태그를 반환하는 함수와 연결되어 있다.
< 사용하는 곳에서의 구성 >
interface Props extends ComponentProps<"button">, PrimitiveProps
export const ExampleComponent = ({...props}: Props) => {
...
return <Primitive.button {...props}/>
}
- PrimitiveProps를 받을 것을 명시해주고 asChild props는 같이 spread로 뿌려줄 수 있습니다.
느낀 점
- 사실 이런 내용을 다 설계하고 갔으면 좋았겠지만, 컴포넌트 설계를 직접 해보면서 고민되는 점들을 하나하나 해결해나가다 보니 다음과 같은 패턴들에 대해 알 수 있었다.
- 이런 패턴을 알면서 팀원들에게 바로바로 글을 써서 공유를 해줬는데, 다음번 PR에 바로 적용해서 컴포넌트를 만들어주는 팀원들도 있었는데 너무 고마웠다.