Vanila-extract Study (+좋은점 +만난버그 +사용법)

Seuling·2023년 7월 24일
4
post-thumbnail

Vanila-extract

| 만난버그 부분은 수정중...!!

좋은점 ? 특징!

  • Zero-runtimeStylesheets inTypeScript.

    • TypeScript에서 스타일시트를 작성하고 사용할 때, 런타임에 추가적인 처리나 계산 없이 작동하는 것을 의미함
    • 일반적으로 emotion과 같은 CSS-in-JS 라이브러리를 사용하면, 런타임에 스타일을 동적으로 생성하고 적용하는 과정이 포함된다.
      (추가적인 JavaScript 실행시간과 성능 오버헤드를 초래할 수 있음)
    • vanila-extract는 모든 스타일이 빌드타임에 이미 생성되어 있고, 런타임에는 단순히 해당 스타일을 적용하기만 하면 됨
    • TypeScript는 정적 타입 체크를 제공하기 때문에, 빌드 타임에 스타일 시트의 유효성을 검사하고 오류를 감지하는 것이 가능함
  • Use TypeScript as your preprocessor.

    • TypeScript를 CSS 전처리기처럼 사용하는 것을 의미함
    • 스타일 코드를 작성하는 동안 TypeScript의 강력한 타입 체크 및 자동완성 기능을 활용할 수 있음
  • Write type‑safe, locally scoped classes, variables and themes

    • 개발자가 타입에 안전한 스타일 클래스, 변수, 테마를 작성할 수 있음
    • 'locally scoped' : 해당 클래스나 변수가 해당 컴포넌트나 모듈에서만 유효하도록 범위가 지정되어, 다른 곳에서는 접근하거나 수정할 수 없다는 것을 의미함(스타일 충돌 방지)
      • 과연 그럴까..? 내가 마주한 버그들은 예상외였다.
        아래에서 좀더 자세히 버그를 보기!
  • then generate static CSS files at build time.

    • 빌드 과정에서 정적 CSS 파일을 생성한다는 것을 의미함
    • 즉, 빌드 과정에서 모든 스타일이 이미 생성되고, 런타임에는 이를 그대로 적용하게 됨(런타임 성능 향상)
  • All the styling APIs in Vanilla Extract take a style object as input.

    • Vanilla Extract의 모든 스타일링 API가 JavaScript 객체 형태의 스타일을 입력으로 받는다 (이 컨셉이 제일 편리했던것 같다!)
  • Describing styles as a JavaScript object enables much better use of TypeScript through your styling code, as the styles are typed data-structures like the rest of your application code.

    • JavaScript 객체를 사용하면 스타일을 타입화된 데이터 구조로 다루는 것이 가능해져, 애플리케이션의 나머지 코드와 일관성을 유지하면서도 타입 안전성을 보장할 수 있음
  • It also brings type-safety and autocomplete to CSS authoring (via csstype).

    • Vanilla Extract가 'csstype' 라이브러리를 이용하여 CSS 작성 시 타입 안전성을 제공하고, 자동완성 기능을 지원함
    • csstype은 TypeScript를 위한 CSS 타입 선언 라이브러리로, CSS 속성의 이름과 값에 대한 자동완성 및 타입 검사를 가능하게 함
  • Type-safe static CSS

    • 왜 css에 타입이 필요한거지?
      • 타입 안전성(Type Safety) : 버그 예방, 수정
      • 자동완성(Auto-completion)
      • 문서화(Documentation) : CSS에서 사용 가능한 속성과 그 값의 타입을 명시하면, 다른 개발자들이 코드를 이해하고 사용하기가 더 쉬워짐
      • 재사용성(Reusability)

만난 버그...

