Polymorphic한 component 만들기

myung hun kang·2023년 9월 20일
0

Polymorphic 나비

위키피디아에서 다형성 Polymorphism 에 대한 설명은 다음 두가지로 나뉜다.

생물학

다형(多形), 다형 현상은 생물학과 동물학에서 동종 개체들 가운데에서 2개 이상의 대립 형질이 뚜렷이 구별되어 나타나는 것을 말한다.

컴퓨터 공학

프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들(상수, 변수, 식, 오브젝트, 함수, 메소드 등)이 다양한 자료형(type)에 속하는 것이 허가되는 성질을 가리킨다

간단한 예시로
숫자나 날짜를 문자형으로 변환하는 함수가 있다고 하자

다형성이 아닌 즉 단형성으로 이 함수가 구현이 되어있다면 함수는 숫자와 날짜일 경우에 따라 이름이 다를 것이다.

numString = numberToString(숫자) 
dateString = dateToString(날짜)

하지만 다형성을 가지는 함수로 구현이 되어있다면 다음과 같을 것이다.

numString = String(숫자) 
dateString = String(날짜)

다양한 종류의 변수가 들어와도 이것을 string으로 변환해주는 역할을 똑같이 해주는 것. 다형성을 가지는 것이라 할 수 있겠다.

오늘의 내용은 이 다형성이 메인다.

서론

프로젝트를 하다보면 재사용 가능한 컴포넌트의 필요성을 느끼게 된다.

이를 위해서 프로젝트에 맞는 스타일을 가지고 있는 custom button, box, card, input 등 다양한 컴포넌트를 만들게 된다.

이렇게 만들어서 컴포넌트의 이곳 저곳에 유용하게 가져다 쓰면된다.

하지만

인간의 욕심은 끝이 없으니 이를 좀 더 다양한 상황에 대응할 수 있도록 만들고싶다는 욕구가 올라온다.

어떻게하면 만들 수 있을지에 대해서 찾아보다가 타입의 안정성도 가져가고 다양한 상황에 대응가능한

즉 다형성을 가지는 Polymorphic 한 컴포넌트를 만드는 방법을 알게되었다.

이번에는 다형성을 가지는 컴포넌트를 제작하는 과정을 살펴보겠다.

본론

가령 여러가지 text 에 대응을 하는 컴포넌트를 만든다고 가정하자

예를들면 어떤 때는 <span> 태그였으면 좋겠고, 어떨때는
<p> 또는 <a> 태그여서 링크를 넣고 싶다고 해보자

예시는 text 와 관련된 태그들이지만 box라는 컴포넌트를 만들어 button div 등 다양한 형태로 사용하고 싶을수도 있다.

이런 경우를 예시로 polymorphic한 컴포넌트를 만들어보자

일단 간단히 text라는 컴포넌트를 만든다.
그리고 이 text라는 컴포넌트는 color, size, children이 props로 전달되면 좋겠다고 해보자

type TextProps = {
  size?: "sm" | "md" | "lg";
  color: "red" | "green" | "black";
  children: React.ReactNode;
};
const Text = ({ size, color, children, ...props }: TextProps) => {

  return (
	<span className={`class-with-${size}-${color}`} {...props}>
	{children}
	</span>
  );
};
size와 color 값은 그냥 className으로 전달하고 그에 맞게 스타일링이 된다고하고 가볍게 넘어가겠다. 

위와 같은 컴포넌트는 아직 다형성 컴포넌트가 아니다.

컴포넌트가 리턴하는 jsx 문의 html 엘리먼트가 span으로 고정되어 있기 때문이다.

다형성을 가지게하기 위해서는 어떤 html 엘리먼트를 사용할 것인지를 넘겨줘서 컴포넌트가 그에맞는 엘리먼트를 리턴하도록 해야한다.

이럴때 주로 props로 as 값을 전달한다.

