데브시스터즈에서는 React 기반의 GatsbyJS와 Typescript를 이용해 웹 사이트를 관리하고 있으며 프로젝트의 스타일링 방법론으로는 CSS in JS 방식을 사용하고 있습니다. Typescript 기반의 React 어플리케이션에서 CSS in JS를 사용하는 방식과 React의 Context API를 사용해 효과적으로의 Theme를 관리하는 방법을 소개합니다.
CSS in JS란?
CSS in JS는 Javascript 코드에서 CSS를 작성하는 방법을 말합니다. Facebook의 개발자인 vjeux는 아래와 같은 CSS 관리의 문제점을 CSS in JS를 도입함으로써 해결한 사례를 설명했습니다. 자세한 내용은 링크에서 확인할 수 있습니다.
- Global namespace
- Dependencies
- Dead Code Elimination
- Minification
- Sharing Constants
- Non-deterministic Resolution
- Isolation
대표적인 CSS in JS 라이브러리로는 JSS, styled-components, emotion이 존재합니다. 현재 프로젝트에서는 emotion을 사용해 개발을 진행하고 있으며 emotion의 대표적인 사용 방법은 아래와 같습니다.
import React from 'react';
import ReactDOM from 'react-dom';
import styled from '@emotion/styled';
// object 스타일
const Container = styled.div({
width: 180,
height: 180,
backgroundColor: '#fd7622',
});
// template literal 스타일
const Title = styled.h1`
color: #ffffff;
`;
const Example = () => (
<Container>
<Title>Devsisters</Title>
</Container>
);
const rootElement = document.getElementById('root');
ReactDOM.render(<Example />, rootElement);
emotion은 object
나 template literal
로 자바스크립트 코드를 작성해 스타일을 표현할 수 있습니다. 또한 자바스크립트 코드를 통해 실제 CSS가 생성되기 때문에 가상선택자와 같은 CSS의 기능을 대부분 이용할 수 있습니다.
CSS in JS 라이브러리에서의 Theme
React에서 사용할 수 있는 대부분의 CSS in JS 라이브러리는 테마(Theme)와 관련된 기능을 제공합니다.
emotion은 React의 Context API를 기반으로 만들어진 ThemeProvider
를 제공하며 emotion 공식문서가 소개하는 ThemeProvider
를 이용하는 방법 3가지 중 데브시스터즈에서는 아래의 styled
기법을 사용합니다.
ThemeProvider
로 감싸진 자식 컴포넌트들은 theme
에 정의된 스타일을 props
으로 전달받아 사용할 수 있습니다. 위의 예제에서는 Title
, SubTitle
, Link
가 theme
에 존재하는 같은 fontColor
를 사용합니다. ThemeProvider
를 이용해 공통으로 사용하는 스타일을 관리할 수 있어 각각의 컴포넌트에 color
속성값을 따로 지정할 때 보다 스타일을 관리하기 간편해졌습니다.
Typescript와 CSS in JS 라이브러리에서의 Theme
실제 코드를 작성할 때에는 타입스크립트를 사용하고 있음으로 ThemeProvider
에 전달되는 theme
속성값들 또한 타입 검사를 진행해야 합니다. 아래와 같이 기존의 ThemeProvider
를 가져와 theme
속성에 들어갈 타입을 의미하는 Theme
타입을 정의해 확장하는 방식으로 사용할 수 있습니다.
또한 styled
방식으로 생성되는 컴포넌트의 props
의 theme
에도 ThemeProvider
의 theme
와 동일한 타입을 전달하기 25번 라인의 코드를 추가했습니다. styled.div
와 같이 생성되는 컴포넌트의 props
의 theme
에도 지정해준 타입이 모두 적용되었음을 확인할 수 있습니다. 작성한 ThemeProvider
와 styled
는 기존의 Javascript로 작성된 코드와 동일하게 사용할 수 있습니다.
기존의 자바스크립트와 다른 점은 theme
의 타입을 Typescript가 알고 있기 때문에 타입에 지정되지 않은 속성을 추가하거나 지정된 속성의 값의 타입이 맞지 않는 경우 타입 오류가 발생하는 것을 볼 수 있습니다. 이처럼 theme
의 타입을 지정함으로써 불필요한 속성값이 ThemeProvider
에 들어가는 것을 방지할 수 있게 되었습니다.
React Context API를 사용해 Theme 확장 유틸 만들기
현재 진행 중인 React 프로젝트는 Fractal 패턴을 사용하여 폴더 구조를 관리하고 있습니다. 폴더의 깊이가 깊어지면서 하위 컴포넌트에서 부모 컴포넌트에서 사용하지 않던 테마 속성을 추가적으로 사용해야 할 경우 어플리케이션의 최상단에 존재하는 ThemeProvider
에는 많은 속성값이 생겨날 것입니다. 또한 아래의 그림과 같이 부모에서 사용하는 속성값과 자식에서 사용하는 테마 속성값을 다르게 주고 싶은 경우가 생길 수 있습니다.
위의 이미지에서 A 컴포넌트의 부모에는 아래 타입을 갖는 테마 객체를 요구하는 ThemeProvider
가 존재할 것입니다.
type Theme = {
color: {
bgColor: string;
textColor: string;
};
};
해당 ThemeProvider
의 테마 속성이 갖는 bgColor
와 textColor
는 A, B, C, D, E 컴포넌트에서 모두 사용할 수 있습니다. 그러나 A 컴포넌트의 하위에 있는 B 컴포넌트와 D 컴포넌트는 A 컴포넌트와 theme
에 존재하는 bgColor
속성값이 다릅니다. 또한 최상위에 있는 ThemeProvider
에 존재하지 않는 border
라는 속성값이 존재합니다. B 컴포넌트와 D 컴포넌트가 사용하고 있는 테마의 속성값의 타입은 아래와 같아야 정상적으로 동작할 것입니다.
type Theme = {
color: {
bgColor: string;
textColor: string;
border: string;
};
};
이런 경우 React의 Context API를 사용해 ThemeProvider
를 확장할 수 있습니다. 무한하게 중첩이 가능한 Context API의 성질을 이용해 기존에 작성한 ThemeProvider
를 확장할 것입니다. 중첩되는 ThemeProvider
에 주입되는 테마의 타입은 아래와 같을 것입니다.
src/utils/makeTheme.tsx
type MergedTheme<BaseTheme extends object, Theme extends object> =
& BaseTheme
& Theme;
여기서 BaseTheme
는 부모 ThemeProvider
의 테마 타입이며 Theme
는 부모에 합칠 새로운 ThemeProvider
의 속성값 타입입니다. Intersection Type을 표현하는 &
연산자를 이용해 두 테마의 타입을 합쳐줍니다. 그 후 기존의 새로운 theme
을 받아 부모의 ThemeProvider
와 합쳐주는 makeTheme
함수를 작성합니다.
src/utils/makeTheme.tsx
export function makeTheme<BaseTheme extends object, Theme extends object>() {
// BaseTheme와 Theme 타입을 병합한 타입인 NewTheme를 정의
type NewTheme = MergedTheme<BaseTheme, Theme>;
// NewTheme 타입을 갖는 styled 함수를 생성
const styled = _styled as CreateStyled<NewTheme>;
// NewTheme 타입의 객체를 받는 ThemeProvider Context 생성
const ThemeProvider: React.FC<{ theme: NewTheme }> = ({
theme,
children,
}) => {
return (
<BaseThemeProvider theme={theme}>
{children}
</BaseThemeProvider>
);
};
return { styled, ThemeProvider };
}
기존에 작성했던 ThemeProvider
를 makeTheme
함수에서 반환하는 형태로 코드를 재사용해 사용할 수 있습니다. styled
방식으로 생성되는 컴포넌트에도 새로운 테마의 타입이 적용되도록 CreateStyled
의 제네릭 타입에 NewTheme
타입을 전달해 줍니다. ThemeProvider
는 기존의 테마를 확장해 사용할 수 있도록 매개변수로 전달받는 새로운 theme
의 타입을 NewTheme
로 설정합니다. 위의 코드를 보다 보면 의문이 드는 부분이 생길 수 있습니다.
<BaseThemeProvider theme={theme}>{children}</BaseThemeProvider>
BaseTheme
타입을 갖는 부모 레벨에 있는 ThemeProvider
가 갖는 실제 속성값을 넘겨주는 부분이 따로 작성되어 있지 않습니다. 예를 들어 아래의 mergeTheme
과 같은 theme
값들을 병합해주는 함수를 생각해볼 수 있습니다.
function mergeTheme<BaseTheme extends object, Theme extends object>(
baseTheme: BaseTheme,
theme: Theme
): MergedTheme<BaseTheme, Theme> {
return { ...baseTheme, ...theme };
}
<ThemeProvider theme={mergeTheme(baseTheme, theme)}>{children}</ThemeProvider>;
기본적으로 emotion-theming
패키지의 ThemeProvider
에서 Object.assign
수준으로 theme
객체들을 복사해주므로 테마를 합쳐주는 기능을 하는 mergeTheme
함수를 따로 작성할 필요는 없습니다. 자세한 내용은 emotion-theming 공식문서에서 확인할 수 있습니다. 최종적으로 ThemeProvider
를 확장하기 위해서는 makeTheme
함수와 MergedTheme
타입만 존재하면 됩니다.
작성한 Theme 확장 유틸 사용하기
작성한 makeTheme
함수를 사용하기 위해 간단한 예제를 작성했습니다. 최종적인 결과는 아래의 이미지로 위에서 그려보았던 그림과 동일한 형태로 구현되어 있습니다. A, C, E 컴포넌트는 가장 최상단의 ThemeProvider
를 사용하며 B 컴포넌트와 D 컴포넌트는 최상단의 ThemeProvider
를 확장한 새로운 ThemeProvider
를 사용합니다.
최상단에 존재하는 ThemeProvider
는 아래와 같은 형태로 작성됩니다. string
타입의 값을 갖는 bgColor
와 textColor
속성을 갖는 color
객체를 갖는 theme
속성이 작성되어 있으며 makeTheme<{}, Theme>
형태로 사용합니다. 여기에서 MergedTheme
타입은 {} & Theme
이므로 Theme
타입과 동일합니다.
src/components/themeContext.ts
import { makeTheme } from '../utils/theme';
export type Theme = {
color: {
bgColor: string;
textColor: string;
};
};
export const { styled, ThemeProvider } = makeTheme<{}, Theme>();
themeContext.ts
파일에 작성된 ThemeProvider
는 App.tsx
에서 아래와 같이 사용됩니다. App 컴포넌트는 물론 B와 C 컴포넌트 모두 App 컴포넌트의 최상단에 위치하는 ThemeProvider
에 존재하는 theme
속성값을 사용합니다.
src/components/App.tsx
import React from "react";
import B from "./B";
import C from "./C";
import { styled, ThemeProvider } from "./themeContext";
const Container = styled.div((props) => ({
backgroundColor: props.theme.color.bgColor
}));
const Title = styled.h1((props) => ({
color: props.theme.color.textColor
}));
export default function App() {
return (
<ThemeProvider
theme={{
color: {
textColor: '#FD7622',
bgColor: '#FFF2CC',
},
}}>
<Container className="App">
<Title>This is A component</Title>
<B />
<C />
</Container>
</ThemeProvider>
);
}
하지만 B와 D 컴포넌트는 App 컴포넌트 내부에서 사용된 ThemeProvider
의 테마 속성값을 사용하지 않고 border
속성을 추가로 갖는 새로운 ThemeProvider
의 테마 속성값을 사용해야 합니다. 따라서 B
라는 폴더를 생성한 후 폴더 내부에 새로운 themeContext.ts
파일을 생성합니다. 이 파일에서 생성된 ThemeProvider
의 타입은 BaseTheme & Theme
가 됩니다.
src/components/B/themeContext.ts
import { Theme as BaseTheme } from '../themeContext';
import { makeTheme } from '../../utils/theme';
type Theme = {
color: {
border: string;
};
};
export const { styled, ThemeProvider } = makeTheme<BaseTheme, Theme>();
결론적으로 App 컴포넌트 내부에 있는 ThemeProvider
의 테마 속성값인 bgColor
, textColor
외에도 추가로 border
라는 속성을 갖게 됩니다. 따라서 B, D 컴포넌트는 이 ThemeProvider
와 styled
를 가져와 사용할 수 있습니다.
src/components/B/index.tsx
import React from "react";
import D from "./D";
import { styled, ThemeProvider } from "./themeContext";
const Container = styled.div(props => ({
backgroundColor: props.theme.color.bgColor,
border: props.theme.color.border,
}));
const Text = styled.p(props => ({
color: props.theme.color.textColor,
}));
function B() {
return (
<ThemeProvider
theme={{
color: {
textColor: '#FD7622',
bgColor: '#FFFFFF',
border: '4px solid red',
},
}}>
<Container>
<Text>A component's child B component</Text>
<D />
</Container>
</ThemeProvider>
);
}
export default B;
이처럼 프로젝트의 테마 확장이 필요한 경우 하위 폴더에 새로운 themeContext.ts
파일을 생성한 후 부모 경로에 있는 themeContext.ts
에서 Theme
타입만 가져와 합치게 되면 기존의 ThemeProvider
를 확장할 수 있게 됩니다.
Theme 확장 유틸을 사용하는 예시
이로써 간단하게 React의 Context API의 중첩 가능한 특성을 이용해 CSS in JS 라이브러리인 emotion의 테마 기능을 Typesafe하게 확장할 수 있게 되었습니다. Fractal 패턴을 이용하지 않은 프로젝트에서도 사용 가능하며 복잡하게 중첩된 ThemeProvider
를 사용하면서 테마 속성값에 잘못된 값을 넣게 되는 상황을 피하여 테마를 확장할 수 있습니다.
Emotion 11버전 마이그레이션
emotion의 버전이 메이저 버전이 11로 올라가게 되면서 emotion의 많은 타입 정의들이 변경되었습니다. 타입 정의가 크게 변경되었지만, emotion의 타입을 확장하는 방식으로 타입을 관리하고 있었기 때문에 약간의 타입 정의만 추가해 동일하게 사용할 수 있습니다. 우선 예시 코드에서 사용했던 타입 중 변경된 타입을 알아보겠습니다.
- 11버전 이전의
CreateStyled
인터페이스
export interface CreateStyled<Theme extends object = any>
extends BaseCreateStyled<Theme>,
StyledTags<Theme> {}
- 11버전의
CreateStyled
인터페이스
export interface CreateStyled extends BaseCreateStyled, StyledTags {}
CreateStyled
인터페이스가 더는 Theme
제네릭을 사용하지 않습니다. 따라서 기존과 같이 CreateStyled
를 사용하기 위해서는 새로운 CreateStyled
가 확장하고 있는 BaseCreateStyled
와 StyledTags
를 확장해 사용해야 합니다. BaseCreateStyled
인터페이스의 정의는 아래와 같습니다.
- emotion/styled의
CreateStyled
인터페이스 정의
export interface CreateStyled {
<
C extends React.ComponentClass<React.ComponentProps<C>>,
ForwardedProps extends keyof React.ComponentProps<
C
> = keyof React.ComponentProps<C>
>(
component: C,
options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
): CreateStyledComponent<
Pick<PropsOf<C>, ForwardedProps> & {
theme?: Theme
as?: React.ElementType
},
{},
{
ref?: React.Ref<InstanceType<C>>
}
>
<C extends React.ComponentClass<React.ComponentProps<C>>>(
component: C,
options?: StyledOptions<React.ComponentProps<C>>
): CreateStyledComponent<
PropsOf<C> & {
theme?: Theme
as?: React.ElementType
},
{},
{
ref?: React.Ref<InstanceType<C>>
}
>
<
C extends React.ComponentType<React.ComponentProps<C>>,
ForwardedProps extends keyof React.ComponentProps<
C
> = keyof React.ComponentProps<C>
>(
component: C,
options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
): CreateStyledComponent<
Pick<PropsOf<C>, ForwardedProps> & {
theme?: Theme
as?: React.ElementType
}
>
<C extends React.ComponentType<React.ComponentProps<C>>>(
component: C,
options?: StyledOptions<React.ComponentProps<C>>
): CreateStyledComponent<
PropsOf<C> & {
theme?: Theme
as?: React.ElementType
}
>
<
Tag extends keyof JSX.IntrinsicElements,
ForwardedProps extends keyof JSX.IntrinsicElements[Tag] = keyof JSX.IntrinsicElements[Tag]
>(
tag: Tag,
options: FilteringStyledOptions<JSX.IntrinsicElements[Tag], ForwardedProps>
): CreateStyledComponent<
{ theme?: Theme; as?: React.ElementType },
Pick<JSX.IntrinsicElements[Tag], ForwardedProps>
>
<Tag extends keyof JSX.IntrinsicElements>(
tag: Tag,
options?: StyledOptions<JSX.IntrinsicElements[Tag]>
): CreateStyledComponent<
{ theme?: Theme; as?: React.ElementType },
JSX.IntrinsicElements[Tag]
>
}
BaseCreateStyled
인터페이스 중간중간 Theme
타입을 갖는 theme
를 사용하는 것을 볼 수 있습니다. 이 부분을 이전과 같이 제네릭으로 만들어 아래와 같이 확장하여 사용할 수 있습니다.
- 확장한
CreateStyled
인터페이스 정의
import type { CreateStyledComponent, StyledOptions } from '@emotion/styled';
import type { FilteringStyledOptions } from '@emotion/styled/types/base';
import type { PropsOf } from '@emotion/react';
export interface CreateStyled<Theme> {
<
C extends React.ComponentType<React.ComponentProps<C>>,
ForwardedProps extends keyof React.ComponentProps<
C
> = keyof React.ComponentProps<C>
>(
component: C,
options: FilteringStyledOptions<React.ComponentProps<C>, ForwardedProps>
): CreateStyledComponent<
Pick<PropsOf<C>, ForwardedProps> & {
theme?: Theme
as?: React.ElementType
}
>
<C extends React.ComponentType<React.ComponentProps<C>>>(
component: C,
options?: StyledOptions<React.ComponentProps<C>>
): CreateStyledComponent<
PropsOf<C> & {
theme?: Theme
as?: React.ElementType
}
>
<
Tag extends keyof JSX.IntrinsicElements,
ForwardedProps extends keyof JSX.IntrinsicElements[Tag] = keyof JSX.IntrinsicElements[Tag]
>(
tag: Tag,
options: FilteringStyledOptions<JSX.IntrinsicElements[Tag], ForwardedProps>
): CreateStyledComponent<
{ theme?: Theme; as?: React.ElementType },
Pick<JSX.IntrinsicElements[Tag], ForwardedProps>
>
<Tag extends keyof JSX.IntrinsicElements>(
tag: Tag,
options?: StyledOptions<JSX.IntrinsicElements[Tag]>
): CreateStyledComponent<
{ theme?: Theme; as?: React.ElementType },
JSX.IntrinsicElements[Tag]
>
}
마찬가지로 StyledTags
타입 또한 emotion 내부에 정의된 타입들을 가져와 직접 관리해야 합니다. 11버전의 StyledTags
타입은 아래와 같습니다.
- emotion/styled의
StyledTags
타입 정의
export type StyledTags = {
[Tag in keyof JSX.IntrinsicElements]: CreateStyledComponent<
{
theme?: Theme
as?: React.ElementType
},
JSX.IntrinsicElements[Tag]
>
}
StyledTags
역시 emotion 내부에 존재하는 Theme
타입을 사용하고 있습니다. 이 부분 역시 제네릭을 이용해 타입을 확장할 수 있습니다.
- 확장한
StyledTags
타입 정의
export type StyledTags<Theme> = {
[Tag in keyof JSX.IntrinsicElements]: CreateStyledComponent<
{
theme?: Theme
as?: React.ElementType
},
JSX.IntrinsicElements[Tag]
>
}
최종적으로 기존에 작성했던 makeTheme
함수는 아래와 같이 변경됩니다.
export function makeTheme<BaseTheme extends object, Theme extends object>() {
type NewTheme = MergeTheme<BaseTheme, Theme>;
// 변경된 부분
const styled = _styled as CreateStyled<NewTheme> & StyledTags<NewTheme>;
const ThemeProvider: React.FC<{ theme: NewTheme }> = ({
theme,
children,
}) => {
return (
<BaseThemeProvider theme={theme}>
{children}
</BaseThemeProvider>
);
};
return { styled, ThemeProvider };
}
emotion이 11로 메이저 버전이 올라가며 치명적인 타입 이슈가 생길 수 있었지만, 중간에서 다리 역할을 해주는 makeTheme
과 같은 함수가 존재하였기 때문에 어렵지 않게 패키지 내부의 타입들을 확장해 마이그레이션을 할 수 있습니다.