Vanilla extract 공식 문서 따라가기 [1]

numeru·2023년 10월 16일
post-thumbnail

기본 배경

본 글의 목표는 Vanilla extract의 개념 정리이므로 기본 배경은 간단히 요약하고 넘어갑니다.

CSS-in-JS

Javascript 코드에서 CSS를 작성하는 방식입니다.
모든 기술이 그렇듯 기존의 일반 CSS가 갖고 있던 문제를 해결하기 위해 나왔으며, 대표적으로 이야기되는 장점은 다음과 같습니다.

  • 스타일을 지역 스코프로 지정하여 충돌을 걱정할 필요가 없습니다.
  • 스타일을 컴포넌트 레벨로 다루어 관리에 용이합니다.
  • Javascript 변수를 스타일에서 사용할 수 있습니다.

개발자에게 여러 편의성을 제공해주며 많은 곳에서 사용하기 시작했지만, 런타임에 동적으로 스타일을 생성하다 보니 런타임 오버헤드를 더한다는 단점이 있습니다.

zero runtime CSS in JS의 등장

새롭게 제기된 문제를 해결하기 위해 나온 것이 zero runtime CSS-in-JS입니다.
말 그대로 런타임에 스타일을 생성하지 않고, 빌드 타임에 css 변수를 이용해 정적인 css 파일을 생성합니다.


Vanilla extract

vanilla extract는 zero runtime CSS-in-JS 라이브러리 중 하나 입니다.
공식 문서의 가장 첫 페이지에서는 이렇게 소개하고 있습니다.

Zero-runtime Stylesheets in TypeScript.
Use TypeScript as your preprocessor.
Write type‑safe, locally scoped classes, variables and themes, then generate static CSS files at build time.

모든 스타일을 빌드 타임에 생성하며 타입스크립트를 기반으로 안전하게 스타일을 적용할 수 있다는 점,
그 외에 다양한 기능을 제공하고 있다는 점을 내세우고 있습니다.

본격적으로 공식문서를 하나씩 따라가며 살펴보겠습니다. (글을 쓰는 현 시점 최신 버전인 v1.13.0을 바탕으로 합니다.)

1. Getting Started

1️⃣ vanilla extract를 사용하기 위해서는 기본적으로 번들러 세팅이 필요합니다.

vite, webpack 등 많이 사용되는 거의 모든 번들러와 통합이 가능합니다.

2️⃣ .css.ts 확장자로 파일을 만듭니다.

.css.ts 파일 내에 style을 작성하면 내부적으로 생성된 class name을 갖는 locally scoped class를 사용할 수 있습니다.

import { style } from '@vanilla-extract/css';

export const container = style({
  padding: 10
});

3️⃣ style을 불러와 class에 적용합니다.

import { container } from './app.css.ts';

document.write(`
  <section class="${container}">
  ...
  </section>
`);

2. Styling

모든 styling API는 style object를 인풋으로 받습니다.
style object는 다음과 같은 특징이 있습니다.

1️⃣ Unitless Properties

몇몇 속성은 number 타입의 값을 받습니다.
이때 unitless 속성을 제외한 다른 속성의 경우, 이를 pixel 값으로 판단하여 px을 자동으로 값 뒤에 붙입니다.

import { style } from '@vanilla-extract/css';

export const myStyle = style({
  // cast to pixels
  padding: 10, // 10px
  marginTop: 25, // 25px

  // unitless properties
  flexGrow: 1,
  opacity: 0.5
});

2️⃣ CSS Variables

css 변수는 vars 라는 key에 객체 value로 정의합니다.
이때 createVar를 통해 생성된 scoped CSS 변수도 사용할 수 있습니다.

import { style, createVar } from '@vanilla-extract/css';

const myStyle = style({
  vars: {
    '--my-global-variable': 'purple'
  }
});

const myVar = createVar();

const myStyle = style({
  vars: {
    [myVar]: 'purple'
  }
});

3️⃣ Media Queries

@media 라는 key에 객체 value로 media query를 정의합니다.
이후 해당 코드를 CSS로 변환하는 과정에서 media query를 자동으로 파일 제일 하단에 위치시키기 때문에, 언제나 다른 스타일보다 더 높은 우선순위를 가집니다.

import { style } from '@vanilla-extract/css';

const myStyle = style({
'@media': {
  'screen and (min-width: 768px)': {
    padding: 10
  },
  '(prefers-reduced-motion)': {
    transitionProperty: 'color'
  }
}
});

4️⃣ Selector

simple pseudo selectorcomplex selector로 나누어 각각 다른 방법으로 selector를 적용할 수 있습니다.

1. simple pseudo selector

import { style } from '@vanilla-extract/css';

const myStyle = style({
  ':hover': {
    color: 'pink'
  },
  ':first-of-type': {
    color: 'blue'
  },
  '::before': {
    content: ''
  }
});

