본문으로 건너뛰기

Emotion이 CSS를 생성하는 방법

· 약 18분
Mitchell

대표적인 CSS in JS 라이브러리인 Emotion의 소스코드를 분석하면서 동작원리를 이해해보겠습니다. 동작원리를 이해하게 되면 장점을 극대화하고, 단점을 최소화하여 라이브러리를 효과적으로 쓸 수 있는 방법을 발견해낼 수 있습니다. 이 글에서는 @emotion/react와 cssProp를 중심으로 설명합니다.

주의깊게 볼 패키지

Emotion은 스타일을 생성하고 최적화하기 위한 많은 패키지들을 가지고 있습니다. 그 중 전체적인 흐름을 이해하기 위해서 다음 패키지들의 코드를 중점적으로 살펴보게 됩니다.
전체 패키지 보기

packages
├── cache
├── css
├── react
├── serialize
├── sheet
├── use-insertion-effect-with-fallbacks
├── utils

전체적인 동작 과정

  1. @emotion/react에 의한 JSX 트랜스파일링
  2. 브라우저에서 React 렌더시작
  3. Emotion cache 생성
  4. cssProp에 전달된 스타일 직렬화
  5. 직렬화된 스타일을 캐시에 등록 및 삽입
  6. stylis로 스타일을 CSSRule로 컴파일
  7. 컴파일된 CSSrule을 HTML문서에 삽입

1번은 실제 코드가 실행되기 전인 컴파일타임에서 이루어지는 동작이고, 나머지는 브라우저에서 JavaScript 파일이 다운로드 되고 코드가 실제로 실행되는 런타임에서 이루어지는 동작입니다. 각 항목에 대해서 하나씩 다뤄보겠습니다.


01. JSX 트랜스파일링

Emotion으로 작성된 코드가 브라우저에서 정상적으로 작동될 수 있도록 변환되는 과정입니다.

cssProp을 사용해 간단한 컴포넌트를 작성해보겠습니다.

function Simple() {
return (
<button css={{
backgroundColor: 'blue'
}}>
간단한 버튼입니다.
</button>
)
}

cssProp를 사용하기 위해 타입스크립트 tsconfig의 jsxImportSource@emotion/react로 설정하고 트랜스파일링 해보면 다음과 같이 변환됩니다.

import { jsx as _jsx } from "@emotion/react/jsx-runtime";
function Simple() {
return (_jsx("button", { css: {
backgroundColor: 'blue'
}, children: "\uAC04\uB2E8\uD55C \uBC84\uD2BC\uC785\uB2C8\uB2E4." }));
}

02. 브라우저에서 React 컴포넌트 렌더시작

위에서 트랜스파일링된 소스코드는 브라우저에서 실행되며, 런타임에서 스타일코드를 생성하고 주입합니다. 모든 흐름은 위에서 본 jsx 함수의 호출로부터 시작합니다.

emotion/packages/react/src/jsx-runtime.js
import * as ReactJSXRuntime from 'react/jsx-runtime'
import Emotion, { createEmotionProps } from './emotion-element'
import { hasOwnProperty } from './utils'

//...

export function jsx(type: any, props: any, key: any) {
if (!hasOwnProperty.call(props, 'css')) {
return ReactJSXRuntime.jsx(type, props, key)
}

return ReactJSXRuntime.jsx(Emotion, createEmotionProps(type, props), key)
}

//...

소스코드 바로가기

@emotion/react/jsx-runtimejsx는 React의 jsx가 cssProp를 처리할 수 있도록 확장하고 있습니다. jsx의 파라미터 중 type에 Emotion을, props에 createEmotionProps(type, props)를 전달하고 있습니다.

Emotion

첫번째로 전달되는 파라미터는 Emotion입니다. jsx는 React의 createElement로 변환되고, 그 함수의 첫번째 파라미터는 string, HTMLElement, Component 등으로 지정되어 있습니다. 그리고 네이밍 컨벤션이 PascalCase인 점을 미루어 보아 Emotion은 리액트 컴포넌트인 것으로 예상해볼 수 있습니다.

emotion/packages/react/src/emotion-element.js
let Emotion = /* #__PURE__ */ withEmotionCache<any, any>(
(props, cache, ref) => {
//...

return (
<>
<Insertion
cache={cache}
serialized={serialized}
isStringTag={typeof WrappedComponent === 'string'}
/>
<WrappedComponent {...newProps} />
</>
)
}
)

