카테고리 없음

SVG-in-JS를 피해보자

Ahyeon, Jung 2024. 7. 10. 04:04

React Custom Calendar Heatmap

a-honey/react-custom-calendar-heatmap: a heatmap library for Customizable SVG Elements and hover actions (github.com)

Making react-custom-calendar-heatmap log (tistory.com)


peerDependencies

플러그인이 특정 버전의 호스트 애플리케이션을 필요로 한다

pnpm link

로컬에서 개발 중인 패키지를 다른 프로젝트에서 사용할 수 있도록 심볼릭 링크를 생성하는 기능

JS 번들 안에 CSS는 넣지 않는다

https://medium.com/@ahoney0512/dont-use-svg-in-js-18805d0a653d

 

Don’t Use SVG-in-JS

What is SVG and SVG-in-JS

medium.com

 

 스타일 가이드라인 정도는 어느정도 구축해주고 싶었다. 특히 이전에 나만의 디자인시스템을 구축해보다가 tailwindCSS에 의존적이었기 때문에, CSS로 스타일을 입히고 싶었다. 그리고 어차피 라이브러리는 보통 내부 구현에 크게 신경쓰지 않고 사용하기 때문에, 아예 인라인으로 넣어줬다. 그게 번들사이즈를 더 줄일 수 있을거라 생각했다.

const CalendarHeatMap = ({
  values,
  depth,
  gap,
  SvgComponent,
  monthType,
  weekType,
}: CalendarHeatMapProps) => {
  ...

  return (
    <Container
      style={{
        display: "grid",
        gridTemplateRows: "auto 1fr",
        gridTemplateColumns: "auto 1fr",
      }}
    >
      <div/>
      <MonthLabel style={{ gridRow: "0", gridColumn: "1" }} type={monthType} />
      <WeekLabel style={{ gridRow: "1", gridColumn: "0" }} type={weekType} />
      <HeatMap
        style={{ gridRow: "1", gridColumn: "1" }}
        row={7}
        values={heatMapValues}
        SvgComponent={SvgComponent}
        depth={depth}
        gap={gap}
      />
    </Container>
  );
};

export default CalendarHeatMap;

 

생각해보면 인라인 스타일로 적용하는건 처음이었는데 이 작은 컴포넌트 하나에도 지옥이었다. 깊이가 두개밖에 안되는데도 계속해서 스타일을 어디서 적용했는지 잊어버리고, UI가 복잡해지는게 불편했다. 

/* 

Heatmap

*/

.heatmap-container {
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  gap: var(--gap);
  height: calc(23 * 7px);
  max-width: calc(365 / 7px);
}

.heatmap-element {
  position: relative;
  width: 20px;
  height: 20px;
}

.heatmap-hover-element {
  display: none;
  position: absolute;
  top: -10px;
  right: -10px;
  align-items: center;
  justify-content: center;
  background-color: #b5b5b5;
  color: #ffffff;
  font-weight: bold;
  font-size: 12px;
  width: 20px;
  height: 20px;
  z-index: 1;
}

.heatmap-element:hover .heatmap-hover-element {
  display: flex;
}

/* 

CalendarHeatmap

*/

.calendar-heatmap-container {
  display: grid;
  grid-template-rows: auto 1fr;
  grid-template-columns: auto 1fr;
}

.calendar-heatmap-empty-element {
  grid-row: 1;
  grid-column: 0;
}

.month-container {
  grid-row: 1;
  grid-column: 0;
  display: flex;
  justify-content: space-between;
}

.month-element {
}