2. complex selector
selectors 라는 key에 객체 value로 작성합니다.
모든 selector는 &를 이용해 현재 element 만을 대상으로 해야합니다.

import { style } from '@vanilla-extract/css';

const link = style({
  selectors: {
    '&:hover:not(:active)': {
      border: '2px solid aquamarine'
    },
    'nav li > &': {
      textDecoration: 'underline'
    }
  }
});

다른 scoped class를 참조할 수는 있지만, 현재 class 외에 다른 element를 selector의 대상으로 지정할 수 없습니다.

import { style } from '@vanilla-extract/css';

 const invalid = style({
  selectors: {
    // ERROR: Targetting `a[href]`
    '& a[href]': {...},

    // ERROR: Targetting `.otherClass`
    '& ~ div > .otherClass': {...}
  }
 });

 // Invalid example:
 export const child = style({});
 export const parent = style({
  selectors: {
   // ERROR: Targetting `child` from `parent`
     [`& ${child}`]: {...}
   }
 });

 // Valid example:
 export const parent = style({});
 export const child = style({
   selectors: {
     [`${parent} &`]: {...}
   }
 });

현재 element 안에서 전역적으로 하위 노드를 대상으로 하고 싶은 경우 globalStyle을 사용해야 합니다.

import { style, globalStyle } from '@vanilla-extract/css';

export const parent = style({});

globalStyle(`${parent} a[href]`, {
  color: 'pink'
});

5️⃣ Fallback Style

몇몇 브라우저에서 지원하지 않는 CSS 속성 값을 사용할 때, 이에 대응하기 위해 배열을 이용하여 같은 property를 두번 정의할 수 있습니다.

import { style } from '@vanilla-extract/css';

export const myStyle = style({
  // In Firefox and IE the "overflow: overlay" will be
  // ignored and the "overflow: auto" will be applied
  overflow: ['auto', 'overlay']
});

// 결과
.styles_myStyle__1hiof570 {
  overflow: auto;
  overflow: overlay;
}

3. Theming

일반적으로 생각하는 전역적인 테마 뿐만 아니라, 더 낮은 레벨에서 적용할 테마까지 쉽게 정의할 수 있습니다.
createTheme은 기본적으로 contract 객체를 인자로 받아 테마 환경 변수를 제공하는 container classcontract에 대응되는 실제 환경 변수 객체를 반환합니다.

// 생성
import { createTheme } from '@vanilla-extract/css';

export const [themeClass, vars] = createTheme({
 color: {
  brand: 'blue'
 },
 font: {
  body: 'arial'
 }
});

// 결과
export const themeClass = 'theme_themeClass__l520oi0';

export const vars = {
 color: {
  brand: 'var(--color-brand__l520oi1)'
 },
 font: {
  body: 'var(--font-body__l520oi2)'
 }
};

// 적용
const text = style({
  color: vars.color.brand,
  font: vars.font.body
})

<div className={themeClass}>
  <p className={text}></p>
</div>

기존 테마의 contract와 같은 형태에 값만 다른 새로운 테마를 만들 수 도 있습니다.
createTheme의 인자로 기존 테마의 vars를 함께 전달하면 되는데, 이 때 createTheme은 배열이 아닌 단일 string 형태의 class name을 반환합니다.

import { createTheme } from '@vanilla-extract/css';

export const [themeClass, vars] = createTheme({
 color: {
  brand: 'blue'
 },
 font: {
  body: 'arial'
 }
});

export const otherThemeClass = createTheme(vars, {
 color: {
  brand: 'red'
 },
 font: {
  body: 'helvetica'
 }
});

이 방법은 간편하지만 테마의 contract를 정의하는 부분과 테마를 구현하는 부분을 결합한다는 점과, 기존의 테마를 불러와야 하기 때문에 코드를 완전히 분리하는 것이 불가능하다는 trade-off가 존재합니다.
이를 보완하고자 createThemeContract가 등장했습니다. 쉽게 말해 contract만 미리 만들어두고, 필요한 곳에서 이를 기반으로 실제 테마를 구현하는 것입니다.

import {
 createThemeContract,
 createTheme,
 style
} from '@vanilla-extract/css';

// contract 생성
export const vars = createThemeContract({
 color: {
  brand: null // 이때 값은 무시되므로 '', null 등 어느 것을 사용해도 됩니다.
 },
 font: {
  body: null
 }
});


// 사용
export const themeA = createTheme(vars, {
 color: {
  brand: 'blue'
 },
 font: {
  body: 'arial'
 }
});

export const themeB = createTheme(vars, {
 color: {
  brand: 'pink'
 },
 font: {
  body: 'comic sans ms'
 }
});

export const brandText = style({
 color: vars.color.brand,
 fontFamily: vars.font.body
});

// 적용
<div className={condition ? themeA : themeB}>
  <p className={brandText}></p>
</div>