소크코드 바로가기

예상대로 Element 변수는 컴포넌트를 반환하고 있습니다. 함수 시그니처 부터 살펴보겠습니다.

  1. withEmotionCache라는 HOC(High order Component)로 컴포넌트를 감싸고 있습니다. 네이밍으로 유추해보면, emotion의 cache 처리와 관련한 로직이 있을 것으로 예상할 수 있습니다.
  2. 원래의 컴포넌트인 <WrappedComponent><Insertion/>이라는 컴포넌트와 함께 렌더링될 것입니다. 네이밍으로 유추하자면, <Insertion/>은 스타일의 삽입이 이루어지는 곳이라고 예상할 수 있습니다.

createEmotionProps

Emotion컴포넌트에서 사용할 Props를 생성하는 함수입니다. 주요 역할은 컴포넌트 타입 결정, cssProp의 유효성을 검사, 개발환경을 위한 디버깅 처리 등 있습니다.


03. Emotion Cache 생성

여기에서 자세히 설명하진 않지만, Emotion에서는 contextAPI를 활용하여 EmotionCacheContext를 생성하고 Emotion의 스타일 주입이 필요한 컴포넌트에서 Cache에 접근할 수 있도록 하고 있습니다.

emotion/packages/react/src/context.js
let withEmotionCache = function withEmotionCache<Props, Ref: React.Ref<*>>(
func: (props: Props, cache: EmotionCache, ref: Ref) => React.Node
): React.AbstractComponent<Props> {
// $FlowFixMe
return forwardRef((props: Props, ref: Ref) => {
// the cache will never be null in the browser
let cache = ((useContext(EmotionCacheContext): any): EmotionCache)

return func(props, cache, ref)
})
}

소스코드 바로가기

위에서 간략하게 살펴본 Emotion 컴포넌트는 withEmotionCache라는 HOC로 랩핑되어 있었습니다. withEmotionCache는 생성된 cache를 useContext를 통해서 가져와 렌더링 대상이 되는 컴포넌트에 전달하는 공통 로직을 가지고 있습니다. 이에 따라 Emotion 컴포넌트에서는 cache에 접근하여 효율적으로 스타일을 생성할 수 있게 됩니다.


04. cssProp에 전달된 스타일 직렬화

정보

여기에서 스타일을 직렬화 한다는 것은 Emotion에서 스타일 삽입을 위한 형태인 SerializedStyles 객체로 만든다는 의미입니다.

export interface SerializedStyles {
name: string
styles: string
map?: string
next?: SerializedStyles
}

다시 Emotion 컴포넌트로 돌아와서 로직을 하나씩 살펴보겠습니다. 내부 로직에서는 3번을 통해 전달된 cache와 props로 스타일을 직렬화 할 것입니다.

emotion/packages/react/src/emotion-element.js
let Emotion = /* #__PURE__ */ withEmotionCache<any, any>(
(props, cache, ref) => {
let cssProp = props.css

if (
typeof cssProp === 'string' &&
cache.registered[cssProp] !== undefined
) {
// SerializedStyles.styles, 즉 스타일문자열을 가져옵니다.
cssProp = cache.registered[cssProp]
}

let WrappedComponent = props[typePropName]
let registeredStyles = [cssProp]
let className = ''

if (typeof props.className === 'string') {
className = getRegisteredStyles(
cache.registered,
registeredStyles,
props.className
)
} else if (props.className != null) {
className = `${props.className} `
}

let serialized = serializeStyles(
registeredStyles,
undefined,
React.useContext(ThemeContext)
)

// ...

className += `${cache.key}-${serialized.name}`

// ...
}
)

소크코드 바로가기

이 로직의 핵심은 cssProp으로 들어온 값을 cache에서 확인하여 이미 등록된게 있으면 직렬화가 완료된 스타일을 가져오고, 없으면 직렬화를 진행한다는 것입니다.

  1. cssPropstring이고 그 값을 키로 캐시에 등록된 스타일이 있으면 cssProp에 캐시에 등록된 값을 할당합니다.
  2. cssProp을 배열의 첫번째 요소로 추가하여 registeredStyles를 초기화합니다.
  3. getRegisteredStyles 함수를 통해서 registeredStyles에 캐시에 등록된 스타일들을 추가하고, 새로운 className을 생성합니다.
  4. serializeStylesregisteredStyles를 직렬화 합니다.

맨 위에서 간단한 예시로 들었던 Simple 컴포넌트로 다시 설명하자면,

