1. 들어가며…
- 많은 서비스에서 아이콘을 사용하고 있다. 그래서 아이콘 관리는 선택이 아닌 필수가 되었다.
- 아이콘을 선언하는 방식은 여러가지가 있는데, png, jpg로 관리하거나 혹은 hover, active용 아이콘을 따로 관리하는 경우가 있다. 하지만 이 방식은 아이콘이 재사용되는 경우 확장성에 불리하다.
- 그렇다면 아이콘을 어떻게 선언해야, 확장성을 지키면서 여러가지 상황을 대응할 수 있을까?
- 이번 시간에는 여러 아이콘 라이브러리를 참고하여, 아이콘을 선언하는 3가지 방법에 대해 알아보았다.
- 단, 실제 코드를 그대로 보여주기 보다는 간략한 버전으로 보여줄 예정이며, 실제 라이브러리 코드는 링크로 제시할 예정이다.
2. 아이콘을 선언하는 3가지 방법
1) 각각의 컴포넌트를 선언하는 방법
(1) 라이브러리 살펴보기
- IconPark은 Star수 8k인 아이콘 라이브러리로, Bytedance 팀에서 만들었다.
- 이 라이브러리는 React, Vue, Svg 아이콘을 모두 제공하며, React, Vue, Svg별로 아이콘을 선언했다.
- 그리고 아이콘 선언시 쓰이는 중복 코드는 runtimes/index.ts에 관리하고 있다.
- runtimes/index.ts는 아이콘을 랜더링하는
IconWrapper()
, 아이콘 기본 설정값을 변경할 수 있는IconProvider
등으로 구성되어 있다. - runtimes/index.ts에서 살펴봐야할 포인트는 2가지이다.
📌 포인트1, 아이콘 기본 설정을 변경할 수 있게 Provider를 제공한다. (
IconProvider
)
- 아이콘의 기본 설정값(
config
)을 사용처에서 수정할 수 있도록Provider
를 제공하고 있다. (코드 보기) - 그래서 사용자가 원한다면, 아이콘의 기본 색상, 굵기, 테마별 색상을 지정할 수 있다.
- Vue 아이콘의 경우,
Provider
대신inject
로 구성되어 있다. (코드 보기)
// runtimes/index.ts
export const DEFAULT_ICON_CONFIGS: IIconConfig = {
size: '1em',
strokeWidth: 4,
theme: { ... }
...
};
const IconContext = createContext(DEFAULT_ICON_CONFIGS);
export const IconProvider = IconContext.Provider;
📌 포인트2, 아이콘 선언시 사용하는 반복 코드를 별도 함수로 관리한다. (
IconWrapper()
)
IconWrapper()
은 아이콘 이름 & 랜더링 함수를 기반으로, 아이콘 컴포넌트를 구성한다.
// runtimes/index.ts
export function IconWrapper(
name: string, rtl: boolean, render: IconRender
): Icon {
return (props: IIconProps) => {
/** (1) props값 가져오기 */
const { size, strokeWidth, fill, theme, ... , extra } = props;
/** (2) 기본설정값 가져오기 */
const ICON_CONFIGS = useContext(IconContext);
/** (3) 컴포넌트 고유 ID 생성하기(재계산 차단) */
const id = useMemo(guid, []);
/** (4) 아이콘의 props 구성하기 */
const svgProps = IconConverter(id, {
size,
strokeWidth,
theme,
fill
}, ICON_CONFIGS
/** (5) 클래스 선언하기 */
const cls: string[] = [ICON_CONFIGS.prefix + '-icon'];
cls.push(ICON_CONFIGS.prefix + '-icon' + '-' + name);
if (className) cls.push(className);
/** (6) 아이콘 렌더링하기 */
return (
<span {...extra} className={cls.join(' ')}>
{render(svgProps)}
</span>
);
};
}
번호 | 설명 |
---|---|
(1) | 아이콘의 props 값을 가져온다. |
(2) | provider 로 선언한 아이콘 기본 설정값을 가져온다. (Vue는 inject를 사용) |
(3) | guid 로 컴포넌트의 고유한 ID를 생성한다. 이때 useMemo 를 ID를 생성하는데, 컴포넌트가 리렌더링할 때마다 ID값이 변경되지 않아 ID를 재계산하는 오버헤드를 피할 수 있다. |
(4) | IconConverter 에 (1), (2), (3) 을 넘겨, 아이콘의 props 를 구성한다. |
(5) | 아이콘의 클래스명을 선언한다. |
(6) | props을 기반으로 svg 아이콘을 렌더링한다. |
- 이렇게 선언한
IconWrapper()
는 각각의 아이콘을 생성할 때마다 쓰인다. - 아래 코드는
IconWrapper()
를 사용해, Add 아이콘을 구성한 모습이다.
// icons/Add.tsx
import React from 'react';
import {ISvgIconProps, IconWrapper} from '../runtime';
export default IconWrapper(
'add',
false,
(props: ISvgIconProps) => (
<svg
width={props.size}
height={props.size}
viewBox="0 0 48 48"
fill="none"
>
<rect
x="6"
y="6"
width="36"
height="36"
rx="3"
fill={props.colors[1]}
stroke={props.colors[0]}
strokeWidth={props.strokeWidth}
strokeLinejoin={props.strokeLinejoin}
/>
...
</svg>
)
);
(2) 장단점
- IconPark 아이콘 방식의 장단점은 무엇일까?
장점 | 단점 |
---|---|
svg 아이콘에 다중 색상을 지정할 수 있다. | 아이콘을 추가할 때, svg 파일을 수정해야한다. |
아이콘의 굵기, borderRadius 변경 등 커스터마이징이 쉽다. | 아이콘을 미리볼 수 없어, storybook 같은 ui 도구가 있는 게 좋다. |
react, vue, svg 아이콘을 생성하는 방식이 유사해, 모듈화시 여러 프레임워크를 지원하기 쉽다. |
(3) 아이콘 선언하기(간단 버전)
📌 그렇다면, IconPark 방식으로 아이콘을 선언하면 어떻게 구성할 수 있을까?
size
,strokeWidth
,fill
를 Props로 받는 아이콘을 생성해보자!
props명 | 타입 | 설명 |
---|---|---|
size |
String | Number | 아이콘의 크기 |
strokeWidth |
Number | 아이콘의 두께 |
fill |
String | String[] | 아이콘의 색상 |
- 폴더 구조는 아래와 같다.
| src
|-- icons
|---- Base.tsx // 아이콘 컴포넌트 선언을 위한 렌더링 코드를 관리
|---- AddIcon.tsx // Base.tsx를 기반으로 생성한 아이콘
📌 [Base.tsx]
- 아이콘 기본 설정을 변경할 수 있는Provider
제공
- 아이콘을 렌더링하는 함수IconWrapper()
제공
- 아이콘의 기본 설정값과 설정값 커스터마이징을 위한
Provider
를 선언한다.
// Base.tsx (타입, import 코드 생략)
export const DEFAULT_ICON_CONFIGS: IIconConfig = {
size: "12px",
strokeWidth: 2,
colors: { fill: "#333", background: "transparent" },
prefix: "i",
};
const IconContext = createContext(DEFAULT_ICON_CONFIGS);
export const IconProvider = IconContext.Provider;
...
IconWrapper()
는 아이콘 컴포넌트를 생성하는 함수이다.render()
를 호출하여 SVG 아이콘을 렌더링하고, 클래스와 props를 적용하여 아이콘을 리턴한다.
// Base.tsx
...
export function IconWrapper(name: string, render: IconRender): Icon {
return (props: IIconProps) => {
const { size, strokeWidth, fill, className, ...extra } = props;
const ICON_CONFIGS = useContext(IconContext);
const id = useMemo(guid, []);
const svgProps = generateSvgProps(
id,
{ size, strokeWidth, fill },
ICON_CONFIGS
);
const cls = getClasses({ prefix: ICON_CONFIGS.prefix, name, className });
return (
<span {...extra} className={cls}>
{render(svgProps)}
</span>
);
};
}
// Base.tsx
...
function guid(): string {
// 아이콘의 고유 ID를 생성
const id = (((1 + Math.random()) * 0x100000000) | 0).toString(16);
return `icon-${id}`;
}
// Base.tsx
...
function generateSvgProps(
id: string,
icon: IIconBase,
config: IIconConfig
): ISvgIconProps {
// SVG 아이콘을 렌더링하는 데 필요한 props를 생성
const fill = typeof icon.fill === "string" ? [icon.fill] : icon.fill || [];
const colors = [
fill[0] || config.colors.fill,
fill[1] || config.colors.background,
];
return {
size: icon.size || config.size,
strokeWidth: icon.strokeWidth || config.strokeWidth,
colors,
id,
};
}
// Base.tsx
...
function getClasses({ prefix, name, className }: IIconClasses) {
// 아이콘에 적용될 클래스를 생성
const classes: string[] = [`${prefix}-icon`];
classes.push(`${prefix}-icon-${name}`);
if (className) classes.push(className);
return classes.join(" ");
}
📌 [AddIcon.tsx]
-IconWrapper()
로 렌더링한 아이콘 컴포넌트
// AddIcon.tsx
import React from "react";
import { ISvgIconProps, IconWrapper } from "./Base";
export default IconWrapper("add", (props: ISvgIconProps) => (
<svg width={props.size} height={props.size} viewBox="0 0 48 48" fill="none">
<rect
x="6"
y="6"
width="36"
height="36"
rx="3"
fill={props.colors[1]}
stroke={props.colors[0]}
strokeWidth={props.strokeWidth}
strokeLinejoin="round"
/>
...
</svg>
));
- 실제 사용 예시는 아래와 같다.
<AddIcon size="32" fill="#000000" />
<AddIcon size={32} fill={["blue", "gray"]} />
📌 앞선 코드를 자세히 살펴보고 싶다면, 아래에서 확인할 수 있다.
2) 특정 폴더의 svg 아이콘을 name으로 호출하기
(1) 라이브러리 살펴보기
- grafana는 데이터 시각화 플랫폼으로, 이 라이브러리에 아이콘도 포함되어 있다.
- grafana의 아이콘은 특정 폴더에 있는 아이콘 파일을 렌더링하는 방식이다.
- 아이콘 선언의 포인트는 두 가지이다.
📌 포인트1, 렌더링하고자 하는 아이콘을 특정 폴더에 관리한다.
📌 포인트2, 에서 특정 경로에 있는 svg파일을 탐색해서 렌더링한다.
name
에 렌더링하고 싶은 아이콘의 이름을 넘기면, 해당 아이콘을 렌더링한다.- 아래 예시는 svg/plus.svg를 렌더링한 모습이다.
<Icon name="plus" />
- 렌더링 방식은 Icon.tsx 코드를 보면 알 수 있다. (전체 코드 보기)
- Icon.tsx에서 중 일부 코드를 보면서 동작 방식을 알아보았다.
// Icon/Icon.tsx
import { css, cx } from '@emotion/css';
import SVG from 'react-inlinesvg';
...
// (1) props 가져오기
export const Icon = React.forwardRef<SVGElement, IconProps>(
({ size = 'md', type = 'default', name, className, style, title = '', ...rest }, ref) => {
...
// (2) size에 해당하는 너비, 높이값 가져오기
const svgSize = getSvgSize(size);
// (3) 아이콘 경로 가져오기
const svgPath = `${...}/${name}.svg`;
// (4) 클래스 생성하기
const composedClassName = cx( ... );
// (5) SVG의 src에 아이콘 경로를 넘겨 아이콘 렌더링하기
return (
<SVG
innerRef={ref}
src={svgPath}
width={svgSize}
height={svgSize}
title={title}
className={composedClassName}
style={style}
{...rest}
/>
);
}
);
번호 | 설명 |
---|---|
(1) | size , name , className 등 여러 props를 받는다. |
(2) | size (sm, md 등)에 해당하는 너비, 높이 값을 가져온다. |
(3) | 아이콘 폴더에 있는 svg 파일의 경로를 가져온다. |
(4) | 아이콘의 클래스를 생성한다. |
(5) | react-inlinesvg의 <SVG /> 를 사용해, 특정 경로에 있는 svg 아이콘을 렌더링한다. |
(2) 장단점
- grafana 아이콘 방식의 장단점은 무엇일까?
장점 | 단점 |
---|---|
아이콘을 추가할 때마다 별도로 컴포넌트를 선언할 필요가 없다. | 아이콘에 다중 색상을 적용하거나 굵기를 변경하기 위해 별도 처리가 필요하다. |
아이콘이 svg파일로 존재하기에 아이콘을 미리볼 수 있다. |
(3) 아이콘 선언하기(간단 버전)
📌 그렇다면, grafana 방식으로 아이콘을 선언하면 어떻게 구성할 수 있을까?
size
,color
를 Props로 받는 아이콘을 생성해보자!
props명 | 타입 | 설명 |
---|---|---|
size |
String | 아이콘의 크기 |
color |
String | 아이콘의 색상 |
- 폴더 구조는 아래와 같다.
| public
| -- svgs // 렌더링할 아이콘 관리
| ---- plus.svg
|
| src
| -- icons
| ---- Icon.tsx // public/svgs의 아이콘을 렌더링
📌 [Icon.tsx]
- public/svgs의 아이콘을 렌더링하는 컴포넌트
- react-inlinesvg의<SVG />
를 사용
- 아이콘의 기본 설정값을 지정한다.
// Icon.tsx
const DEFAULT_ICON_CONFIGS = {
size: 20,
color: "black",
prefix: "i",
};
...
DEFAULT_ICON_CONFIGS
와props
기반으로 아이콘의 프로퍼티값을 생성한다.
// Icon.tsx
// ...
function generatorSvgProps(props: IconProps) {
const { name, size, color, style, className } = props;
const svgStyle = {
color: color || DEFAULT_ICON_CONFIGS.color,
...style,
};
const svgSize = size || DEFAULT_ICON_CONFIGS.size;
const svgClassName = cx(
className,
`${DEFAULT_ICON_CONFIGS.prefix}-icon`,
`${DEFAULT_ICON_CONFIGS.prefix}-icon-${name}`
);
return { svgStyle, svgSize, svgClassName };
}
<SVG />
에 파일 경로와 프로퍼티 값을 넘겨, 아이콘을 렌더링한다.
// Icon.tsx
// ...
const Icon = forwardRef<HTMLElement, IconProps>(
({ name, size, color, style, className, ...rest }, ref) => {
const svgPath = `/svgs/${name}.svg`;
const { svgStyle, svgSize, svgClassName } = generatorSvgProps({
name,
size,
color,
style,
className,
});
return (
<SVG
innerRef={ref as React.Ref<SVGElement>}
className={svgClassName}
src={svgPath}
width={svgSize}
height={svgSize}
style={svgStyle}
{...rest}
/>
);
}
);
export default Icon;
- 실제 사용 예시는 아래와 같다.
import Icon from "../src/icons/Icon";
<Icon name="plus" size="32" color="red" />
<Icon className="plus-icon" name="plus" size="50px" color="blue" />
📌 앞선 코드를 자세히 살펴보고 싶다면, 아래에서 확인할 수 있다.
3) 특정 폴더의 svg 아이콘을 컴포넌트로 호출하기
(1) 라이브러리 살펴보기
- 앞선 grafana 방식에서 아이콘명으로 컴포넌트에 접근하고 싶다면?
- 이 경우,
createElement
+module.exports
방식으로 구현할 수 있다.
<PlusIcon size="32" color="red" />
- 유사한 예시로 react-icons이 있다.
- react-icons의 경우,
createElement
로 svg를 아이콘 컴포넌트로 변환해,export
한다. - 모듈 방식의 포인트는 3가지이다.
📌 포인트1, svg를 아이콘 컴포넌트로 변환하는
<GenIcon />
을 생성한다. (코드 보기)
// iconBase.tsx
...
function Tree2Element(tree: IconTree[]): React.ReactElement[] {
return (
tree &&
tree.map((node, i) =>
React.createElement(
node.tag,
{ key: i, ...node.attr },
Tree2Element(node.child)
)
)
);
}
export function GenIcon(data: IconTree) {
return (props: IconBaseProps) => (
<IconBase attr={{ ...data.attr }} {...props}>
{Tree2Element(data.child)}
</IconBase>
);
}
📌 포인트2, 특정 폴더의 svg 파일 기반으로, 아이콘 컴포넌트를 생성한다. (코드 보기)
// scripts/task_files.ts
...
export async function writeIconModuleFiles(
icon: IconDefinition,
{ DIST, LIB, rootDir }: TaskContext,
) {
// (1) 중복 예방
const exists = new Set();
for (const content of icon.contents) {
// (2) 특정 폴더의 아이콘을 탐색
const files = await getIconFiles(content);
for (const file of files) {
...
// (3-1) iconRowTemplate로 아이콘 컴포넌트 생성 (ex. module/fa/FaBeer.mjs)
const modRes = iconRowTemplate(icon, name, iconData, "module");
const modHeader =
"// THIS FILE IS AUTO GENERATED\nimport { GenIcon } from '../lib/index.mjs';\n";
await fs.writeFile(
path.resolve(DIST, icon.id, `${name}.mjs`),
modHeader + modRes,
"utf8",
);
...
exists.add(file);
}
}
}
// (3-2)
export function iconRowTemplate(
icon: IconDefinition,
formattedName: string,
iconData: IconTree,
type = "module",
) {
switch (type) {
case "module":
return (
`export function ${formattedName} (props) {\n` +
` return GenIcon(${JSON.stringify(iconData)})(props);\n` +
`};\n`
);
...
}
📌 포인트3, 모듈 방식으로 사용할 수 있도록, 빌드한다. (코드 보기)
// scripts/build.ts
...
await Promise.all(
icons.map((icon) => taskFiles.writeIconModuleFiles(icon, filesOpt)),
);
(2) 아이콘 선언하기(간단 버전)
- 앞선 라이브러리 예시처럼 생성하려면, 빌드 설정이 필요하다.
- 그래서 간단하게 grafana의
Base.tsx
+createElement
+module.exports
으로 구현했다.
| public
| -- svgs // 렌더링할 아이콘 관리
| ---- plus.svg
|
| src
| -- icons
| ---- Base.tsx // 아이콘 기본 컴포넌트(grafana 방식)
| ---- index.ts // 아이콘명으로 컴포넌트 생성
📌 [icons/index.ts]
- 특정 폴더의 svg파일로 컴포넌트를 생성
- 생성한 컴포넌트를 module.exports 처리
// icons/index.ts
import svgIcon from "./Icon";
import { createElement, memo } from "react";
const IconName = ["plus"]; // (주의) IconName은 file read로 방식으로 변경할 것
function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
IconName.forEach((name) => {
const componentName = `${capitalize(name)}Icon`;
module.exports[componentName] = memo((props) =>
createElement(svgIcon, { name, size: 32, color: "red", ...props })
);
});
- 그러면 아래와 같이 아이콘 컴포넌트에 접근할 수 있다.
import { PlusIcon } from "../src/icons";
<PlusIcon size="32" color="red" />
<PlusIcon name="plus" size="50px" color="blue" />
📌 앞선 코드를 자세히 살펴보고 싶다면, 아래에서 확인할 수 있다.
3. 마치며…
- 이번 시간에는 아이콘 컴포넌트를 선언하는 3가지 방식에 대해 알아보았다. 첫 번째는 아이콘 컴포넌트를 각각 선언하는 방식으로, 커스터마이징이 쉽다는 장점이 있었다. 두 번째, 세 번째는 하나의 아이콘 컴포넌트만 생성하는 방식인데, 매번 아이콘을 선언하지 않아도 되는 장점이 있었다. 하지만 스타일 커스터마이징 위해서는 부차적인 처리가 필요하다.
- 이처럼 아이콘 컴포넌트를 생성하는 여러 방법이 있는데, 팀에서 관리하는 아이콘의 성격에 따라 컴포넌트를 관리하는 게 좋을 듯 하다.
'개발 기술 > 개발 이야기' 카테고리의 다른 글
이미지, 배경이미지의 지연 로드 구현 방법(with. Vue) (2) | 2024.06.10 |
---|---|
이미지, 배경이미지의 지연 로드 구현 방법 (with. intersectionObserver API) (0) | 2024.05.30 |
[vscode] 컬러 변수 뷰어 만들기(2) - colorvariabletracker (0) | 2024.03.31 |
[vscode] 컬러 변수 뷰어 만들기(1) - webview API 사용법 (4) | 2024.03.16 |
Vue 3.4 변경점 파헤치기 (0) | 2024.02.29 |
댓글