안녕하세요, 이번 시리즈에서는
zero run time css tool인 vanilla extract에 대해 알아보며
기존의 css modules, css-in-js와 어떤 점이 다르고 vanilla extract가 주는 developer experience의 장점에 대해 말해보려고 합니다.
개인적으로 앞으로 많이 사용되었으면 좋곘는 css tool이며 스타일링을 포함한 모든 프론트엔드 코드에 정적 타이핑 커버리지를 100%에 가깝게 올릴 수 있다는 장점,
런타임에 변환되는 css-in-js와 다르게
타입스크립트가 선언된 스타일링을 빌드타임에 전처리해주기 때문에
css-in-js보다 더 빠른 스타일 loading 시간을 갖는다는 점이 특정입니다.
scss , css-in-js는 컴포넌트에 스타일을 입히는 방식이 매우 다릅니다.
scss는 개별 스타일 단위
로 stylesheet를 작성한다면 css-in-js는 컴포넌트 단위로 스타일을 작성합니다.
scss 파일
.base {
color: white;
}
.active {
color: green;
}
.hover {
color: blue;
}
return (
<>
<div className={styles.base}/>
<div className={styles.active}/>
<div className={styles.hover}/>
</>
)
css-in-js 파일
const StyledBaseBox = styled`
color: white;
`
const StyledActiveBox = styled`
color: green;
`
const StyledHoverBox = styled`
color: blue;
...
return (
<>
<StyledBaseBox/>
<StyledActiveBox/>
<StyledHoverBox/>
</>
)
`
scss을 사용하면 다음과 같이 여러 스타일을 조합하여 하나의 컴포넌트를 구성할 수 있는 반면, css-in-js는 하나의 완성된 컴포넌트를 다른 곳에 재사용해야 한다는 차이점이 있습니다.
import * as styles from "./styles.scss";
import clsx from "clsx";
const cx = clsx.bind(styles);
...
return (
<div className={cx("color", "gutter", "background", "something")}/>
<div className={cx("color", "something")}/>
)
이 때 className 에 집어 넣는 스타일들은 런타임에서 서로 다른 class 이름들로 자동으로 난독화되기 때문에 다른 스타일시트에서 작성한 클래스 이름과 충돌하지 않습니다.
그리고 각 클래스들은 다른 컴포넌트를 스타일링할 때 재사용 할 수 있다는 장점이 있습니다.
아쉬운 점은 위 코드처럼 className
에 넣는 스타일 값들이 string 타입이기 때문에 color
가 아닌 colors
로 오타를 발생시켜도 컴파일 타임에 이러한 오타들을 필터링할 수 없다는 것입니다. 너무 많은 스타일 코드를 작성한다거나 color, colors가 다른 스타일시트 파일에 혼용되어 있다고 한다면, 특정 스타일시트 파일에 없는 colors를 사용하더라도 개발자는 전혀 위화감을 느끼지 못할 수 있습니다.
내가 이전에 color 클래스 이름을 A.scss 파일에 작성했는지를 일일이 찾아보며 잘못된 스타일링이 없는지를 점검해봐야 합니다. 이는 개발 속도를 더디게 만듭니다.
css-in-js는 미리 선언해둔 스타일을 조립해서 사용하는 것이 아닌 하나의 컴포넌트의 스타일을 template literal에 선언해 완성한 후 사용해야 합니다.
아래처럼 js,ts 파일 내부에 변수를 선언하고 template literal 안에서 변수로 참조해 사용할 수 있지만 결국 padding은 스타일이 아닌 string 값일 뿐이고 실제로 스타일링을 적용하기 위해서는 Section 이라는 컴포넌트를 사용해야 합니다.
const padding = '3em'
const Section = styled.section`
color: white;
/* Pass variables as inputs */
padding: ${padding};
/* Adjust the background from the properties */
background: ${props => props.background};
앞서 말한 특징 외에도 두드러지게 나타나는 장점들도 많습니다.
먼저 styled.section
내부에서 사용하는 props.background를 통해 스타일 분기에 대한 로직을 클라이언트에 노출하지 않고 스타일 코드 내부에서 처리할 수 있습니다.
또한 타입 시스템을 사용할 수 있다는 점은 대단히 큰 장점입니다. 팀 내부의 스타일 컨벤션을 통일하고 정적 타이핑을 할 수 있기 때문입니다.
// styled.tsx
const Section = styled.section<{ background: "white" | "blue" | "green" }>`
background: ${props => props.background};
...
// Section.tsx
return (
<Section background="red" /> // 타입 에러 발생
)
css-in-js에 적용할 수 있는 정적타이핑을 사용하여 디자인 시스템에 사용할 컬러,폰트,스페이스,리스폰스등의 변수들을 선언해 ThemeProvider
에 넣는다면 스타일링에 강력한 타이핑을 적용할 수 있죠.
// import styled, { ThemeProvider } from 'styled-components'
const Box = styled.div`
color: ${props => props.theme.color}; // color는 항상 mediumseagreen
`
render(
<ThemeProvider theme={{ color: 'mediumseagreen' }}>
<Box>I'm mediumseagreen!</Box>
</ThemeProvider>
)
다음과 같이 컴포넌트 레벨로 꼭 스타일을 작성하지 않고도 스타일을 적용할 수 있습니다.
<div
css={`
background: papayawhip;
color: ${props => props.theme.colors.text};
`}
/>
<Button
css="padding: 0.5em 1em;"
/>
하지만 잘 작성된 스타일 컴포넌트라면 위와 같이 스타일에 대한 로직이 두 부분으로 나뉘어질 이유는 없을 것입니다.
그리고 styled component 내부에서 사용되는 css properties의 키값을 잘못 작성하는 것에 대한 정적 타이핑은 여전히 해결되지 않습니다.
더불어 디자인 시스템에서 제공하는 theme가 복잡할 수록 디자인 시스템 내부에서 선언된 타입 파일은 매우 정교하게 작성되어야 합니다.
예를 들어 브라우저 스크린 break point에 따른 스타일링을 props.theme 이하의 키값을 통해 적용하고 싶다고 가정해봅시다.
다음과 같이 template literal 안에 미디어 쿼리를 바로 작성할 수도 있지만
const Column = styled.div`
@media only screen and (min-width: 768px) {
width: ${props => props.something ? "245px": "100%"};
}
`
미디어 쿼리를 작성하는데 적지 않은 코드가 필요하니까 우리는 좀 더 간편한 형식으로 사용해봅시다.
아래처럼 뷰포트에 따른 breakpoints를 미리 정해두고 이를 미디어 쿼리에서 참조해 사용하려고 합니다.
breakpoints: [ '420px', '768px', '1280px' ]
mediaQueries: {
small: `@media screen and (min-width: ${breakpoints[0]})`,
medium: `@media screen and (min-width: ${breakpoints[1]})`,
large: `@media screen and (min-width: ${breakpoints[2]})`,
}
const Card = styled.div`
display: flex;
flex-direction: column;
margin: 0.5rem;
${mediaQueries.small} {
flex-direction: row;
}
`;
조금 나아졌습니다. 여기서 더 나아가
ThemeProvider
에 주입할 테마로 작성해봅시다.
emotion의 최신 버전인 v11.10.4를 시용하겠습니다.
먼저 테마 타입을 정의하고 테마에 들어갈 각 항목들의 타입을 별도로 정의해주어야 합니다.
// theme.ts
import { ColorTheme } from "./colors";
import { Device } from "./device";
export type AppTheme = {
color: Readonly<ColorTheme>;
device: Readonly<Device>;
}
// device.ts
type BreakPoints = [string, string, string];
export const baseBreakPoints: BreakPoints = ["420px", "768px", "1280px"]
export type Device<B extends BreakPoints = BreakPoints> = {
small: `@media screen and (min-width: ${B[0]})`,
medium: `@media screen and (min-width: ${B[1]})`,
large: `@media screen and (min-width: ${B[2]})`,
}
export const createDevice = (breakPoints = baseBreakPoints): Device => {
return {
small: `@media screen and (min-width: ${breakPoints[0]})`,
medium: `@media screen and (min-width: ${breakPoints[1]})`,
large: `@media screen and (min-width: ${breakPoints[2]})`,
}
}
위에서 createDevice 함수를 이용해 테마를 만들어줄 때 small, medium, large에 해당하는 breakpoint를 넣어주겠습니다.
// lightTheme.ts
export const createLightTheme = (): AppTheme => ({
...
device: createDevice(),
})
// App.tsx
function App() {
return (
<ThemeProvider theme={lightTheme}>
<MyComponent/>
</ThemeProvider>
)
}
이제 MyComponent에서 lightTheme를 사용할 때 런타임에서 에러가 발생하지 않지만
타입추론을 위해서는 다음과 같이 추가적으로 타입 정의를 해주어야 합니다.
이번 포스팅에 사용하는 emotion 버전의 경우 다음처럼 d.ts 파일을 별도로 작성해주어야 합니다.
import '@emotion/react'
import type { AppTheme } from "./theme/theme"
declare module '@emotion/react' {
export interface Theme extends AppTheme{
}
}
이전 버전의 emotion에서는 다음처럼 styled
를 명시적으로 다른 타입으로 강제로 변환해주어야 합니다.
// const styled = _styled as CreateStyled<NewTheme>;
각 라이브러리별로 컴포넌트 내부에서 테마를 추론하기 위해 별도의 타입 선언을 해주어야 하는 방법이 상이합니다. 동일한 라이브러리 안에서도 방법이 나뉘어집니다. 이러한 다양한 방법은 개발자를 피곤하게 만듭니다.
아쉽게도 타입스크립트 사용을 위한 emotion의 설정은 여기서 끝이 아닙니다.
emotion에서는 style props처럼 <div style={{background: "red"}} />
theme에서 제공하는 스타일링을 인라인으로 적용할 수 있게 해주는 css prop 타입이 있습니다.
css-in-js를 사용하는 경우 인라인 스타일링에서도 사전에 정의한 테마 템플릿을 사용할 수 있게 하기 위해 컴포넌트를 컴파일할때 React.createElement가 아닌 자체 jsx 함수를 사용해 컴파일 합니다.
css prop을 사용하기 위해서는 추가 설정을 해줘야 하는데 리엑트 프로젝트 구성에 따라 설정 방법이 다릅니다.
웹팩을 사용해 자체적으로 프로젝트를 밑바닥 부터 하나씩 구성할 경우 커스텀 바벨 설정이 가능함으로 다음과 같이 .babelrc 파일을 통해 preset 설정이 가능하며, 이 또한 사용하는 JSX runtimes 에 따라
달라집니다. import React
를 명시적으로 설정해줘야 하는 구버전일 경우 아래처럼,
// .babelrc
{
"presets": ["@emotion/babel-preset-css-prop"]
}
아닐 경우 @emotion/babel-plugin
을 사용해야합니다.
// .babelrc
{
"presets": [
[
"@babel/preset-react",
{ "runtime": "automatic", "importSource": "@emotion/react" }
]
],
"plugins": ["@emotion/babel-plugin"]
}
새 JSX runtimes를 사용하지만 next.js를 사용할 경우 다음과 같이 설정해야 합니다.
{
"presets": [
[
"next/babel",
{
"preset-react": {
"runtime": "automatic",
"importSource": "@emotion/react"
}
}
]
],
"plugins": ["@emotion/babel-plugin"]
}
create-react-app을 사용할 경우 babel 커스텀을 사용할 수 없고 다음과 같이 컴포넌트 상단에 JSX Pragma를 명시적으로 사용해야 합니다.
/** @jsx jsx */
import { jsx } from '@emotion/react'
또 new JSX runtimes를 사용하는 create React App4 부터는 위처럼 사용할 수 없으며
/** @jsxImportSource @emotion/react */
과 같이 사용해야 합니다.
vitejs를 사용하는 경우 automatic transform이 지원됨으로 다음과 같이 vite.config.js에서 바벨 플러그인을 명시적으로 지정해주고 tsconfig.js를 수정해주어야 합니다.
// vite.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react({
jsxImportSource: '@emotion/react',
babel: {
plugins: ['@emotion/babel-plugin'],
},
}
)]
})
// tsconfig.json
{
compilerOptions: {
...,
"jsxImportSource": "@emotion/react"
}
}
이후에 useTheme 훅을 사용해 인라인 스타일링에서도 theme를 사용할 수 있습니다.
export const Button = () => {
const theme = useTheme();
return (
<StyledButton>
<div
css={css`
backgroundColor: ${theme.color.background}}
`}
/>
</StyledButton>
)
}
css-in-js가 다양한 스타일링 기법을 지원하지만 컴포넌트 기반의 스타일링과 클래스 기반의 스타일링, 인라인 스타일링이 혼재된 형태가 코드에 혼재될 수 있는 가능성이 발생합니다.
앞서 작성한 color, device 테마를 사용해 StyledButton에 스타일링을 해보겠습니다.
const StyledButton = styled.div`
${({theme}) => css`
${theme.device.small} {
color: ${theme.color.background};
}
${theme.device.medium} {
color: ${theme.color.error};
}
${theme.device.large} {
color: ${theme.color.primary};
}
`}
`;
위 코드는 잘 동작하지만 중첩된 template literal 형식이 나타납니다. 또한 중첩 형식 내부에서 theme 값을 참조할 수 있어 편리하지만 color 스타일링 코드가 여러 번 반복되는 것은 피할 수 없습니다.
css-in-js가 타입 추론을 할 수 있는 스타일링 기법을 제공하지만 이를 잘 활용하기 위해서는 먼저 정교하게 작성한 타입과 함께 typescript 설정이 필요합니다. 버전 업데이트에 따라 기존 설정에서 변경해줘야 할 점 까지 발생할 수 있어 챙겨줘야 하는 점이 적지 않습니다.
앞서 styled api를 사용해 개별 컴포넌트를 template literal 구문으로 정의했지만 함수 형태로 사용할 수도 있습니다.
const StyledButton = styled.div({
backgroundColor: "red",
}, forwardProps)
css 함수를 이용한 인라인 스타일링도 제공하며 StyledButton에서는 className props도 허용합니다.
emotion의 api는 flexible하나 이 점이 사용자간 코드 형태의 불일치를 불러일으킬 수 있습니다.
Emotion is an extremely flexible library, but this can make it intimidating, especially for new users https://emotion.sh/docs/best-practices
이모션은 매우 유욘헌 라이브러리이지만 이 점 때문에 특히 새로운 사용자에게는 위협이 될 수 있습니다.
서론이 길었습니다.
지금부터 vanilla extract에서 제공하는 api들의 사용예시를 보여드리겠습니다. 앞서 봤던 emotion의 themeing과 무엇이 다른지 직접 확인해보세요.
vanilla extract는 타입스크립트를 전처리기로 사용합니다.
모든 css 코드를 스타일시트인 styles.css
, styles.scss
파일에 작성하는 것이 아닌
styles.css.ts
와 같은 .css.ts
postfix 파일에 작성하기 때문에
작성하는 모든 스타일에 대한 타입 추론이 가능합니다.
vars에 정의 해두지 않은 변수의 오타 발생 시 정적 체크를 해주는 모습
모든 스타일은 타입 스크립트로 작성해야 vanilla extract에서 전처리해줄 수 있으며 모든 스타일 관련 파일은 .css.ts
postfix가 붙은 이름으로 만들어져야 합니다.
위와 같이 .css.ts에서 작성된 변수들은 오른쪽과 같이 난수화된 클래스 이름으로 전처리되어 만들어집니다.
사용하는 방식은 css module을 사용하는 것과 유사합니다. 다른 점은 전처리하기 위해 선언된 각 변수들을 타입스크립트로 작성했기 때문에 css module과는 다르게 사용하는 측에서 바로 타입 추론이 가능하다는 점입니다.
css 모듈과 동일하게 사용된 변수는 locally scoped 되어 다른 .css.ts 파일에서 선언된 변수와 동일한 이름을 사용하더라도 서로 클래스 명이 충돌되지 않습니다.
앞서 봤던 예제 코드에서 눈치채셨겠지만 vanilla extract의 모든 스타일링과 관련된 api는 리엑트의 style props 인풋과 같은 형태인 style object를 사용합니다.
컴포넌트에 적용할 스타일링을 컴포넌트 단위가 아닌 스타일 단위로 잘게 쪼개어 css 모듈과 같이 사용할 수 있는데, 이 때 타입 추론과 더불어 스타일과 관계없는 일반 자바스크립트 변수처럼 camelCase를 동일하게 적용할 수 있다는 점이 vanilla extract를 사용하면서 제일 처음 느낄 수 있는 특징이자 장점입니다.
// 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;
}
post css를 이용해 처리하는 css와 마찬가지로 기본적으로 모든 속성에 대해 vendor prefix가 지정되나 다음과 같이 camelCase를 사용해 특정 속성에 대한 특정 vendor prefix를 지정할 수 있습니다.
vendor prefix 속성 값을 위한 키 네이밍 법칙은 원본 vendor prefix의 camelize 된 키 값에 -
값을 제외한 것입니다. -webkit-tap-highlight-color
->
export const myStyle = style({
WebkitTapHighlightColor: 'rgba(0, 0, 0, 0)'
});
vanilla extract에서 선언되는 css variables는 vars 키워드 내부에 선언되어야 합니다.
const myStyle = style({
vars: {
'--my-global-variable': 'purple'
}
});
css variable은 하드코딩 할 수 도 있지만 createVar api를 통해 임의의 변수명을 생성해 참조할 수도 있습니다.
import { createVar, style } from '@vanilla-extract/css';
export const accentVar = createVar();
export const blue = style({
vars: {
[accentVar]: 'blue'
}
});
export const pink = style({
vars: {
[accentVar]: 'pink'
}
});
위 코드는 아래와 같이 변환됩니다.
.accent_blue__l3kgsb1 {
--accentVar__l3kgsb0: blue;
}
.accent_pink__l3kgsb2 {
--accentVar__l3kgsb0: pink;
}
동일한 변수명을 참조하더라도 값은 다르게 선언될 수 있습니다. 아래 예시에서는 동일한 accentVar를 참조하나 클래스 명에 따라 다르게 동작하는 color 값을 볼 수 있습니다.
import { createVar, style } from '@vanilla-extract/css';
export const accentVar = createVar();
export const blue = style({
vars: {
[accentVar]: 'blue'
},
'@media': {
'(prefers-color-scheme: dark)': {
vars: {
[accentVar]: 'lightblue'
}
}
}
});
export const pink = style({
vars: {
[accentVar]: 'pink'
},
'@media': {
'(prefers-color-scheme: dark)': {
vars: {
[accentVar]: 'lightpink'
}
}
}
});
.accent_blue__l3kgsb1 {
--accentVar__l3kgsb0: blue;
}
.accent_pink__l3kgsb2 {
--accentVar__l3kgsb0: pink;
}
@media (prefers-color-scheme: dark) {
.accent_blue__l3kgsb1 {
--accentVar__l3kgsb0: lightblue;
}
.accent_pink__l3kgsb2 {
--accentVar__l3kgsb0: lightpink;
}
}
전처리 된 css variable. 각 클래스에서 --accentVar__l3kgsb0라는 동일한 변수 명을 참조하고 있으나 다른 값이 적용됨을 볼 수 있다.
css variables 마저 typescript로 작성되어 export 될 수 있기 때문에 서로 다른 .css.ts
파일에서 이 변수 명을 쉽게 참조하여 사용할 수 있습니다.
// accent.cs.ts
import { createVar, style } from '@vanilla-extract/css';
export const accentVar = createVar();
export const blue = style({
vars: {
[accentVar]: 'blue'
}
});
export const pink = style({
vars: {
[accentVar]: 'pink'
}
});
// style.css.ts
import { createVar, style } from '@vanilla-extract/css';
import { accentVar } from './accent.css.ts';
export const accentText = style({
color: accentVar
});
글이 길어짐에 따라 vanilla extract를 주제로한 포스팅을 2~3회로 나누어 올리려고 합니다.
다음 시간에는 본격적인 api들의 설명에 대해 나누도록 하겠습니다.
감사합니다.
단테님 안녕하세요 ㅎㅎ
vanilla-extract 에서는
width: ${props => props.something ? "245px": "100%"}; props 로 처리는 안되는 건가요?!