<button css={{
backgroundColor: 'blue'
}}>
간단한 버튼입니다.
</button>

cssProp에 전달한 { backgroundColor: 'blue' }serializeStyles에 의해서 직렬화됩니다.

getRegisteredStyles

파라미터로 전달받은 값으로부터 캐시에 등록된 스타일문자열을 가져오고, 등록된 값이 없다면 해당 클래스네임은 다시 반환해주는 함수입니다.
소스코드 바로가기

serializeStyles

Emotion에서 스타일 삽입으로 처리할 수 있는 형태인 SerializedStyles 객체로 직렬화 해주는 함수입니다. Object형식이든 Tagged Template Literal형식이든 저희가 작성했던 스타일을 처리합니다.

emotion/packages/serialize/src/index.js
export const serializeStyles = function (
args: Array<Interpolation>,
registered: RegisteredCache | void,
mergedProps: void | Object
): SerializedStyles {
// args를 순회하며 여러 타입으로 작성된
// 스타일들을 처리하여 하나의 문자열로 생성하고
// 직렬화된 스타일 객체를 반환함.
}

소스코드 바로가기

  1. args가 1개이고, SerializedStyles이라면 직렬화 과정 없이 바로 그 값을 반환합니다.
  2. 1번이 아니라면 모든 args 요소에 대해 반복문을 돌면서 handleInterpolation함수를 통해 스타일 문자열을 만듭니다.
  • argscss`color: blue;`과 같은 Tagged Template Literal로 들어오면 고정문자열에 handleInterpolation 함수를 거친 동적변수 문자열을 합쳐 스타일 문자열을 만듭니다.
  1. 생성된 스타일 문자열을 바탕으로 스타일의 hash name을 만듭니다.
  2. hash name, 스타일 문자열 등으로 SerializedStyles 객체를 만들어 반환합니다.

handleInterpolation

함수, 문자열, 숫자, 객체 등 스타일을 위해 작성된 값을 각 케이스 별로 처리하여 스타일 문자열로 변환합니다. 객체의 경우는 createStringFromObject 함수를 통해서 문자열 처리를 진행합니다. 이미 직렬화된 스타일의 경우는 역시 별도 처리없이 바로 반환됩니다.
소스코드 바로가기

createStringFromObject

이 함수는 전달받은 객체의 key와 value를 바탕으로 순회하며 스타일 문자열을 만들어 반환합니다.
소스코드 바로가기

정리하자면, (serializeStyleshandleInterpolationcreateStringFromObject)의 과정을 거쳐 SerializedStyles 객체를 만듭니다. 그리고 그 객체는 serialized 변수에 담아 최종 반환 값인 <Insertion/> 컴포넌트로 cache와 함께 props로 전달됩니다.


05. 직렬화된 스타일을 캐시에 등록 및 삽입

4번의 복잡했던 스타일 직렬화 과정을 거친 <Insertion/> 컴포넌트에서는 직렬화된 스타일을 캐시에 등록하고 삽입합니다.

emotion/packages/react/src/emotion-element.js
const Insertion = ({ cache, serialized, isStringTag }) => {
registerStyles(cache, serialized, isStringTag)

const rules = useInsertionEffectAlwaysWithSyncFallback(() =>
insertStyles(cache, serialized, isStringTag)
)

if (!isBrowser && rules !== undefined) {
let serializedNames = serialized.name
let next = serialized.next
while (next !== undefined) {
serializedNames += ' ' + next.name
next = next.next
}
return (
<style
{...{
[`data-emotion`]: `${cache.key} ${serializedNames}`,
dangerouslySetInnerHTML: { __html: rules },
nonce: cache.sheet.nonce
}}
/>
)
}
return null
}

소스코드 바로가기

  1. registerStyles에서 className을 key로 직렬화된 스타일을 value로 캐시에 등록합니다.
  2. useInsertionEffectAlwaysWithSyncFallback이라는 훅 안에서 insertStyles를 실행합니다.
  • CSR에서는 useInsertionEffect훅에서 insertStyles를 실행하여 스타일 삽입을 완료합니다.
  • SSR에서는 insertStyles의 반환된 값으로 <style/>태그를 렌더합니다.

registerStyles

전달받은 캐시의 key와 직렬화된 스타일의 name으로 className을 생성하고, 그 값을 key로 직렬화된 스타일문자열을 저장해둡니다.

// ...
let className = `${cache.key}-${serialized.name}`