Q. 리액트, vanila-extract 로 css를 구현했는데, 로컬에서는 문제가없었는데 배포만 하면 스타일이 초기화되는 문제가발생했어,
확인해보니 뭐 예를들어 타이틀이 2가지가 있으면, 하나는 기본 타이틀 Title이고, 그 Title을 import 해서 커스텀해서 PriceTitle 을 만들었더니 PriceTitle을 여러번 들어오면 Title의 기본 속성이 다 덮어씌여지는거야,
그래서 보다보니 Title의 className과 PriceTitle className은 달랐지만, Title의 해시값이 chunk에 계속 덮여씌어지더라고, 그래서 새로운 해시값의 청크로 새로운 기본 Title의 스타일이 적용되는거였어!

  • 정상적인 부분

  • 문제가 되는 부분

Q. chunk가 뭐지??

A. Webpack에서 "chunk"란 코드를 작은 조각으로 나누는 것을 의미합니다. Webpack은 이러한 chunk를 생성하고, 이를 통해 여러분의 애플리케이션을 더욱 효율적으로 로딩할 수 있게 도와줍니다.
Entry Chunk: 이는 애플리케이션의 진입점입니다. 여기에는 애플리케이션 로직이 포함되어 있습니다.
Normal Chunk: 이는 애플리케이션의 비동기적으로 로딩되는 부분입니다. 이런 코드 조각은 애플리케이션의 시작 시에는 필요하지 않지만, 특정 조건이나 상황에서 필요할 수 있습니다.
Initial Chunk: 이는 애플리케이션이 처음 시작될 때 로딩되는 코드입니다. 여기에는 애플리케이션의 핵심 로직이 포함됩니다.
Webpack은 이런 chunk들을 생성하고 관리하면서, 필요한 시점에 적절하게 로딩하게 합니다. 이러한 과정은 코드 스플리팅(code splitting), 비동기 로딩(async loading) 등과 같은 기능을 가능하게 합니다.

이 문제를 해결하기 위해 가설을 생각하고 검증하는 과정을 거쳤다.
가설1. title이라는 className이 다 같아서 그런거 아닐까 ? NO!
가설2.전역스타일이 아니여서 적용이안되는걸까?
가설3. composition 으로 분리하면 ?
가설4. 그렇다면 Import 순서 문제아닐까?
가설5. createTheme 을 이용해서 한정된 범위에서 스타일을 적용하면 hash 값이 다르게 아니면 chunk가 다르게 들어가지않을까 ?
모두 해결되지않아서, 결국에는 개별적인 Title 스타일을 갖기로 하여 해결하였다...!

사용법

import { myStyle } from './app.css.ts'

function App() {

  return (
    <>
      <div className={myStyle}>
       style test!  
      </div>
    </>
  )
}

export default App
  • CSS Properties - camelCase
//app.css.ts
import { style, globalStyle } from '@vanilla-extract/css';

export const myStyle = style({
  display: 'flex',
  paddingTop: '3px'
});

globalStyle('body', {
  margin: 0
});

변환 시 ⬇️

.app_myStyle__sznanj0 {
  display: flex;
  padding-top: 3px;
}
body {
  margin: 0;
}
  • Unitless Properties
    • 일부 속성은 숫자를 값으로 사용할 수 있음
    • 단위가 없는 프로퍼티를 제외한 이러한 값은 픽셀로 간주되며 값에 자동으로 px가 추가됨 -> 조심하자!!!! 이부분에서 버그를 .... 또 만났었다.......
      분명.. 공식 문서에서는 된다고 했는데..... 이부분 때문에 장애를 잠깐 낸 적이있다! (resize 될때 resize 값이 숫자로 되기에 px 를 안붙였는데 이부분이 정상적으로 작동하지않아서 다음부터는 그냥 다 px를 붙인다!)
// styles.css.ts

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

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

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

변환 시 ⬇️

.styles_myStyle__1hiof570 {
  padding: 10px;
  margin-top: 25px;
  flex-grow: 1;
  opacity: 0.5;
}
  • Vendor Prefixes : PascalCase + removing -
import { style } from '@vanilla-extract/css';

export const myStyle = style({
  WebkitTapHighlightColor: 'rgba(0, 0, 0, 0)'
});

변환 시 ⬇️

