개발 기술/개발 이야기

[React] 아이콘 컴포넌트를 선언하는 3가지 방법

by GicoMomg 2024. 4. 14.

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_CONFIGSprops 기반으로 아이콘의 프로퍼티값을 생성한다.
// 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가지 방식에 대해 알아보았다. 첫 번째는 아이콘 컴포넌트를 각각 선언하는 방식으로, 커스터마이징이 쉽다는 장점이 있었다. 두 번째, 세 번째는 하나의 아이콘 컴포넌트만 생성하는 방식인데, 매번 아이콘을 선언하지 않아도 되는 장점이 있었다. 하지만 스타일 커스터마이징 위해서는 부차적인 처리가 필요하다.
  • 이처럼 아이콘 컴포넌트를 생성하는 여러 방법이 있는데, 팀에서 관리하는 아이콘의 성격에 따라 컴포넌트를 관리하는 게 좋을 듯 하다.

 

반응형

댓글