React가 유행하면서 CSS in JS가 많은 인기를 얻었습니다. 리액트를 사용해봤다면 한번쯤은 styled-component나 emotion을 사용해본 경험이 있을 정도입니다. CSS in JS는 다음과 같은 장점이 있습니다.
하지만 모든 기술에는 단점도 존재합니다. 기존의 CSS in JS, 즉 런타임 CSS in JS는 성능을 저하시킬 수 있다는 것입니다. 런타임 CSS in JS는 런타임에 js파일이 실행되면서 style을 생성합니다. style 생성의 규모가 크고 빈번할 수록 성능이 저하될 수 있습니다. 더 구체적인 예시를 들어보겠습니다.
위 이미지와 같이 share버튼을 클릭하면 loading
상태가 true
되고 text-color
가 회색으로 변하는 버튼을 예로 들어보겠습니다. 버튼을 누르면 상태가 변하고 ShareButton이 다시 랜더링됩니다. 랜더링 된다는 뜻은 함수가 다시 실행된다는 뜻과 같습니다.
const [isLoading,setIsLoading]= useState(false)
<ShareButton isLoading={isLoading}> Share </ShareButton>
const ShareButton = styled.button<{isLoading: boolean}>`
color: ${({isLoading})=> isLoading? "gray" : "black"}
`
CSS-in-JS가 대게 두가지 방법으로 런타임에 스타일을 추가합니다.
html
<head>
태그에 <style>
태그를 만들어서 삽입하는 방법 2번 방법으로 스타일을 삽입하면 style
태그가 비어있는 것처럼 보입니다. 개발자 도구에서 document.styleSheets[0].cssRules
로 직접 CSS Rule
을 확인할 수 있습니다.
2 번 동작을 기반으로 예시를 설명해보겠습니다. 초기 랜더링에서 isLoading은 false
이기 때문에 .ghhwYS { color: black; }
와 같은 스타일이 cssRules
에 삽입됩니다. 클릭 후 isLoading이 false
가 되면 다시 스타일을 계산해서 .iXkpNI { color: gray; }
를 cssRules
에 추가합니다.
어떻게 해야 성능문제를 체감할 수 있을까요? 방법은 스타일 컴포넌트에 느린 코드를 삽입하는 것입니다. 그리고 해당 컴포넌트를 여러개를 랜더링합니다. 클릭 후 2~3초가 지나야 리랜더링을 완료하는 것을 확인할 수 있습니다. 데모프로젝트에서 직접 확인할 수 있습니다. 또 CSS in JS 성능 관련 아티클에서 더 자세한 설명을 들을 수 있습니다.
const colorArr = ['skyblue', 'red', 'gray', 'mint', 'blue'];
const Button = styled.button<{ count: number }>`
font-size: 17px;
&:hover{
background-color : ${({ count }) => {
const s = Array(10000000).map(() => 1);
return colorArr[count];
}};
}
`;
<Button count={count}/>
<Button count={count}/>
<Button count={count}/>
// ... 여러개를 랜더링
Runtime CSS in JS의 문제점을 해결하기 위해 Zero-runtime CSS in JS가 등장합니다. Linaria
, Sttiches
, Vanilla Extract
등이 있는데 본 글에서는 Vanilla Extract를 소개합니다.
Vanilla Extract
의 특징은 다음과 같습니다. 기본적으로 CSS Modules-in-TypeScript
라고 생각하면 됩니다.
Tailwind
처럼 Atomic CSS를 구성할 수도 있습니다. Sttitches
처럼 variant 기반 스타일링을 구성할 수 있습니다. build타임에 css파일로 변환되고 head태그에 삽입되기 때문에 bundle 설정은 필수입니다. 거의 모든 번들러를 지원하고 있습니다. bundler-integration에서 사용하는 번들러에 맞게 설정합니다.
// style.css.ts
import { style } from '@vanilla-extract/css';
export const myStyle = style({
display: 'flex',
paddingTop: '3px',
fontSize: '42px',
});
// App.tsx
<div className={myStyle}>안녕하세요</div>
// 결과물
// .s_myStyle__t0h71y0 {
// display: flex;
// padding-top: 3px;
// font-size: 42px;
// }
기본적인 스타일 만들기 입니다. mystyle
는 빌드타임에 s_myStyle__t0h71y0
라는 className
으로 변경되어서 사용됩니다.
스타일을 조각해서 Utility Style을 만들 수 있습니다. 그리고 px단위를 생략해도 자동적으로 px로 변합니다.
export const flexCenter = style({
// cast to pixels
padding: 10, // 10px로 계산됩니다.
marginTop: 25,
// unitless properties
display: 'center',
alignItems: 'center',
justifyContent: 'center',
});
cssVariable을 이용하고 싶다면 createVar
를 이용하면 됩니다. createVar
는 unique한 variable이름을 만들어줍니다.
import { style, createVar } from '@vanilla-extract/css';
const myVar = createVar(); // 결과값 --myVar__t0h71y2
const myStyle = style({
vars: {
[myVar]: 'purple'
// '--puple_color' : 'purple' createVar를 이용하지 않고 바로 작성해도됨
}
});
style들을 합성해서 사용할 수도 있습니다.
const base = style({ padding: 12 }); // s_base__t0h71y4
// base의 className이 합쳐져서 나옴
const secondary = style([base, { background: 'aqua' }]); // s_base__t0h71y4 s_secondary__t0h71y6
Vanilla Extract
에서 제공하는 createTheme를 통해서 테마를 만들 수 있습니다.
css variable을 생성하고 값을 부여하는 방식입니다.
export const [themeClass, vars] = createTheme({
color: {
brand: 'blue'
},
font: {
body: 'arial'
}
});
createTheme
의 결과값인 vars는 변수명들이 저장되어있는 object입니다. 그리고 themeClass는 변수 값들을 매핑해놓은 스타일 조각의 className
입니다.
vars는 테마가 이런 변수들을 가지고 있다는 정보를 알려주는 객체입니다. 실제 blue
나 mint
같은 값들을 themeClass
실제 적용한 것은 themeClass입니다.
// theme 템플릿 혹은 스키마라고 생각하세요!
export const vars = {
color: {
brand: 'var(--color-brand__l520oi1)'
},
font: {
body: 'var(--font-body__l520oi2)'
}
};
export const themeClass = 'theme_themeClass__l520oi0';
// .theme_themeClass__l520oi0는 이런 스타일 조각임
.theme_themeClass__l520oi0 {
--color-brand__l520oi1 : blue;
--font-body__l520oi2 : arial
}
이제 Theme를 지정하고 style을 만들어서 적용해봅시다.
// style.css.ts
export const brandText = style({
color: vars.color.brand, // 이 값은 var(--color-brand__l520oi1) 입니다.
font: vars.font.body,
});
// themeClass와 brandText를 import합니다.
// App.tsx
function App() {
const [count, setCount] = useState(0);
return (
<div className={themeClass}>
<div className={brandText}>안녕하세요</div>
</div>
);
}
앞서 vars는 테마가 이런 변수들을 가지고 있다는 정보를 알려주는 객체라고 했습니다. vars를 통해 우리는 또다른 테마를 만들어낼 수 있습니다. createTheme은 이전 예시와는 다른 인자들을 받았습니다. 형태를 vars를 통해서 정해놓고 변수에 값들을 넣어주는 격입니다. 아래 예시와 같이 두개의 테마를 만들고 상위에서 className
만 변경하면 테마를 변경할 수 있습니다.
export const [themeClass, vars] = createTheme({
color: {
brand: 'blue'
},
font: {
body: 'arial'
}
});
export const otherThemeClass = createTheme(vars, {
color: {
brand: 'red'
},
font: {
body: 'helvetica'
}
});
// App.tsx
function App() {
const [isPrimary, setIsPrimary] = useState(false);
return (
<div className={isPrimary? themeClass : otherThemeClass}>
<div className={brandText}>안녕하세요</div>
</div>
);
}
Vanilla Extract
에서는 Atomic CSS를 쉽게 구현할 수 있는 Sprinkles이라는 도구를 제공합니다.
기존에는 스타일을 지정할 때마다 vars.color.brand
처럼 불필요하게 길게 코드를 작성했습니다. Sprinkles를 이용하면 좀 더 짧게 코드를 작성할 수 있습니다.
sprinkles
에서 사용할 프로퍼티들을 정의해주고 createSprinkles
를 통해 sprinkles
를 만듭니다. 프로퍼티들이 하나하나 쪼개져서 스타일 조각들이 만들어집니다. 그리고 그 스타일 조각들은 각각의 className을 가집니다. (Tailwind CSS
와 비슷하죠.)
import {
defineProperties,
createSprinkles
} from '@vanilla-extract/sprinkles';
const colors = {
'brand': vars.color.brand,
'secondary' : vars.color.secondary
// etc.
};
const colorProperties = defineProperties({
properties: {
color: colors,
}
});
// sprinkles에 프로퍼티들을 넣어줘서
export const sprinkles = createSprinkles(
colorProperties
);
// It's a good idea to export the Sprinkles type too
export type Sprinkles = Parameters<typeof sprinkles>[0];
기존 style함수와 같이 사용할 수 있고, runtime에도 사용할 수 있습니다.
import { style } from '@vanilla-extract/css';
import { sprinkles } from './sprinkles.css.ts';
// style함수와 혼합해서 사용할 수 있음
export const container = style([
sprinkles({
color: 'brand'
}),
{
':hover': {
outline: '2px solid currentColor'
}
}
]);
// 혹은 runtime에 사용
<div className={sprinkles({color: 'brand'})}>안녕하세요</div>
Recipes는 Sttiches와 동일한 기능들을 제공합니다. variant기반으로 컴포넌트를 스타일링 할 수 있습니다. variant 기반 스타일링에 대해 잠깐 설명하겠습니다.
아래 그림처럼 두개의 Box가 있습니다. 텍스트 색과 박스의 테두리 색이 같은 박스 두개가 있습니다. 우리는 자연스럽게 테두리 색과 텍스트 색을 묶어서 생각합니다. 자연스럽게 Box라는 컴포넌트에는 orange
와 blue
variant(변형)이 존재한다고 생각할 수 있습니다. variant로 동일한 기능을 가진 컴포넌트를 여러 가지 스타일 또는 모양으로 나타낼 수 있습니다. 그러면 코드로 한번 구현해보겠습니다.
export const button = recipe({
// base를 기준으로 variant를 생성한다.
base: {
borderRadius: 6,
outline: 'solid',
},
variants: {
color: {
orange: { color: 'orange', outlineColor: 'orange' },
blue: { color: 'blue', outlineColor: 'blue' },
},
},
// fallback으로 설정하는 variant
defaultVariants: {
color: 'blue',
},
});
<div className={button({ color: 'blue' })}>안녕하세요</div>
배경색이 채워진 버튼이라면 글자 색이 하얀색이다.
라는 새로운 요구사항이 추가되었다고 가정해봅시다. (이미지 참고)recipe
는 이런 경우도 대응이 가능합니다. compounded variant
를 사용해서 variant의 조합에 따라 스타일을 추가할 수 있습니다. 예시를 통해 살펴보겠습니다.
export const button = recipe({
base: {
borderRadius: 6,
outline: 'solid',
},
variants: {
color: {
orange: { color: 'orange', outlineColor: 'orange' },
blue: { color: 'blue', outlineColor: 'blue' },
},
fill: {
true: { color: 'white' },
},
},
// Applied when multiple variants are set at once
compoundVariants: [
{
// color 가 orange이고 fill이 true일 때 background는 orange임.
variants: { color: 'orange', fill: true },
style: { backgroundColor: 'orange' },
},
{
variants: { color: 'blue', fill: true },
style: { backgroundColor: 'blue' },
},
],
defaultVariants: {
color: 'blue',
fill: false,
},
});
<div className={button({ color: 'orange', fill:true })}>안녕하세요</div>
recipe
로 생성하니 분기처리가 깔끔해졌습니다. recipe
가 없었더라면 복잡한 분기처리가 되어서 까다로울 것입니다.
여러가지 CSS in JS라이브러리를 사용해본 결과, Vanilla Extract의 개발자 경험은 우수했습니다. 우선 type-safe하다는 점이 마음에 듭니다. styled-component에서는 잘못된 프로퍼티를 사용해도 타입 검사를 제대로 하지 못한다는 단점이 있습니다.(물론 객체형태로 사용하면 됩니다.) 그리고 개인적으로 거추장스러운 문법들이 있었습니다. 예를 들면 (props)=> props.theme.color
같은 것들이 있습니다. Vanilla Extract는 거추장스러운 문법은 버리고 객체형태로 이를 풀어갑니다.
Vanilla Extract는 기능적으로 높은 확장성을 가지고 있습니다. Tailwind CSS를 모방한 Sprinkles
, Sttiches을 모방한 Recipes
그리고 본 글에서는 소개하지 않았지만 Linaria를 모방한 dynamic
을 제공합니다. Vanilla Extract
의 조금 부족했던 사용성을 채워줍니다. 거의 모든 CSS in JS를 총망라 했다고 볼 수 있습니다.
런타임 CSS in JS의 오버헤드가 걱정되거나 여러가지 CSS in JS의 개념들을 학습하고 싶은 분들에게 추천합니다.
참조