런타임까지 테마의 값을 알지 못하는 경우도 있습니다.
contract는 단지 CSS 변수의 집합이기 때문에, inline styles 설정을 통해 타입 안정성을 유지하면서 런타임에 무언가를 생성하거나 CSS를 주입하는 일 없이 값을 동적으로 적용할 수 있습니다.

import { assignInlineVars } from '@vanilla-extract/dynamic';
import { container, themeVars } from './theme.css.ts';

interface ContainerProps {
 brandColor: string;
 fontFamily: string;
}
const Container = ({
 brandColor,
 fontFamily
}: ContainerProps) => (
 <section
  className={container}
  style={assignInlineVars(themeVars, {
   color: { brand: brandColor },
   font: { body: fontFamily }
  })}
 >
  ...
 </section>
);

4. Composition / Merging

style의 인자로 배열을 전달하면 여러 style을 합성할 수 있습니다.
배열에는 string 타입의 class name이나 style object가 들어갈 수 있습니다.

이 경우 반환되는 class name은 style object를 하나로 합쳐 새롭게 생성한 style의 class name배열에 넣은 class name들이 공백으로 이어져 있는 단일 string 형태입니다.

import { style } from '@vanilla-extract/css';

// base = 'styles_base__8uideo0'
const base = style({ padding: 12 });

// primary = 'styles_base__8uideo0 styles_primary__8uideo1'
const primary = style([base, { background: 'blue' }]);

style object 없이 class name 만 넣어도 아무 속성 없는 class name이 생성되고, 전달한 class name들이 이어서 붙게 됩니다.
style object에서 중복된 속성을 정의할 경우 뒤에 있는 객체가 더 높은 우선순위를 갖습니다.

이 과정을 실제 코드에서 간단히 살펴보겠습니다.

function composedStyle(rules: Array<StyleRule | ClassNames>, debugId?: string) {
  const className = generateIdentifier(debugId);
  registerClassName(className, getFileScope());

  const classList = [];
  const styleRules = [];

  for (const rule of rules) {
    if (typeof rule === 'string') {
      classList.push(rule);
    } else {
      styleRules.push(rule);
    }
  }

  let result = className;

  if (classList.length > 0) {
    result = `${className} ${dudupeAndJoinClassList(classList)}`;

    registerComposition(
      {
        identifier: className,
        classList: result,
      },
      getFileScope(),
    );

    if (styleRules.length > 0) {
      // If there are styles attached to this composition then it is
      // always used and should never be removed
      markCompositionUsed(className);
    }
  }

  if (styleRules.length > 0) {
    const rule = deepmerge.all(styleRules, {
      // Replace arrays rather than merging
      arrayMerge: (_, sourceArray) => sourceArray,
    });

    appendCss({ type: 'local', selector: className, rule }, getFileScope());
  }

  return result;
}

export function style(rule: ComplexStyleRule, debugId?: string) {
  if (Array.isArray(rule)) {
    return composedStyle(rule, debugId);
  }

  const className = generateIdentifier(debugId);

  registerClassName(className, getFileScope());
  appendCss({ type: 'local', selector: className, rule }, getFileScope());

  return className;
}
  1. style 함수에서 인자로 받은 rule이 배열인지 아닌지에 따라 분기처리합니다.
  2. 두 경우 모두 generateIdentifier 함수를 통해 새로운 class name을 생성합니다.
  3. rule이 배열일 경우, composedStyle 함수로 가서 각 요소를 string 타입의 class name과 style object로 분리합니다.
  4. class name들은 dudupeAndJoinClassList 함수를 이용해 중복을 제거하고 생성한 class name에 공백으로 이어 붙입니다.
  5. style object는 deepmerge를 이용해 하나로 병합하고, 생성한 class name으로 새로운 style로 만듭니다.
  6. 이렇게 만들어진 css style과 class name을 파일에 추가하고 최종적으로 합성된 class name을 반환합니다.
  7. rule이 배열이 아닐 경우, 인자로 받은 하나의 style object 대해 5, 6번과 유사한 과정을 수행합니다.

만약 이렇게 합성된 class name이 selector에 사용될 경우, 뒤에 붙은 class name들은 제거되고 가장 앞에 있는 class name 하나만 남게 됩니다.

import { style } from '@vanilla-extract/css';

const base = style({ padding: 12 });

const primary = style([base, { background: 'blue' }]);

const text = style({
 selectors: {
  [`${primary} &`]: {
   color: 'white'
  }
 }
});

// 결과
.styles_base__1hiof570 {
 padding: 12px;
}
.styles_primary__1hiof571 {
 background: blue;
}
.styles_primary__1hiof571 .styles_text__1hiof572 {
 color: white;
}

마무리

이렇게 공식 문서를 따라 vanilla extract의 기본 개념에 대해 살펴봤습니다.
여기서 언급하지 못했거나 자세히 다루지 못한 API들, 그 외 추가로 제공하는 기능들은 다음 글에서 정리해보겠습니다.

0개의 댓글