.week-container {
  grid-row: 0;
  grid-column: 1;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.week-element {
}

.calendar-heatmap {
  grid-row: 1;
  grid-column: 1;
}

 

그래서 그냥 CSS 파일을 별도의 파일로로 제공했다. 그치만 이건 너무 유저한테 다 맡겨버리는거라, 라이브러리가 좀 안정되면 최대한 가져와야겠다.

import { ComponentPropsWithRef } from 'react';
import createStyledComponent from '../models/StyledComponent';
import { BaseObject, KnownTarget, WebTarget } from '../types';
import domElements, { SupportedHTMLElements } from '../utils/domElements';
import constructWithOptions, { Styled as StyledInstance } from './constructWithOptions';

const baseStyled = <Target extends WebTarget, InjectedProps extends object = BaseObject>(
  tag: Target
) =>
  constructWithOptions<
    'web',
    Target,
    Target extends KnownTarget ? ComponentPropsWithRef<Target> & InjectedProps : InjectedProps
  >(createStyledComponent, tag);

const styled = baseStyled as typeof baseStyled & {
  [E in SupportedHTMLElements]: StyledInstance<'web', E, JSX.IntrinsicElements[E]>;
};

// Shorthands for all valid HTML Elements
domElements.forEach(domElement => {
  // @ts-expect-error some react typing bs
  styled[domElement] = baseStyled<typeof domElement>(domElement);
});

export default styled;
export { StyledInstance };

/**
 * This is the type of the `styled` HOC.
 */
export type Styled = typeof styled;

/**
 * Use this higher-order type for scenarios where you are wrapping `styled`
 * and providing extra props as a third-party library.
 */
export type LibraryStyled<LibraryProps extends object = BaseObject> = <Target extends WebTarget>(
  tag: Target
) => typeof baseStyled<Target, LibraryProps>;

 

styled-components도 살펴보면, 태그와 스타일을 받아서 해당 속성을 가진 DOM 요소를 생성해주는 방식이다. 다만, CSS-in-JS는 런타임에서 동작하기 때문에, 렌더링될 때 실시간으로 스타일이 적용되면서 딜레이가 발생한다.

SVG-in-JS는 안티패턴이다

자바스크립트 번들에 SVG가 포함되지 않아야하는 이유

JS에 번들에 SVG 번들을 포함시키는 것은 파싱 및 컴파일 비용을 증가시키고, 메모리 사용을 비효율적으로 만들 수 있다.

 

브라우저는 자바스크립트를 파싱하고 컴파일하는 동안 메인 스레드를 사용한다. 따라서 메인 스레드가 자바스크립트를 파싱하거나 컴파일하는 중이라면 응답 혹은 페이지의 다른 작업들이 중단될 수 있다. 즉, 자바스크립트 파싱 및 컴파일 시간을 줄여 메인 스레드 차단을 최소화하고, 번들 크기를 줄여 브라우저가 초기 렌더링을 빠르게 수행할 수 있도록 해야한다. 또한 파싱된 내용은 페이지가 유지되는 동안 자바스크립트 메모리 힙에 보관되어야 하므로, 메모리 캐시를 효율적으로 사용하기 위해서는 크기를 줄여야 한다.

대안

 

1. 로고 등의 중요한 페이지 정보인 경우, SVG-in-HTML을 통해 초기에 바로 보이게 한다.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Fast Loading Logo</title>
</head>
<body>
  <svg width="100" height="100" viewBox="0 0 100 100">
    <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
  </svg>
  <div id="root"></div>
  <script src="/static/js/bundle.js"></script>
</body>
</html>

 

2. img태그의 lazy-loading을 활용하거나 Astro 라이브러리, 웹팩 등을 추가 설정하여 svg 파일을 외부화한다.

import HeartIcon from "./HeartIcon.svg";

const App = () => <img src={HeartIcon} loading="lazy" />;
import HeartIcon from "./HeartIcon.svg";

const App = () => (
  <svg>
    <use href={`${HeartIcon}#heart`} />
  </svg>
);

<svg viewBox="0 0 300 300" id="heart">
  ...
</svg>

 

3. 이미지 스프라이트처럼 SVG 스프라이트를 활용한다.

<!-- icons.svg -->
<svg>
  <!-- 1: `<defs>` 태그를 추가 -->
  <defs>
    <!-- 2`<symbol>`을 감싸고 ID 추가 (`viewBox`) -->
    <symbol id="icon1">
      <!-- 3: `<symbol>`안에 컨텐츠 복사 -->
      ...
    </symbol>
    <symbol id="icon2">...</symbol>
  </defs>
</svg>
<svg><use href="icons.svg#icon1" /></svg>
<svg><use href="icons.svg#icon2" /></svg>

 

4. SVG에 className을 명시해주고, 해당 className을 통해 세부 스타일을 준다.

 

일단 기존에는 color를 props로 넘겨주면, 그걸 받아서 상속하여 색을 정했다. 이는 결국 로직이 자바스크립트 번들에 포함되기 때문에, 다른 방안을 찾아야한다.

const DefaultElement = ({ className, ...props }) => {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" className={className} {...props}>
      <rect width="100%" height="100%" fill="current" />
    </svg>
  );
};

export default DefaultElement;

 

mask-image 혹은 filter 속성을 활용하여 svg 파일을 CSS 파일에 넣어준다.

.masked-element {
    mask-image: url('mask.svg');
    /* Other mask properties can be used here */
}

.filtered-element {
    filter: url('filter.svg#filter-id');
    /* Other filter properties can be used here */
}

 

5. 서버컴포넌트를 활용한다.

SVG를 바로 렌더링하는 경우

1. 로고, GNB 등 중요하고 사용자가 빠르게 이동 가능해야하는 경우 인라인(CLS 방지)

2. 나머지는 lazy-loading


 


Reference 

dev-blog/JavaScript/breaking-up-with-svg-in-js-in-2023.md at master · yeonjuan/dev-blog (github.com)