최근에 나의 메일로 구독한 FE Article에서 2023년 SVG-in-JS와 결별 라는 제목으로 메일이 와 한번 살펴보게 되었다. 살펴본 결과 유익한 내용이라 성능적으로 사내 프로젝트에 개선해보고자 적용해보았다.
첫 번째 단계는 자바스크립트 번들에 SVG가 있는지 확인하는 것이다. 즉, 현재 프로젝트 안에 svg를 쓰고 있는지 확인하는 것이다.
번들 안에 적지 않은 양이 들어있는 경우, 여기 아티클에서는 아래와 같은 그림으로 최적화를 하라고 되어 있다.
이렇게 4가지를 부류로 최적화하는 방법을 나타내주고 있다.
말 그대로 Html에 인라인하는 방법이다.
svg 태그를 그대로 react에서 쓰는 방법은 다음과 같다.
import icon from './Icon.svg';
const TestComponent = () => {
return (
<div>
<defs dangerouslySetInnerHTML={{ __html: icon }}></defs>
</div>
)
}
icon을 직접 __html에 넣으려면 webpack에서 svg-loader 설정을 바꿔주긴 해야한다.
또는 svg를 string으로 관리하는 방법인데 이는 올바른 방법 같지 않다.
webpack에서 svg파일만 svg-inline-loader를 통해 그대로 가져오도록 해야한다.
{
test: /\.svg$/,
loader: 'svg-inline-loader'
}
(이 방법을 썼을 때 svg속성의 width랑 height이 없어지는 이슈가 있었다.)
svg를 img 태그의 src에 넣어 사용하는 방법이다.
마찬가지로 webpack 설정이 필요하다.
const config = {
// … 다른 웹팩 설정들 …
module: {
rules: [
{
test: /\.svg/,
type: "asset/resource",
},
],
},
};
import HeartIcon from "./HeartIcon.svg";
const App = () => <img src={HeartIcon} loading="lazy" />;
지연 로딩 속성인 loading="lazy"
와 같은 속성을 사용하거나 패치 우선순위를 바꾸기 위해 importance="high"
를 사용할 수 있다.
그러나 이렇게 img태그를 사용할 경우 svg가 동적으로 변경하기가 어렵다. css속성으로 svg를 직접 건드릴 경우에도 img태그의 src로 불러오기 때문에 적용되지 않는다.
SVG 스프라이트와 use태그를 사용해 한번의 svg로딩으로 여러개의 svg태그를 한번에 사용하는 방법이다.
여기서 스프라이트 기법은 이미지 스프라이트와 똑같은 기법으로 자세한 내용은 여기서 다루지 않겠다.
import HeartIcon from "./HeartIcon.svg";
const App = () => (
<svg>
<use href={`${HeartIcon}#heart`} />
</svg>
);
이때 use태그의 #heart는 svg의 id를 의미한다.
<svg viewBox="0 0 300 300" id="heart">
...
</svg>
이렇게 id를 설정하는 것들을 모아서 하나의 svg에 모두 담아서 여러개 svg를 관리할 수도 있다.
<!-- icons.svg -->
<svg>
<!-- 1: `<defs>` 태그를 추가 -->
<defs>
<!-- 2`<symbol>`을 감싸고 ID 추가 (`viewBox`) -->
<symbol id="icon1">
<!-- 3: `<symbol>`안에 컨텐츠 복사 -->
...
</symbol>
<symbol id="icon2">...</symbol>
</defs>
</svg>
⚠️
<use>
주의사항:
<mask>
와<clippath>
는 외부에서 로드해온 SVG에서 동작하지 않는다.
SVG는 를 사용할 때 CDN에서 로드될 수 없다.
// 😐 최적이 아님
const Icon = ({ favColor, width }) => (
<svg>
<use
href={`${HeartIcon}#heart`}
fill={favColor ? favColor : "red"}
width={width}
/>
</svg>
);
const App = () => (
<>
<Icon favColor="#FFFF00" />
<Icon width={300} />
</>
);
위처럼 코드를 짜게되면 favColor이 동적, width도 동적이라 결국 JavaScript 번들링에 포함이된다.
그렇게 되면 svg-in-js를 피하지 못하게 된다.
// ✅ Goooood
const Icon = ({ className }) => (
// 클래스를 추가하고 사용자가 CSS를 통해 세부사항을 다룰 수 있도록 합니다. ⬇️
<svg>
<use href={`${HeartIcon}#heart`} className={`heart ${className}`} />
</svg>
);
const YellowHeart = () => <Icon className="yellow" />;
const BigHeart = () => <Icon className="big" />;
const App = () => (
<>
<YellowHeart />
<BigHeart />
</>
);
여기서 내가 헷갈렸던 부분이 하나 있엇는데 결국 Icon도 React Component로 감쌌기 때문에 JS 번들링에 속하는게 아닌가? 라고 생각해서 이게 왜 Goood이지 했다.
다시 한번 살펴본 결과, Icon은 Js 번들링을 피할 수 없다. 그러나 YellowHeart, BigHeart를 className으로 관리하게 됨으로써 최적화를 하게 된 것이다.
/* 👍 good */
.heart {
fill: currentcolor; /* ⬅️ 현재 `color` 적용 */
}
/* ⬇️ 사용하는 디자인 시스템에서 가져온 클래스를 사용할 수 있고 SVG에만 국한되지 않습니다. */
.big {
width: 300px;
}
.yellow {
color: #ffff00;
}
즉 이렇게 css로 svg 속성을 관리하여 동적인 svg를 조금 최적화된 방식으로 생성한 것이다.
⚠️ 주의사항:
<use>
의 너비와 높이는 원본<svg>
에 viewBox 속성 (또는<view>
)가 있어야 한다.
아래에 조금 더 내용이 있는데 아티클을 직접 보는것이 더 도움될 것 같아 내가 적용한 방식으로 넘어가려 한다.
지금 프로젝트에서는 react svg 키워드를 검색하면 가장 많이 흔히들 쓰는 svgr 을 사용해 svg를 react 컴포넌트로 사용하는 방식이 나온다.
const config = {
// … 다른 웹팩 설정들 …
module: {
rules: [
{
test: /\.svg/,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack', 'url-loader'],
},
],
},
};
import React from 'react';
const Icon = ({ ...props }) => {
return (
<svg {...props}>
...
</svg>
);
};
export default Icon;
이렇게 사용하면 svg에 props를 받아 props를 쉽고 다양하게 넣을 수 있다.
아티클에서도 얘기하지만 편의성에 대한 비용을 지불해야 한다.
Granted, this is very convenient and easy to use, but the ease of use comes with a drawback your users have to pay…
import React from 'react';
const Icon = ({ className }) => {
return (
<svg className={className}>
...
</svg>
);
};
export default Icon;
위에서 예를 든것 처럼 className을 하나만 넘기거나 아에 안넘겨서 사용할까 해서 build했더니,
createElement("svg"
를 검색하자 어김없이 빌드한 파일에서 나왔다.
왜? 당연히 svg를 tsx 문법으로 감쌌으니 리엑트는 props가 있건 말건 component로 인식하기 때문이다.
webpack 로더에 위에서 언급한 svg-inline-loader를 통해 설정하고 dangerouslySetInnerHTML로 그대로 삽입하려고 했다.
svg는 그대로 삽입되어 svg를 리엑트에서 import한 것은 문제없이 잘 동작했다.
그러나 css에서 background-url에 넣은 방식에서 제대로 동작하지 않았다.
.wrapper {
background-image: url('./icon.svg');
}
css 에서 svg를 임포트하면 webpack에서 svg 로더를 inline으로 설정했기 때문에 제대로 노출되지 않았다.
svg를 결국 data-url로 관리하고 직접 svg를 import할때는 data-url로 된 것을 svg태그로 다시 변경해서 삽입하였다.
const config = {
// … 다른 웹팩 설정들 …
module: {
rules: [
{
test: /\.(jpg|jpeg|png|svg)?$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 4 * 1024, // 4mb
},
},
},
],
},
};
웹팩은 다음과 같이 설정했다.
type을 asset으로 설정할 경우 기본적으로 url-loader로 되어있다.
자세한 설정은 웹팩 공식 문서를 보기를 바란다.
이렇게 되면 svg가 dataurl 형태로 뽑히게 된다.
그럼 이제 data-url을 어떻게 svg로 바꾸는지 보면 된다.
import React from 'react';
const SvgIcon = ({ icon }) => (
<defs dangerouslySetInnerHTML={{ __html: atob(icon.replace(/data:image\/svg\+xml;base64,/, '')) }}></defs>
);
export default SvgIcon;
SvgIcon이라는 컴포넌트를 만들고 props로 svg를 받는다. 그런다음 atob라는 메소드를 통해 다시 svg태그로 변형시켜 준다.
08/23 변경
위의 예시로 사용해도 크게 문제되진 않지만 svg가 image태그를 사용할 경우 이슈가 생긴다.
image태그를 사용하는 svg는 data-url이 data:image/png;base64,
로 시작되어 atob 메소드가 실행되지 않고 오류가 발생하게 된다.
그래서 url-loader를 사용했을때 svg태그를 그대로 들고오기 위해 코드를 수정하였다.
import React, { useEffect, useState } from 'react';
async function fetchSvgFile(url) {
const response = await fetch(url);
const svgText = await response.text();
return svgText;
}
const SvgIcon = ({ icon }) => {
const [svgString, setSvgString] = useState('');
useEffect(() => {
async function loadSvg() {
const svgContent = await fetchSvgFile(icon);
setSvgString(svgContent);
}
loadSvg();
}, [icon]);
return <span dangerouslySetInnerHTML={{ __html: svgString }}></span>;
};
export default SvgIcon;
webpack 설정은 그대로 사용하고 SvgIcon 컴포넌트만 변경하였다.
data-url을 로드한 뒤 text()로 svg태그를 그대로 들고와 dangerouslySetInnerHTML 에 삽입하였다. 사용하는 곳에서도 기존과 동일하게 사용해주면 된다.
import React from 'react';
import SvgIcon from 'SvgIcon';
import SomeIcon from 'SomeIcon.svg';
const Component = () => {
return <>
<SvgIcon icon={SomeIcon}></SvgIcon>
</>
}
이렇게 사용하면 컴포넌트에 svg 태그가 그대로 삽입되고 js 번들링 이후에도 createElement("svg" 는 없어지게 된다.
Svg 스프라이트 기법을 사용하여 최적화하려 했지만 use태그가 CDN에서 로드될 수 없어서 사용할 수 없었다.
최근 트랜드를 살펴보면 css-in-js도 빠지는거 보면 점점 다시 순수하게 돌아가는 느낌이 든다.. ㅋㅋㅋ
그리고 더 좋은 방법이 있으면 댓글 남겨주시면 시도해보겠습니다!! 🥹🥹🥹🥹