Vanilla Extract란 무엇인가

금교영·2023년 3월 26일
61
post-thumbnail
post-custom-banner

Runtime CSS in JS의 문제

React가 유행하면서 CSS in JS가 많은 인기를 얻었습니다. 리액트를 사용해봤다면 한번쯤은 styled-component나 emotion을 사용해본 경험이 있을 정도입니다. CSS in JS는 다음과 같은 장점이 있습니다.

  • CSS에서 JS문법을 사용할 수 있어서 생산성 🔼
  • 컴포넌트 파일에 관련된 코드들을 함께 둘 수 있음 (colocation)
    • 기존에는 css와 js파일을 분리해야했다.
  • className이 겹치지 않음을 보장한다.(지역 스코프 스타일)

하지만 모든 기술에는 단점도 존재합니다. 기존의 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가 대게 두가지 방법으로 런타임에 스타일을 추가합니다.

  1. html <head>태그에 <style>태그를 만들어서 삽입하는 방법
  2. CSSStyleSheet.insertRule을 사용해서 CSSOM에 직접 삽입

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}/>
// ... 여러개를 랜더링

Vanilla Extract

Runtime CSS in JS의 문제점을 해결하기 위해 Zero-runtime CSS in JS가 등장합니다. Linaria, Sttiches, Vanilla Extract등이 있는데 본 글에서는 Vanilla Extract를 소개합니다.

Vanilla Extract의 특징은 다음과 같습니다. 기본적으로 CSS Modules-in-TypeScript라고 생각하면 됩니다.

  • 빌드타임에 ts파일을 css파일로 만듭니다. (sass와 같음)
  • type-safe하게 theme를 다룰 수 있습니다.
  • 프론트앤드 프레임워크에 구애받지 않습니다.
  • 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는 테마가 이런 변수들을 가지고 있다는 정보를 알려주는 객체입니다. 실제 bluemint같은 값들을 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>
  );
}


notfoundimage

앞서 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>
  );
}

Sprinkles로 좀 더 편하게 스타일링하기

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로 좀 더 깔끔하게 스타일링하기

Recipes는 Sttiches와 동일한 기능들을 제공합니다. variant기반으로 컴포넌트를 스타일링 할 수 있습니다. variant 기반 스타일링에 대해 잠깐 설명하겠습니다.

아래 그림처럼 두개의 Box가 있습니다. 텍스트 색과 박스의 테두리 색이 같은 박스 두개가 있습니다. 우리는 자연스럽게 테두리 색과 텍스트 색을 묶어서 생각합니다. 자연스럽게 Box라는 컴포넌트에는 orangeblue 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의 개념들을 학습하고 싶은 분들에게 추천합니다.

참조

profile
SW Engineer를 꿈꾸는 👨‍🌾
post-custom-banner

0개의 댓글