디자인 시스템 개발 일지 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에 바로 적용해서 컴포넌트를 만들어주는 팀원들도 있었는데 너무 고마웠다.