.styles_myStyle__1hiof570 {
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
  • Css Variables : nested within the vars key
import { style } from '@vanilla-extract/css';

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

변환 시 ⬇️

.styles_myStyle__1hiof570 {
  --my-global-variable: purple;
}
  • The vars key also accepts scoped CSS variables, created via the createVar API.
import { style, createVar } from '@vanilla-extract/css';

const myVar = createVar();

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

변환 시 ⬇️

.styles_myStyle__1hiof571 {
  --myVar__1hiof570: purple;
}
  • Media Queries : @media
    • @media 키 안의 스타일은 CSS 규칙 순서 우선순위에 따라 항상 다른 스타일보다 우선순위가 높음
import { style } from '@vanilla-extract/css';

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

변환 시 ⬇️

@media screen and (min-width: 768px) {
  .styles_myStyle__1hiof570 {
    padding: 10px;
  }
}
@media (prefers-reduced-motion) {
  .styles_myStyle__1hiof570 {
    transition-property: color;
  }
}
  • Selectors : simple pseudo selectors
import { style } from '@vanilla-extract/css';

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

변환 시 ⬇️

.styles_myStyle__1hiof570:hover {
  color: pink;
}
.styles_myStyle__1hiof570:first-of-type {
  color: blue;
}
.styles_myStyle__1hiof570::before {
  content: "";
}
  • Selectors : complex selectors selectors + &
import { style } from '@vanilla-extract/css';

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

변환 시 ⬇️

.styles_link__1hiof570:hover:not(:active) {
  border: 2px solid aquamarine;
}
nav li > .styles_link__1hiof570 {
  text-decoration: underline;
}
  • 다른 scope에 있는 class 참조
import { style } from '@vanilla-extract/css';

export const parent = style({});

export const child = style({
  selectors: {
    [`${parent}:focus &`]: {
      background: '#fafafa'
    }
  }
});

변환 시 ⬇️

styles_parent__1hiof570:focus .styles_child__1hiof571 {
  background: #fafafa;
}
  • 서로를 참조할 때에는 get을 사용함
import { style } from '@vanilla-extract/css';

export const child = style({
  background: 'blue',
  get selectors() {
    return {
      [`${parent} &`]: {
        color: 'red'
      }
    };
  }
});

export const parent = style({
  background: 'yellow',
  selectors: {
    [`&:has(${child})`]: {
      padding: 10
    }
  }
});

변환 시 ⬇️

.styles_child__1hiof570 {
  background: blue;
}
.styles_parent__1hiof571 .styles_child__1hiof570 {
  color: red;
}
.styles_parent__1hiof571 {
  background: yellow;
}
.styles_parent__1hiof571:has(.styles_child__1hiof570) {
  padding: 10px;
}

get selectors()는 JavaScript의 getter 함수로, 객체 속성에 접근할 때마다 특정 작업을 수행할 수 있게 해줍니다. 여기서는 child의 selectors 속성을 불러올 때마다 parent 스타일을 참조하여 새로운 선택자를 생성하게 됩니다. 이를 통해 parent와 child가 서로를 참조하는 관계를 생성합니다.

Style Composition

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

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

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

const secondary = style([base, { background: 'aqua' }]);

변환 시 ⬇️

.styles_base__1hiof570 {
  padding: 12px;
}
.styles_primary__1hiof571 {
  background: blue;
}
.styles_secondary__1hiof572 {
  background: aqua;
}
  • 만약 자식 요소에만 적용하고싶을땐??
const base = style({ padding: 12 });

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

export const text = style({
  selectors: {
    [`${primary} &`]: {
    //   color: '#000'
    }
  }
});
<div className={primary}>
        <div className={text}>
        style test!  
        </div>
</div>

packages

Recipes : multi-variant styles with a type-safe runtime API

<a className={css.recipeTest({isActive: true})} ></a>
export const recipeTest = recipe({
  base: {
    display: 'block',
    textDecoration: 'underline',
    outline: 'none',
    cursor: 'pointer',
    WebkitTapHighlightColor: 'transparent',
    //공통 스타일
  },
  variants: {
    isActive: {
      true: {
        textDecoration: 'underline',
      },
      false: {
        textDecoration: 'none',
      },
    },
  },
})

Dynamic : 동적으로 변수를 만들어야하는 경우

import { assignInlineVars } from '@vanilla-extract/dynamic'

<div
      className={css.dynamicStyle}
      style={assignInlineVars({
        [css.dynamicVar]: `${resize}`,
      })}
    />
import { createVar, globalStyle, style } from '@vanilla-extract/css'

export const dynamicVar = createVar()

export const dynamicStyle = style({
  marginBottom: dynamicVar,
  transition: 'margin-bottom 0.3s ease',
})

Case 별로

이부분은 emotion to vanila-extract로 마이그레이션을 진행하며 겪었던 주로 사용하는 익숙하지 않았던 부분을 정리해보고자 한다

  • :disabled , &:active
 selectors: {
      '&:active': {
        backgroundColor: 'red'
        transition: 'background-color 0s',
      },
    },
    ':disabled': {
      backgroundColor: 'gray',
      color: 'white',
    },
  • li : last-child
export const root = style({
  display: 'flex',
  flexDirection: 'column',

  selectors: {
    [`li:last-child &`]: {
      marginRight: '1rem',
    },
    [`li:first-child &`]: {
      marginLeft: '1rem',
    },
  },
})
export const item = style({
  marginRight: '1rem',

  selectors: {
    [`&:last-child`]: {
      marginRight: '0',
    },
  },
})
  • className 2개이상
<section
      className={[
        css.container,
        ...(props.className ? [props.className] : []),
      ].join(' ')}
    >

==

<section
      className={[
        css.container,
        props.className ? props.className : ""),
      ].join(' ')}
    >
  • 만약 ios별로 padding을 다르게 줘야한다면??
