다양한 아이콘을 불러와주는 Icon 컴포넌트를 만들어보자
아이콘 컴포넌트의 요구사항은 아래와 같다.
1. svg 파일을 불러와 제공
2. 아이콘 이름을 props로 주면, 해당 아이콘을 렌더링
3. 색상이 props에 따라 변경
4. 크기가 props에 따라 변경
Next.js에서 svg 이미지를 다루는 방법으로 크게 2가지를 들 수가 있는데,
하나는 next/images를 이용하여 img 태그에 src 형태로 넣어주는 방법이고
다른 하나는 리액트 컴포넌트 형태로 가져오는 방법이다.
두 방법 중 src 속성에 svg 파일의 path를 넣어주는 방식으로 2번을 쉽게 구현할 수 있었기 때문에 우선적으로 고려하고 있었다.
그러나 Tailwind를 사용하고 있던터라 svg 파일의 크기와 속성을 변경하기 위해서는
svg 태그에 className을 직접적으로 넣어줘야했는데
img 태그를 사용하는 방식은 img태그 하위 태그로 svg 태그가 들어가서 직접적인 svg 변경이 불가능했다.
그래서 리액트 컴포넌트로 불러오는 방법에서 2번의 요구사항을 충족할 수 있는 방법을 찾다가
쏘카의 React Custom Icon Component 개발기에서 그 해답을 찾을 수 있었다.
만들어보기
import { VariantProps, cva } from "class-variance-authority";
import Setting from "@assets/icons/settings.svg";
const iconVariants = cva("", {
...
});
type IconPropsType = VariantProps<typeof iconVariants>;
const Icon = ({ color, size, ...props }: IconPropsType) => {
return (
<Setting className={classMerge(iconVariants({ color, size }))} {...props} />
);
};
export default Icon;
여기서부터 시작을 해보자.
이는 Tailwind에서 재사용 가능한 컴포넌트로 게시글에서 언급한 cva를 통해 만든 아이콘 컴포넌트이다.
이 컴포넌트는 setting.svg를 불러와 Setting라는 컴포넌트 형태로 반환한다.
이를 위해서 SVGR을 통한 웹팩 로더가 설정되어있는 상태다.
문제는 이 컴포넌트를 Icon이라는 컴포넌트이지만 Setting 아이콘 밖에 표시할 수 없다.
이를 위해서 다형성 컴포넌트를 만드는 방법을 통해 다양한 Icon을 불러올 수 있도록 만들자.
import { VariantProps, cva } from "class-variance-authority";
import classMerge from "utils/classMerge";
import Add from "@assets/icons/add.svg";
import ArrowBack from "@assets/icons/arrowBack.svg";
import DarkMode from "@assets/icons/darkMode.svg";
import Error from "@assets/icons/Error.svg";
import LightMode from "@assets/icons/lightMode.svg";
import Loading from "@assets/icons/loading.svg";
import Refresh from "@assets/icons/refresh.svg";
import Search from "@assets/icons/search.svg";
import Settings from "@assets/icons/settings.svg";
import SyncProblem from "@assets/icons/syncProblem.svg";
const iconVariants = cva("", {
...
});
type IconPropsType = {
icon:
| "Add"
| "ArrowBack"
| "DarkMode"
| "Error"
| "LightMode"
| "Loading"
| "Refresh"
| "Search"
| "Settings"
| "SyncProblem";
} & VariantProps<typeof iconVariants>;
const Icon = ({ icon, color, size, ...props }: IconPropsType) => {
const IconComponent = icon;
return (
<IconComponent
className={classMerge(iconVariants({ color, size }))}
{...props}
/>
);
};
export default Icon;
svg 파일들을 미리 import 해두고, props를 통해서 받은 아이콘을 그대로 IconComponent로 넣어주었다.
이렇게 하면 개발자가 Add라는 props를 입력하면 IcomComponet에 Add가 들어가 Add 아이콘을 렌더링해줄 것이다.
그러나 이 컴포넌트에는 아래와 같은 문제점이 있다
1. TypeScript 오류 발생
2. Icon 컴포넌트에서 사용하지 않는 아이콘도 전부 import
3. 새로운 아이콘을 import 할 때마다 PropsType에도 다시 작성해줘야하는 유지보수 문제
이제 이 문제점들을 해결해보자.
문제점 해결하기
먼저, 사용하지 않는 아이콘들을 필요할 떄만 import 할 수 있는 방법이 필요하다.
JS에서는 이에 대한 방법으로 동적 import라는 것을 제공한다.
이를 이용해 문제점 2번을 해결할 수 있다.
type IconPropsType = {
icon: keyof typeof ICON_MAP;
} & VariantProps<typeof iconVariants>;
const ICON_MAP = {
Add: () => import("@assets/icons/add.svg"),
ArrowBack: () => import("@assets/icons/arrowBack.svg"),
DarkMode: () => import("@assets/icons/darkMode.svg"),
Error: () => import("@assets/icons/error.svg"),
LightMode: () => import("@assets/icons/lightMode.svg"),
Loading: () => import("@assets/icons/loading.svg"),
Refresh: () => import("@assets/icons/refresh.svg"),
Search: () => import("@assets/icons/search.svg"),
Settings: () => import("@assets/icons/settings.svg"),
SyncProblem: () => import("@assets/icons/syncProblem.svg"),
};
const Icon = ({ icon, color, size, ...props }: IconPropsType) => {
const IconComponent = ICON_MAP[icon];
return (
<IconComponent
className={classMerge(iconVariants({ color, size }))}
{...props}
/>
);
};
export default Icon;
위와 같이 ICON_MAP이라는 객체에서 key - 동적 import 형태로 매칭해놓고,
icon props를 통해 동적으로 import 하는 구문을 실행하도록 만들어줬다.
이렇게하면 string인 Key를 받아서 ICON_MAP에 넣고,
ICON_MAP으로부터 리액트 컴포넌트를 받기 때문에 문제점 1번이었던 TypeScript 문제도 해결된다.
type IconPropsType = {
icon: keyof typeof ICON_MAP;
} & VariantProps<typeof iconVariants>;
또, ICON_MAP의 key들로 구성된 타입을 지정할 수 있어 문제점 3번이었던 유지보수 문제도 해결할 수 있다.
이제 ICON_MAP에만 아이콘을 추가하면 IconPropsType에도 자동으로 반영이 될 것이다.
그런데 이렇게만 하면 오류가 난다.
동적 import는 Promise 객체를 return하는데, 이를 다루기 위해 React에서는 lazy와 같은 함수를 사용해야하기 때문이다.
const Icon = ({ icon, color, size, ...props }: IconPropsType) => {
const IconComponent = lazy(ICON_MAP[icon]);
return (
<Suspense fallback={<div>loading...</div>}>
<IconComponent
className={classMerge(iconVariants({ color, size }))}
{...props}
/>
</Suspense>
);
};
export default Icon;
lazy 함수를 사용하면서, 아이콘을 불러올동안 보여줄 로딩을 처리하기 위해 Suspense를 같이 사용해주었다.
lazy가 싫다면, next/dynamic 함수를 통해서도 이와 비슷하게 구현할 수 있다.
const Icon = ({ icon, color, size, ...props }: IconPropsType) => {
const IconComponent = dynamic(ICON_MAP[icon], {
loading: () => <div>loading...</div>,
});
return (
<IconComponent
className={classMerge(iconVariants({ color, size }))}
{...props}
/>
);
};
export default Icon;
이를 스토리북으로 확인해보면 아래와 같다.
다른 아이콘을 선택할 때마다 loading이 뜬 후에 아이콘이 불러와지는 모습을 확인할 수 있다.
useMemo로 최적화하기
위 자체로 사용해도 상관은 없지만, 필요에 따라 조금 더 개선을 해볼 수 있다.
현재 위 아이콘 컴포넌트는 아이콘이 바뀌는 게 아닌 색상, 크기와 같은 값이 변경될 때도
동일한 아이콘을 다시 불러온다는 문제점이 있다.
이는 useMemo를 이용하여 개선할 수 있다.
const Icon = ({ icon, color, size, ...props }: IconPropsType) => {
const IconComponent = useMemo(() => lazy(ICON_MAP[icon]), [icon]);
return (
<Suspense fallback={<div>loading...</div>}>
<IconComponent
className={classMerge(iconVariants({ color, size }))}
{...props}
/>
</Suspense>
);
};
useMemo를 통해서 동일한 icon을 불러올 때에는 저장된 값을 사용하도록 만들어줬다.
이렇게하면 동일한 아이콘의 색상이나 크기가 변경될 때는 다시 import를 해오지 않게 된다.
스토리북에서 확인해보자.
위를 보면 동일한 아이콘에서는 로딩이 발생하지 않고, 다른 아이콘을 불러올 때만 로딩이 발생하는 것을 보여준다.
즉, useMemo를 통해서 icon props가 같을 때는 저장된 값을 사용하는 것이다.
물론, useMemo 역시 비용이 드는 함수인만큼 적절하게 사용하는 것이 중요하겠다.
마무리
//iconMap.ts
const ICON_MAP = {
Add: () => import("@assets/icons/add.svg"),
ArrowBack: () => import("@assets/icons/arrowBack.svg"),
DarkMode: () => import("@assets/icons/darkMode.svg"),
Error: () => import("@assets/icons/error.svg"),
LightMode: () => import("@assets/icons/lightMode.svg"),
Loading: () => import("@assets/icons/loading.svg"),
Refresh: () => import("@assets/icons/refresh.svg"),
Search: () => import("@assets/icons/search.svg"),
Settings: () => import("@assets/icons/settings.svg"),
SyncProblem: () => import("@assets/icons/syncProblem.svg"),
};
export default ICON_MAP;
//Icon.tsx
import { VariantProps, cva } from "class-variance-authority";
import { Suspense, lazy, useMemo } from "react";
import classMerge from "utils/classMerge";
import ICON_MAP from "@constants/iconMap";
const iconVariants = cva("", {
variants: {
color: {
primary: "fill-primary",
primaryFixed: "fill-primary-fixed",
secondary: "fill-secondary",
secondaryFixed: "fill-secondary-fixed",
tertiary: "fill-tertiary",
tertiaryFixed: "fill-tertiary-fixed",
red: "fill-red",
yellow: "fill-yellow",
green: "fill-green",
magenta: "fill-magenta",
onSurface: "fill-surface-on",
onSub: "fill-surface-on-variant",
onPrimary: "fill-primary-on",
onPrimaryFixed: "fill-primary-fixed-on",
onSecondary: "fill-secondary-on",
onSecondaryFixed: "fill-secondary-fixed-on",
onTertiary: "fill-tertiary-on",
onTertiaryFixed: "fill-tertiary-fixed-on",
onRed: "fill-red-on",
onRedSub: "fill-red-variant-on",
onYellow: "fill-yellow-on",
onGreen: "fill-green-on",
onMagenta: "fill-magenta-on",
},
size: {
s: "w-s h-s",
m: "w-m h-m",
l: "w-l h-l",
xl: "w-xl h-xl",
"2xl": "w-2xl h-2xl",
"3xl": "w-3xl h-3xl",
},
},
defaultVariants: {
color: "onSurface",
size: "m",
},
});
type IconPropsType = {
icon: keyof typeof ICON_MAP;
} & VariantProps<typeof iconVariants>;
const Icon = ({ icon, color, size, ...props }: IconPropsType) => {
const IconComponent = useMemo(() => lazy(ICON_MAP[icon]), [icon]);
return (
<Suspense fallback={<div>loading...</div>}>
<IconComponent
className={classMerge(iconVariants({ color, size }))}
{...props}
/>
</Suspense>
);
};
export default Icon;
좀 더 편하게 사용하기 위해서 ICON_MAP은 별도의 파일로 분리해주었다.
이후 ICON을 추가하고 싶다면 iconMap.ts 파일에만 추가해주면
자동으로 Icon 컴포넌트의 PropsType에도 추가가 되어 사용할 수 있게 될 것이다.
위를 통해 다양한 svg 아이콘을 불러와주는 icon 컴포넌트를 만들어 볼 수 있었다.
엄청 쉬울 것이라 생각했지만, 막상 해보니 생각해야할 부분이 많았던 icon 컴포넌트.....
Icon 컴포넌트를 만들면서 다양한 디자인 시스템 라이브러리를 까보면서 어떤 식으로 개발하는지 살펴보았지만,
위 방법이 가장 내가 만들어야하는 요구사항에 적합한 방법이라는 생각이 들어 이 방법으로 진행하게 되었다.
이와 같은 방법을 소개해준 쏘카에 다시 한 번 감사의 말씀을 드린다. ( っ '~')づ
Reference
React Custom Icon Component 개발기
아이콘 등록 프로세스 효율화를 통한 개발자 경험(Developer Experience) 개선
tech.socarcorp.kr
'Frontend > React' 카테고리의 다른 글
태그에 따라 바뀌는 React 컴포넌트 만들기 3 - 컴포넌트 별칭 정하기 (0) | 2023.09.06 |
---|---|
태그에 따라 바뀌는 React 컴포넌트 만들기 2 - As 속성 제한하기 (0) | 2023.09.06 |
Tailwind에서 재사용 가능한 컴포넌트로(with cva, tailwind-merge) (0) | 2023.08.05 |
태그에 따라 바뀌는 React 컴포넌트 만들기(with TypeScript) (0) | 2023.08.03 |
무한스크롤 컴포넌트 구현하기 -1- (0) | 2023.04.25 |