대표적인 CSS in JS 라이브러리인 Emotion의 소스코드를 분석하면서 동작원리를 이해해보겠습니다. 동작원리를 이해하게 되면 장점을 극대화하고, 단점을 최소화하여 라이브러리를 효과적으로 쓸 수 있는 방법을 발견해낼 수 있습니다. 이 글에서는 @emotion/react
와 cssProp를 중심으로 설명합니다.
주의깊게 볼 패키지
Emotion은 스타일을 생성하고 최적화하기 위한 많은 패키지들을 가지고 있습니다. 그 중 전체적인 흐름을 이해하기 위해서 다음 패키지들의 코드를 중점적으로 살펴보게 됩니다.
전체 패키지 보기
packages
├── cache
├── css
├── react
├── serialize
├── sheet
├── use-insertion-effect-with-fallbacks
├── utils
전체적인 동작 과정
@emotion/react
에 의한 JSX 트랜스파일링- 브라우저에서 React 렌더시작
- Emotion cache 생성
- cssProp에 전달된 스타일 직렬화
- 직렬화된 스타일을 캐시에 등록 및 삽입
- stylis로 스타일을 CSSRule로 컴파일
- 컴파일된 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
함수의 호출로부터 시작합니다.
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-runtime
의 jsx
는 React의 jsx가 cssProp를 처리할 수 있도록 확장하고 있습니다. jsx의 파라미터 중 type에 Emotion
을, props에 createEmotionProps(type, props)
를 전달하고 있습니다.
Emotion
첫번째로 전달되는 파라미터는 Emotion
입니다. jsx는 React의 createElement
로 변환되고, 그 함수의 첫번째 파라미터는 string, HTMLElement, Component 등으로 지정되어 있습니다. 그리고 네이밍 컨벤션이 PascalCase인 점을 미루어 보아 Emotion
은 리액트 컴포넌트인 것으로 예상해볼 수 있습니다.
let Emotion = /* #__PURE__ */ withEmotionCache<any, any>(
(props, cache, ref) => {
//...
return (
<>
<Insertion
cache={cache}
serialized={serialized}
isStringTag={typeof WrappedComponent === 'string'}
/>
<WrappedComponent {...newProps} />
</>
)
}
)
예상대로 Element
변수는 컴포넌트를 반환하고 있습니다. 함수 시그니처 부터 살펴보겠습니다.
withEmotionCache
라는 HOC(High order Component)로 컴포넌트를 감싸고 있습니다. 네이밍으로 유추해보면, emotion의 cache 처리와 관련한 로직이 있을 것으로 예상할 수 있습니다.- 원래의 컴포넌트인
<WrappedComponent>
는<Insertion/>
이라는 컴포넌트와 함께 렌더링될 것입니다. 네이밍으로 유추하자면,<Insertion/>
은 스타일의 삽입이 이루어지는 곳이라고 예상할 수 있습니다.
createEmotionProps
Emotion컴포넌트에서 사용할 Props를 생성하는 함수입니다. 주요 역할은 컴포넌트 타입 결정, cssProp의 유효성을 검사, 개발환경을 위한 디버깅 처리 등 있습니다.
03. Emotion Cache 생성
여기에서 자세히 설명하진 않지만, Emotion에서는 contextAPI를 활용하여 EmotionCacheContext
를 생성하고 Emotion의 스타일 주입이 필요한 컴포넌트에서 Cache에 접근할 수 있도록 하고 있습니다.
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로 스타일을 직렬화 할 것입니다.
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
에서 확인하여 이미 등록된게 있으면 직렬화가 완료된 스 타일을 가져오고, 없으면 직렬화를 진행한다는 것입니다.
cssProp
이string
이고 그 값을 키로 캐시에 등록된 스타일이 있으면cssProp
에 캐시에 등록된 값을 할당합니다.cssProp
을 배열의 첫번째 요소로 추가하여registeredStyles
를 초기화합니다.getRegisteredStyles
함수를 통해서registeredStyles
에 캐시에 등록된 스타일들을 추가하고, 새로운 className을 생성합니다.serializeStyles
로registeredStyles
를 직렬화 합니다.
맨 위에서 간단한 예시로 들었던 Simple 컴포넌트로 다시 설명하자면,
<button css={{
backgroundColor: 'blue'
}}>
간단한 버튼입니다.
</button>
cssProp에 전달한 { backgroundColor: 'blue' }
가 serializeStyles
에 의해서 직렬화됩니다.