type TextProps = {
  size?: "sm" | "md" | "lg";
  color: "red" | "green" | "black";
  children: React.ReactNode;
  as: html 엘리먼트 
};

as에 특정 엘리먼트만 오도록 하려면 "a" | "span" |"p" 와 같이 사용해도 된다 . 하지만 polymorphic한 컴포넌트를 위해서 사용자가 입력하는 값에 따라 다르게 하기 위해서 제네릭을 사용해서 구현해보자

type TextProps<E extends React.ElementType> = {
  size?: "sm" | "md" | "lg";
  color: "red" | "green" | "black";
  children: React.ReactNode;
  as: E
};

이제 as로 사용할 htlm 태그를 가져왔으니 전체 컴포넌트에서 해당 태그를 리턴하도록 바꾼다.

type TextProps<E extends React.ElementType> = {
  size?: "sm" | "md" | "lg";
  color: "red" | "green" | "black";
  children: React.ReactNode;
  as: E
};
const Text = <E extends React.ElementType = "span">({ as, size,color,children, ...props}: TextProps<E>) => {
  const Component = as || "span";
  
  return (
	<Component className={`class-with-${size}-${color}` {...props}>
	{children}
	</Component>
  );
};

여기서는 default로 span 태그를 사용하도록 했다.

이렇게하면 일단 as 로 들어온 태그를 사용할 수 있다.

하지만 사용자가 지정한 props외에 as 로 들어온 태그에 기본으로 사용되는 요소들은 사용하려할때 에러가 난다.

그래서 추가적으로 제네릭으로 들어온 E 타입의 요소들을 TextProps 타입에 가져와야 한다.

// 기존 타입을 사용자 지정 타입으로 분리 
type TextOwnProps<E extends React.ElementType> = {
  size?: "sm" | "md" | "lg";
  color: "red" | "green" | "black";
  children: React.ReactNode;
  as: E
} 

// Omit을 사용해서 E 타입의 요소들에서 TextOwnProps에서 사용된 부분을 제거한다.  
type TextProps<E extends React.ElementType> = TextOwnProps<E> & Omit<React.ComponentPropsWithoutRef<E>, keyof TextOwnProps<E>>;

이걸 합치면

type TextOwnProps<E extends React.ElementType> = {
  size?: "sm" | "md" | "lg";
  color: "red" | "green" | "black";
  children: React.ReactNode;
  as: E
} 

type TextProps<E extends React.ElementType> = TextOwnProps<E> & Omit<React.ComponentPropsWithoutRef<E>, keyof TextOwnProps<E>>

const Text = <E extends React.ElementType = "span">({ as, size,color,children, ...props}: TextProps<E>) => {
  const Component = as || "span";
  
  return (
	<Component className={`class-with-${size}-${color}` {...props}>
	{children}
	</Component>
  );
};

이제 지정한 props 이외에도 태그의 기본 요소들을 에러없이 사용할 수 있게 되었다~

forwardRef로 ref도 전달하기

위와 같이 만들어서 사용하면 대부분의 경우에 문제가 없다.

근데 여기서 더 심화버전으로 ref를 넣는 polymorphic component를 만들어보자

우선 타입설정을 보기 편하게 하기위해 수정을 한다.

  • as props을 따로 AsProps 타입으로 뺀다.
  • ref를 사용하기 때문에 ref 타입을 PolymorphicRef 라는 이름으로 정의한다.
  • 다른 props들과 ref, as 를 포함하는 PolymorphicComponentProps를 정의한다.
  • 컴포넌트 전체 TextComponent 타입을 정의한다.
  • 컴포넌트에 React.forwardRef를 씌우고 새로 정의한 타입을 끼워 넣는다.

위 순서대로 진행해보겠다.

1. as props을 따로 AsProps 타입으로 뺀다.

type AsProps<E extends React.ElementType> = {
  as?: E;
};

2. ref 타입을 PolymorphicRef 라는 이름으로 정의한다.

React에서 forwardRef에 사용되는 ref의 타입RefAttributes<T>로 실제 코드는 다음과 같다.

interface RefAttributes<T> extends Attributes {
  ref?: Ref<T> | undefined;
}
type Ref<T> = RefCallback<T> | RefObject<T> | null;

type RefCallback<T> = {
  bivarianceHack(instance: T | null): void;
}["bivarianceHack"];

interface RefObject<T> {
  readonly current: T | null;
}

그리고 이 RefAttributes는 ComponentPropsWithRef에도 ref를 포함하게 하기위해 사용되고 있다. 따라서 ComponentPropsWithRef에서 ref만 따로 뺀 모습으로 PolymorphicRef를 정의해 볼 수 있다.

type PolymorphicRef<E extends React.ElementType> =
  React.ComponentPropsWithRef<E>["ref"];

3. PolymorphicComponentProps를 정의한다.

앞서 정의한 AsProps와 PolymorphicRef 를 합치고 후에 여러 다른 종류의 다형성 컴포넌트에서 정의한 Props 타입을 받는 구조로 위 타입을 정의한다.

type PolymorphicComponentProps<
  E extends React.ElementType,
  Props = {}
> = AsProps<E> & Props &
  React.ComponentPropsWithoutRef<E> & {
    ref?: PolymorphicRef<E>;
  };

ref를 따로 PolymorphicRef<E>로 정의했기 때문에 E 태그의 기본 요소는 React.ComponentPropsWithoutRef<E>로 받아온다.

4. 전체 TextComponent 타입을 정의한다.

Text 컴포넌트만의 TextOwnProps를 합쳐서 전체 타입을 정의한다.

type TextOwnProps = {
  size?: "sm" | "md" | "lg";
  color?: "primary" | "secondary";
  children: React.ReactNode;
};

type TextProps<E extends React.ElementType> = PolymorphicComponentProps<
  E,
  TextOwnProps
>;

5. React.forwardRef를 씌우고 새로 정의한 타입을 끼워 넣는다.

컴포넌트에 다음과 같이 씌워서 사용하면 된다.

const Text: TextComponent = React.forwardRef(
  <E extends React.ElementType = "span">(
    { size, color, children, as, ...props }: TextProps<E>,
    ref: PolymorphicRef<E>
  ) => {
    const Component = as || "span";
    return (
      <Component className={`class-with-${size}-${color}`} ref={ref} {...props}>
        {children}
      </Component>
    );
  }
);

export default Text;

Text 컴포넌트 에러

위와 같이 만들어서 사용하면 문제없이 사용이 가능할 것이다.

하지만 Text 컴포넌트의 타입과 React.forwardRef 의 타입이 서로 맞지 않은 부분이 존재해 Text 컴포넌트에 빨간 밑줄이 가는것을 볼 수 있다.

이를 해결하기위해 여러 블로그를 뒤지고 찾아본 결과는

React.forwardRef 의 타입을 재정의하는 방법이었다.

발견한 방식이 이것뿐이지만 혹시 다른 방법이 있다면 알려주시면 감사하겠다.

declare module "react" {
  function forwardRef<T, P = {}>(
    render: (props: P, ref: React.Ref<T>) => React.ReactElement | null
  ): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}

이제 forwardRef 가 재정의 되었기 때문에 에러가 사라졌을 것이다.

결론

ref 까지 포함한 모습으로 다형성을 가지는 컴포넌트를 만들어야하는 상황이 생길까? 싶기도 하다.

위 내용은 많은 블로그 글들과 youtube를 통해 보고 이해한 내용이라 할 수 있다.

어렵지만 typescript를 사용하여 위와 같은 것을 만들 수 있다는 점이 참으로 재미있는 것 같다.

기록해두었으니 까먹지 않고 훗날 사용할 수 있기를 바라며 ...

profile
프론트엔드 개발자입니다.

0개의 댓글