🔯 다형성 컴포넌트 시리즈
1. 태그에 따라 바뀌는 React 컴포넌트 만들기(with TypeScript)
2. 태그에 따라 바뀌는 React 컴포넌트 만들기 2 - As 속성 제한하기
3. 태그에 따라 바뀌는 React 컴포넌트 만들기 3 - 컴포넌트 별칭 정하기
부록. 다형성 컴포넌트 개발에 대한 고민 기록 ✅
다형성 컴포넌트, 어떻게 만들 수 있을까?
다형성 컴포넌트 만들기 게시글은 이미 작성되어 올라가 있는 상태지만,
고민에 대한 기록을 남기는 것이 프로젝트에서 해야할 일이라 생각이 들어 다형성 컴포넌트를 만들면서 했던 고민들을 적어본다.
다형성 컴포넌트를 만들 때 고려했던 사항들은 아래와 같았다.
1. as라는 props를 직접 받아서 해당 태그로 렌더링될 것
2. 렌더링 될 수 있는 태그의 종류를 제한할 수 있을 것
3. as props를 입력하지 않아도 기본적으로 렌더링 태그가 지정될 것
4. 렌더링되는 태그에 따라 속성을 검증해줄 것
5. ref 타입 렌더링되는 태그에 따라 검증될 것
6. forwardRef를 사용할 수 있을 것
온라인 상에 잘 알려진 다형성 컴포넌트 개발 방법은 잘 알려진 방법이기도 하고 다형성 컴포넌트 만들기 1편에서도 다뤘었다.
다만 해당 방법에서는 1, 3, 4, 5, 6을 만족하지만 2번 조건인 렌더링 될 수 있는 태그의 종류를 제한할 수 없다는 단점이 있었다.
그런데 생각을 해보자.
만약 Button 컴포넌트를 사용하는데 갑자기 개발자가 `form`나 `input` 태그로 렌더링을 한다면 어떻게 될까?
`input` 태그는 children을 받지 않는 태그이기 때문에 children으로 구현된 Button 태그에서는 정상적으로 동작하지 않는다.
`form` 태그 역시 Button 컴포넌트를 만들려는 개발자의 의도와 상관없는 태그임에 틀림 없다.
문제는 TypeScript 상에서는 큰 오류가 발생하지 않는다는 점이다.
왜나면 다형성 컴포넌트는 모든 HTML 태그를 받을 수 있도록 설계되어있기 때문이다.
즉, 개발자의 의도와 다르게 렌더링 될 수 있다는 단점이 있었고,
이러한 점이 DX(Developer eXperience)에서 큰 문제가 될 수 있겠다 생각하여 2번을 해결하기 위한 탐색 과정에 나섰다.
기존 방법 개량하기
기존 방법을 개량하는 것은 처참하게 실패했는데 이는 TypeScript의 자동완성이 지원되지 않아서였다.
type AsPropType<T extends React.ElementType> = {
as?: T;
};
export type PolymorphicRefType<T extends React.ElementType> = React.ComponentPropsWithRef<T>["ref"];
export type PolymorphicPropsType<T extends React.ElementType, Props = object> = AsPropType<T> &
Props &
Omit<React.ComponentPropsWithoutRef<T>, keyof Props> & {
ref?: PolymorphicRefType<T>;
};
언뜻 보면 기존 코드에서 태그를 제한하는 것은 쉬워보인다.
그냥 Union 타입으로 `'button' | 'a'`를 지정하면 될 것처럼 보였으니....
그러나 유념해야할 부분은 AsPropsType의 T를 `"button" | "a"`로 바꾸는 것은 불가능하다는 점이다.
1편에서 말했듯 as에 값을 넣는 것을 통해 컴포넌트의 렌더링 태그가 결정되는데
이때 TypeScript에서 각 태그에 맞는 속성을 가져올 수 있는 이유는
추론을 통해 as 태그가 T라는 변수가 실제로 넣은 html 태그값으로 대체되기 때문이다.
그런데 이를 `"button" | "a"`로 바꿔버리면, T가 정해질 수 있는 방법이 사라져 올바른 태그 속성 타입을 가져올 수 없게 된다.
즉, 여기서 건드릴 수 있는 유일한 부분은 바로 `T extends React.ElementType` 부분이다.
때문에 이 부분을 `T extends "button" | "a" = "button"`으로 시도해보았으나 이상하게도 자동완성이 동작하지 않았다.
2번의 궁극적인 목적이 태그를 제한하는 것인데
기본값 외에 다른 값들을 보여주지 못한다면 그 기능을 못하는 것과 다름 없다 생각하여 계속 시도하였지만
결국 원인을 찾지 못하고 이를 파악하기 위해 다른 라이브러리들을 탐색하러 가게 된다.
다른 방법은 없을까?
먼저, 온라인 상에서 다형성 컴포넌트를 만드는 여러 방법들을 탐색해보았으나 1편에서 소개한 방법에서 크게 벗어나지 않았다.
결국 온라인 상에서 해결 방법을 찾는 것을 포기했고,
실제 기업에서 개발해 사용중인 디자인 시스템 라이브러리들을 읽어가며 인사이트를 얻어야겠다는 생각을 했다.
MUI, Ant Design, Carbon 디자인 시스템 등 여러가지 라이브러리를 살펴보았으나
가장 좋은 레퍼런스가 되어주었던 것이 바로 Microsoft의 Fluent 2 디자인 시스템이다.
왜냐면 Storybook을 통한 문서화가 잘 되어있을 뿐더러 유일하게 as의 태그를 제한해둔 디자인 시스템이었기 때문이다.
이후 Fluent 2 디자인 시스템의 버튼 코드를 직접 까보면서 구현 방식을 이해하려 노력했다.
Fluent 2 디자인 시스템에서 다형성 컴포넌트 구현 방식
Fluent 2에서는 다형성 컴포넌트를 구별된 유니온(Discriminated Union) 방식으로 구현한다.
구별된 유니온은 타입별로 공통 프로퍼티를 가진 타입을 유니온(`|`)하는 대신
공통 프로퍼티에 `값`을 다르게 설정하여 각 타입 중에 어떤 타입을 사용하는지 결정한다.
이를 다형성 컴포넌트에 적용해보면 아래와 같다.
type PolymorphicType = {
as : 'button';
//... 기타 button 태그 속성 타입
} | {
as : 'a';
href : string;
//... 기타 a 태그 속성 타입
}
만약 as가 각 타입의 공통 프로퍼티로 들어가있고 각 타입이 유니온(`|`)되어있는 것이 보인다.
여기서 as 값을 통해서 만약 as가 `button`이라면 전자의 타입을, `a`라면 후자의 타입을 사용하는 것이다.
언뜻 보면 괜찮아보이나 이 구현 방식은 조건 중 2가지인
- 3번 : as를 명시하지 않아도 기본 렌더링 태그가 지정될 것
을 만족하지 못했다.
물론 구별된 유니온 타입에서도 기본값을 지정할 수 있다.
바로 기본값으로 지정할 as 타입을 `?`를 사용하여 선택 속성으로 넣는 방법이다.
type PolymorphicType = {
as? : 'button'; // button을 기본 태그로 지정
... 기타 button 태그 속성 타입
} | {
as : 'a';
href : string;
... 기타 a 태그 속성 타입
}
문제는 as를 명시하지 않은 체 href와 같이 특정 타입에만 속하는 속성을 사용할 경우 해당 타입으로 타입이 바뀐다는 점이다.
<Button href="..."></Button> // as를 명시하지 않았음에도 'a'태그로 렌더링
즉, 기본값이 특정 속성에 따라 고정되지 못하는 문제가 발생한다.
그래서 결국 새로운 방법을 포기하고 기존 방법을 개량하는 것으로 다시 눈을 돌린다.
5번 : ref 타입 렌더링되는 태그 역시 만족하지 못하는데, 이는 유니온을 통해 정의되었기 때문이다.
Fluent 2에서 forwardRef 타입의 추론은
type ObscureEventName = 'onLostPointerCaptureCapture';
export type ForwardRefComponent<Props> = ObscureEventName extends keyof Props
? Required<Props>[ObscureEventName] extends React.PointerEventHandler<infer Element>
? React.ForwardRefExoticComponent<Props & React.RefAttributes<Element>>
: never
: never;
위 코드로 이루어지는데 이 과정에서 유니온 된 RefAttribute를 가져오기 때문에
`RefAttribute<HTMLButtonElement> | RefAttrubute<HTMLAnchorELement>`의 형태로 가져오게 된다.
이 때문에 ref 타입에 유니온 형태로 정의되어 a태그에 button타입 ref를 넣어도 에러가 나지 않는다.
기존 방법 개량하기 2
Fluent 2를 까보면서 약간의 통찰을 얻은 끝에 기존 타입을 다시 개량하기로 했다.
동시에 forwardRef를 포기했다.
forwardRef 자체가 다형성 컴포넌트와 끼워맞추기 힘들기 때문에 저 모든 조건들을 충족시키는 것이 힘들 것이라 생각했고
가장 우선순위가 낮았던 forwardRef 적용을 포기하였다.
그렇게 여러가지 고민 결과 TypeScript가 자동완성을 지원하지 못하는 이유가
복잡한 다형성 컴포넌트 타입을 이해하기 어렵기 때문이라 추측했다.
때문에 TypeScript에게 힌트를 준다면? 어쩌면 자동완성이 지원되지 않을까?라는 생각을 갖게 되었고
어떻게 힌트를 줄까 고민하다가 Fluent 2의 코드에서 인사이트를 얻을 수 있었다.
export type ARIAButtonSlotProps<AlternateAs extends 'a' | 'div' = 'a' | 'div'> = ExtractSlotProps<
Slot<'button', AlternateAs>
> &
Pick<ARIAButtonProps<ARIAButtonType>, 'disabled' | 'disabledFocusable'>;
위는 Fluent 2에서 Aria Button Props를 정의하는데 사용되는 코드 중 일부이다.
Slot이라는 컴포넌트에 기본 타입인 "button"과 "a" | "div"라는 2개의 타입을 나누어서 넣는 것이 보인다.
이 점에서 착안하여, 기본 타입과 대체 타입을 나누어서 힌트를 제공하면 좋지 않을까? 라는 생각을 하게 되었고 시도해보게 되었다.
그렇게 성공한 것이 바로 아래의 타입이다.
export type PolymorphicPropsType<
T extends React.ElementType,
Props = object,
AlternateAs extends React.ElementType = T
> = AsPropsType<T | AlternateAs> & Props & Omit<React.ComponentPropsWithoutRef<T>, keyof Props>;
그리고 실제 사용할 때는 아래처럼 사용하게 된다.
const Button = <
T extends ButtonDefault | ButtonAlterAs = ButtonDefault,
A extends ButtonAlterAs = ButtonAlterAs
>({
renderAs,
...props
}: PolymorphicPropsWithInnerRefType<T, ButtonBasePropsType, A>) => {
return (
<Box>
...
</Box>
);
};
여러 가지를 시도해 보았지만 여기서 하나라도 뭔가 더 생략되거나 빠지게 된다면,
자동완성이 안된다던가, 기본값 지정이 안되는 문제가 발생했다.
위 코드는 6번을 제외한 1, 2, 3, 4, 5번을 모두 충족하므로 이 코드를 완성본으로 채택하였다.
마무리
이렇게 기나긴 다형성 컴포넌트 개발이 끝났다.
여러가지 방법도 살펴보고 타협도 하였지만,
우선순위가 높았던 조건 1, 2, 3, 4, 5를 만족하는 타입을 개발했다는데 굉장히 만족한다.
혹여나 비슷한 고민을 하는 사람이 있을지는 모르겠지만........참고가 되었으면 좋겠다.
'토이프로젝트 > chistock' 카테고리의 다른 글
컬러 체계 개편기 (0) | 2023.07.29 |
---|---|
아토믹 디자인 도입기 (0) | 2023.07.17 |
디자인 작업 완료 (0) | 2023.07.17 |
디자인 작업과 API에 관해 (0) | 2023.07.13 |
치스톡 프로젝트 재개 (0) | 2023.07.12 |