대표적인 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에 의해서 직렬화됩니다.