/// ...
cache.registered[className] = serialized.styles

소스코드 바로가기

insertStyles

insertStyles에서는 EmotionCache.insert를 통해서 스타일을 컴파일하고 삽입합니다.

export const insertStyles = (
cache: EmotionCache,
serialized: SerializedStyles,
isStringTag: boolean
) => {
registerStyles(cache, serialized, isStringTag)

let className = `${cache.key}-${serialized.name}`

if (cache.inserted[serialized.name] === undefined) {
let stylesForSSR = ''
let current = serialized
do {
let maybeStyles = cache.insert(
serialized === current ? `.${className}` : '',
current,
cache.sheet,
true
)
if (!isBrowser && maybeStyles !== undefined) {
stylesForSSR += maybeStyles
}
current = current.next
} while (current !== undefined)
if (!isBrowser && stylesForSSR.length !== 0) {
return stylesForSSR
}
}
}

소스코드 바로가기

  1. 캐시의 key와 직렬화된 스타일의 name으로 className을 생성합니다.
  2. 직렬화된 스타일의 name을 key로 이미 삽입된 스타일인지 체크합니다.
  3. 삽입되지 않았다면 cache.insert로 해당 직렬화된 스타일을 삽입합니다.
  4. 삽입 후 반환 값을 SSR을 위해 저장하고 반환합니다.

stylis로 삽입할 스타일 CSSRule로 컴파일

EmotionCacheinsert 함수로 직렬화된 스타일을 현재의 StyleSheet에 주입하고 직렬화된 스타일의 name으로 cache에 삽입된 스타일을 저장합니다.

emotion/packages/cache/src/index.js
// ...
insert = (
selector: string,
serialized: SerializedStyles,
sheet: StyleSheet,
shouldCache: boolean
): void => {

// ...

stylis(selector ? `${selector}{${serialized.styles}}` : serialized.styles)

if (shouldCache) {
cache.inserted[serialized.name] = true
}
}
// ...

소스코드 바로가기

stylis

stylis는 CSS preprocessor로 주어진 스타일 문자열을 CSSRule로 컴파일 하는 라이브러리입니다. Emotion은 내부적으로 stylis를 통해 직렬화된 스타일을 처리합니다. 이 과정에서 직렬화된 스타일은 CSSRule로 파싱되고, Vendor Prefix를 추가하고 별도의 최적화처리를 거치게 됩니다.

import {
serialize,
compile,
middleware,
rulesheet,
stringify,
COMMENT
} from 'stylis'

// ...

const finalizingPlugins = [
stringify,
process.env.NODE_ENV !== 'production'
? element => { /* ... */ }
: rulesheet(rule => {
currentSheet.insert(rule)
})
]

// ...

const serializer = middleware(
omnipresentPlugins.concat(stylisPlugins, finalizingPlugins)
)

const stylis = styles => serialize(compile(styles), serializer)

// ...

소스코드 바로가기

위 stylis라는 함수 미들웨어로 등록된 플러그인 중 finalizingPlugins을 살펴보면 스타일을 주입하는 코드를 확인할 수 있습니다. 여기에서 currentSheetinsertStyles 내부에서 호출된 cache.insert 함수의 파라미터 cache.sheet입니다.

컴파일된 CSSRule을 StyleSheet에 삽입

다시 이 currentSheet는 Emotion의 StyleSheet 클래스의 인스턴스입니다.(최초에 EmotionCache를 생성할 때 함께 인스터스가 생성되었었습니다.) StyleSheet.insert 메소드에서는 CSSStyleSheet의 insert를 통해 위에서 stylis로 컴파일된 CSSRule을 실제 document.styleSheet에 삽입하게 됩니다.

소스코드 바로가기


정리

Emotion은 개발자가 작성한 CSS(문자열이든, Object이든)를 직렬화하고 <Insertion/>로 스타일을 문서에 삽입해주며,

  1. cssProp으로 부터 들어온 값을 스타일 직렬화 한다.
  2. 직렬화된 스타일은 캐시에 등록되어 다른 cssProp의 직렬화 효율을 높여준다.
  3. 직렬화된 스타일은 stylis를 통해 CSSRule로 변환된다.
  4. CSSRule은 CSSStyleSheet로 스타일이 주입된다.

그 과정에서 만들어지는 className이 본래의 컴포넌트에 추가되면서 최종적으로 스타일이 입혀진 컴포넌트가 렌더링되도록 해줍니다.