emotion을 현재 조직에서 사용하고 있기도 하고, 예전에 팀원분이 이슈 디버깅중 실제 개발환경에서는 style코드들이 확인되는데, 운영 환경에서 style이 비어있는 이슈를 마주하셨습니다. 이를 계기로 emotion및 다른 라이브러리의 스타일 적용 동작을 파악해보게 되었고, 파악하면서 css-in-js 라이브러리들의 구현 방식이나, 왜 그런 특성들을 각자 갖게 되었는지 알게된점을 이글에서 공유하려 합니다.
우선, emotion에서 어떻게 스타일이 적용되는지 코드를 기반으로 파악해보았습니다.
https://github.com/emotion-js/emotion/blob/main/packages/sheet/src/index.js#L65
export class StyleSheet {
isSpeedy: boolean
ctr: number
tags: HTMLStyleElement[]
container: HTMLElement
key: string
nonce: string | void
prepend: boolean | void
before: Element | null
insertionPoint: HTMLElement | void
constructor(options: Options) {
this.isSpeedy = options.speedy === undefined ? process.env.NODE_ENV === 'production' : options.speedy this.tags = []
this.ctr = 0
this.nonce = options.nonce
// key is the value of the data-emotion attribute, it's used to identify different sheets
this.key = options.key
this.container = options.container
this.prepend = options.prepend
this.insertionPoint = options.insertionPoint
this.before = null
}
isSppedy
라는 flag)
_insertTag = (tag: HTMLStyleElement) => {
let before
if (this.tags.length === 0) {
if (this.insertionPoint) {
before = this.insertionPoint.nextSibling
} else if (this.prepend) {
before = this.container.firstChild
} else {
before = this.before
}
} else {
before = this.tags[this.tags.length - 1].nextSibling
}
this.container.insertBefore(tag, before)
this.tags.push(tag)
}
insert(rule: string) {
// the max length is how many rules we have per style tag, it's 65000 in speedy mode
// it's 1 in dev because we insert source maps that map a single rule to a location
// and you can only have one source map per style tag
if (this.ctr % (this.isSpeedy ? 65000 : 1) === 0) { this._insertTag(createStyleElement(this))
}
...
this.ctr++ }
예시로 확인하기
import React from 'react'
import styled from '@emotion/styled'
const Button = styled.button`
color: hotpink;
`
const Button2 = styled.button`
color: blue;
`
const Button3 = styled.button`
color: green;
`
function App() {
return (
<div className='App'>
<h1>{process.env.NODE_ENV}</h1>
<Button>이모션</Button>
<br />
<Button2>고모션</Button2>
<br />
<Button3>저모션</Button3>
</div>
)
}
export default App
dev 모드
prod 모드
조금 더 코드상에서 파악해봅시다.
insert(rule: string) {
// the max length is how many rules we have per style tag, it's 65000 in speedy mode
// it's 1 in dev because we insert source maps that map a single rule to a location
// and you can only have one source map per style tag
if (this.ctr % (this.isSpeedy ? 65000 : 1) === 0) {
this._insertTag(createStyleElement(this))
}
...
if (this.isSpeedy) {
const sheet = sheetForTag(tag)
try {
// this is the ultrafast version, works across browsers
// the big drawback is that the css won't be editable in devtools
sheet.insertRule(rule, sheet.cssRules.length) } catch (e) {
if (
process.env.NODE_ENV !== 'production' &&
!/:(-moz-placeholder|-moz-focus-inner|-moz-focusring|-ms-input-placeholder|-moz-read-write|-moz-read-only|-ms-clear){/.test(
rule
)
) {
console.error(
`There was a problem inserting the following rule: "${rule}"`,
e
)
}
}
} else {
tag.appendChild(document.createTextNode(rule)) }
this.ctr++
}
CSSStyleSheet.insertRule()
CSSStyleSheet
CSS 스타일 시트는 CSS 사양에 정의된 스타일 시트를 나타내는 추상적인 개념 이고, CSSOM에서 css style sheet는 CSSStyleSheet 객체로 표현됩니다
sheet.rules
로 dev tool에서 조회해야 조회가 가능하다
runtime css-in-js 라는 emotion의 특성과 겹친다 (styled component도 동일)
css parsing으로 인해 blocking되는 시간을 최대한 줄이는 노력이 필요했고, (브라우저는 DOM및 CSSOM트리를 결합하여 렌더링 트리를 형성 → 렌더링) emotion에서 DOM트리는 수정하지 않고 CSSOM을 수정하는 방식을 선택하여 DOM트리 parsing에 드는 시간을 줄이는 방법을 선택했겠구나라는 생각이 들었습니다.
동적인 스타일 변경으로 더 체감해보기
아래와 같이 상태에 따라 스타일들이 변경 되도록 수정
import React, { useState } from 'react'
import styled from '@emotion/styled'
const Button = styled.button<{ flag: boolean }>`
color: ${(props) => (props.flag ? 'pink' : 'hotpink')};
font-weight: bold;
`
const Button2 = styled.button<{ flag: boolean }>`
color: ${(props) => (props.flag ? 'skyblue' : 'blue')};
font-weight: bold;
`
const Button3 = styled.button<{ flag: boolean }>`
color: ${(props) => (props.flag ? 'yellow' : 'green')};
font-weight: bold;
`
function App() {
const [flag, setFlag] = useState(false)
return (
<div className='App'>
<h1>{process.env.NODE_ENV}</h1>
<button onClick={() => setFlag((flag) => !flag)}>flag 바꾸기</button>
<br />
<br />
<Button flag={flag}>이모션</Button>
<br />
<Button2 flag={flag}>고모션</Button2>
<br />
<Button3 flag={flag}>저모션</Button3>
</div>
)
}
export default App
세부 코드들은 다르지만, 동일하게 production에선 cssom 수정방식을 dev에선 dom 수정방식을 선택했습니다.
export const DISABLE_SPEEDY = Boolean(
typeof SC_DISABLE_SPEEDY === 'boolean'
? SC_DISABLE_SPEEDY
: typeof process !== 'undefined' &&
typeof process.env.REACT_APP_SC_DISABLE_SPEEDY !== 'undefined' &&
process.env.REACT_APP_SC_DISABLE_SPEEDY !== ''
? process.env.REACT_APP_SC_DISABLE_SPEEDY === 'false'
? false
: process.env.REACT_APP_SC_DISABLE_SPEEDY
: typeof process !== 'undefined' &&
typeof process.env.SC_DISABLE_SPEEDY !== 'undefined' &&
process.env.SC_DISABLE_SPEEDY !== ''
? process.env.SC_DISABLE_SPEEDY === 'false'
? false
: process.env.SC_DISABLE_SPEEDY
: process.env.NODE_ENV !== 'production'
)
2. 그 flag에 따라 cssom injection 쓸지 /말지
const defaultOptions: SheetOptions = {
isServer: !IS_BROWSER,
useCSSOMInjection: !DISABLE_SPEEDY}
/** Create a CSSStyleSheet-like tag depending on the environment */
export const makeTag = ({ isServer, useCSSOMInjection, target }: SheetOptions) => {
if (isServer) {
return new VirtualTag(target)
} else if (useCSSOMInjection) {
return new CSSOMTag(target) } else {
return new TextTag(target)
}
}
insertRule(index: number, rule: string): boolean {
try {
this.sheet.insertRule(rule, index); this.length++;
return true;
} catch (_error) {
return false;
}
}
insertRule(index: number, rule: string) {
if (index <= this.length && index >= 0) {
const node = document.createTextNode(rule);
const refNode = this.nodes[index];
this.element.insertBefore(node, refNode || null); this.length++;
return true;
} else {
return false;
}
}
runtime의 특성때문에 생기는 production에서의 최적화 처리는 알겠는데, 그러면 아예 runtime이 아닌 라이브러리들을 고려해보지 않았을까? 아래에서 알아봅시다!
emotion과 styled component
runtime css-in-js에서 생긴 문제점들
위 내용은 한 블로그에서 발췌한 내용인데, 해석해보면 아래와 같습니다.
자바스크립트로 조작하는 UI 스타일링들은 자바스크립트 런타임과 연관이 생겼고, 아래와 같은 성능관련 여러 내용들을 생각해보게 되었다
run time css-in-js로 얻는 DX 의 장점(js로 핸들링 하는 css,..etc)
과 웹 성능들에서 생기는 문제점
들이 양자택일의 문제처럼 상충하기 시작했다
그럼 run time에서 생기는 문제점들이 있으니 zero run time? 그런데, 빌드시 css파일이 생기는 예전으로 회귀하는게 아닌가..? 사실 Zero Runtime CSS 라는 것은 예전에도 있었습니다..! 하지만 과거에는 prop이나 state에 의한 동적 스타일링을 지원하지 않은 채로, 단순한 정적 스타일 파일을 빌드 시간에 생성하는 것에 그쳤습니다.
linaria 라는 대표적인 zero run time css in js 라이브러리가 있는데, 그럼 이건 어떻게 zero run time을 접목했을까요?
Q) 생긴건 emotion/styled 나 styled-components랑 비슷한데 뭐가 다른가?
간단히 말해, Emotion 및 Styled Components와 같은 JS 라이브러리의 CSS는 브라우저에서 페이지가 로드될 때 스타일을 구문을 분석해 적용하고(runtime) Linaria는 프로젝트를 빌드할 때(예: webpack) CSS 파일에 스타일을 추출하고 CSS 파일이 로드되는 방식 (zero runtime).
Q) 그러면 기존의 동적 스타일링은 어떻게 구현?
Linaria는 Babel Plugin과 Webpack Loader를 사용해서 빌드될 때 별도의 CSS 파일을 생성하게 되는 데 이 파일 안에서 prop이나 state 등에 의한 값들을 CSS Variable로 정의하고 CSS Variable의 값을 변경시킴으로써 동적 스타일링을 구현
Q) 어떠한 장점이 있을까?
zero runtime css-in-js는 run time css-in-js에서의 DX는 유지하면서, 웹성능 문제가 상충하는 상황도 해결하려 노력했다
Q) 그럼 좋은건 알겠는데 고려할점은 없을까..?
CSS 변수를 사용한 방식이다보니까 브라우저 호환성에서 문제가 있다. IE
에서는 CSS 변수를 지원하고 있지 않기 때문에 IE11을 지원해야 하는 프로젝트에서는 Linaria가 아닌 다른 방식을 선택해야 한다.
https://github.com/seek-oss/vanilla-extract
물론 zero runtime css-in-js 라이브러리에 linaria 말고도 vanilla-extracts라는 다른 라이브러리도 있습니다.
import { createTheme, style } from '@vanilla-extract/css'
export const [themeClass, vars] = createTheme({
color: {
brand: 'blue',
white: '#fff'
},
space: {
small: '4px',
medium: '8px'
}
})
export const hero = style({
backgroundColor: vars.color.brandd,
color: vars.color.white,
padding: vars.space.large
})
zero run time은 이제 알겠는데.. 그럼 near zero run time은 뭘까???
stitches가 대표적인 near zero run time 라이브러리입니다.
prop interpolation
최적화란..?
https://stitches.dev/blog/migrating-from-styled-components-to-stitches
위 링크에서 더 자세히 알수 있지만, prop interpolation 기준으로만 살펴보면 아래와 같이 variant로 제약을 둬서 불필요한 prop interpolation을 방지합니다.
아래 링크에서 알 수 있듯 거의 대부분의 지표에서 성능은 stitches가 좋게 나왔습니다.
아래 사진은 https://2021.stateofcss.com/en-US/ 에서 가져온 css-in-js 라이브러리의 흥미도 그래프입니다. 위와 같이 여러 흐름들을 살펴보니 run time css-in-js에서 zero run time css-in-js, near zero runtime css-in-js로 이어지는 흐름에 영향을 주지 않았을까..? 라는 생각도 들게 되었습니다 😃
그도 그럴것이, stitches, vanilla-extract, linaria 등이 높은 순위를 차지하고 있었기 떄문입니다 🕵🏻♂️
**사고의 과정**
1. 왜 emotion라이브러리를 사용하면 운영에서 스타일 태그가 비어있지?
2. emotion, styled-component와 같은 runtime css-in-js 라이브러리 코드를 파악해보니 dev와 prod는 runtime css in js의 성능 문제를 해결하려다보니 다르게 구현했구나?
3. 그럼 run-time 이외의 css-in-js는 있을까? (zero run time css / near zero run time css)
위와 같은 사고의 과정으로 runtime css-in-js 라이브러리의 스타일 추가 구현 방식 그리고 run time css-in-js이외의 zero run time css-in-js, near zero run time css-in-js의 특성까지 파악해보았었습니다.
이렇게 파악해보면서, 많이들 알려져있는 emotion이나 styled component를 이미 기존에 사용하고 있다면
다만 처음 프로젝트를 시작할떄는
그리고 추가로 이 글에 다 소개하진 못했지만, facebook에선 새로운 방법론으로 css파일 크기를 80프로 줄이면서 최적화를 극대화 했다고 하는데요, 빌드 타임에 css를 생성해 atomic css를 js적인 방법으로 활용할 수 있는 stylex라는 라이브러리를 개발하였고, 이 stylex는 atomic css 방법론을 적용했다고 합니다. 아직 stylex 라이브러리가 공개되진 않은거 같네요 😢
tailwind , atomizer 등등의 라이브러리들이 atomic css 방법론을 적용한 라이브러리라고 합니다
atomic css
자세한 내용은 react 핀란드 2021 conf 영상 참고 하시면 좋을것 같습니다 :)
emotion, styled component에서 직관적으로 반응형 대응이 가능함 (네 제 라이브러리 홍보 맞습니다 ㅎ)
import media from 'css-in-js-media'
export const exampleClass = css`
color: red;
${media('>desktop')} {
font-size: 15px;
}
${media('<=desktop', '>tablet')} {
font-size: 20px;
}
${media('<=tablet', '>phone')} {
font-size: 25px;
}
${media('<=phone')} {
font-size: 30px;
}
`