paddingBottom: [
    `calc(constant(safe-area-inset-bottom) + 1.25rem)`,
    `calc(env(safe-area-inset-bottom) + 1.25rem)`,
  ],
  • vanila-extract에서 adjacent sibling 처리 방법
    • globalStyle({selector},{style}) 을 이용하여 처리
export const scrollerbase = style({})
globalStyle(`${scrollerbase}  li + li`, {
  marginLeft: '0.5rem',
})

globalStyle(`${scrollerbase} > :last-child`, {
	marginRight: '0.75rem',
})

이렇게 해도 되지만, 전역 스타일을 하는것이기에 좀더 좋은방법은 무엇일까 ?

import { ReactNode } from 'react'
import * as css from './FleaMarketFilterChipList.css'
type ListProps = {
  children?: ReactNode
  key?: string
}

const ChipList = (props: ListProps) => {
  return (
    <li key={props.key} className={css.chipItem}>
      {props.children}
    </li>
  )
}

export default ChipList
export const chipItem = style({ 
	selectors: { 
  [`${scrollerbase} > & + &`]: { marginLeft: '0.5rem', }, 
  [`${scrollerbase} > &:last-child`]: { marginRight: '0.75rem', },
 }, 
})

바닐라 익스트랙트 좋은것인가 ? 나쁜것인가 ?

일단, 원래는 emotion 을 사용하다 vanila-extract 로 전환되고 실제로 성능최적화를 이룰 수 있었기에, 아주 만족스러운 결과였지만, 아직 생긴지 2년쯤 지났기에, 레퍼런스가 많지않아 어려웠던 점이 있다. 하지만 대부분은 github issues 혹은 discussion 을 보면 되기에 문제가 되지않았다. 낯설었을 뿐...!
일단은, 타입스크립트로 작성되었기에, 타입추론이 가능해서 너무 편리했다.
css in js 를 유지하며 성능 까지 챙기기엔 괜찮은 것 같다. 하지만 예상하지 못한 버그를 만날 수 있다는점..?! 그래도 지금까진 쉽게 배울 수 있고, 다음 프로젝트에서도 한번 사용해보려 한다!

profile
프론트엔드 개발자 항상 뭘 하고있는 슬링

2개의 댓글

comment-user-thumbnail
2023년 7월 24일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

1개의 답글