jgjgill

Next.js에서의 styled-components 탐구

5 min read
Exploring styled-components in next.js thumbnail

Next.js에서의 styled-components

해당 글의 버전은 Next.js v14(Page Router), styled-components v6으로 진행됩니다.


프로젝트에서 Next.jsstyled-omponents를 사용하고 있다.

근데 Next.js를 사용할 때 CSS-in-JS을 쓰면 문제가 많다는 것을 듣기만 했지 자세히 살펴보지는 않았었다.

이번 기회에 한 번 알아보고자 한다. 🧐

문제 접근하기

기본 환경 세팅은 npx create-next-app@latest으로 진행했다.


예제 코드는 다음과 같다.

pages/index.tsx

import styled from 'styled-components'

export default function Home() {
  return (
    <>
      <Container>
        <Title>테스트를 진행합니다.</Title>
        <Button>Click Me!</Button>
      </Container>
    </>
  )
}

const Container = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background-color: #e0ffe0;
`

const Title = styled.h1`
  font-size: 2.5em;
  color: #008000;
`

const Button = styled.button`
  background-color: #008000;
  color: white;
  border: none;
  padding: 10px 20px;
  font-size: 1em;
  cursor: pointer;
  border-radius: 5px;
  margin-top: 20px;
`

우선 next.config.js에서 styledComponents: true로 설정한다.

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  compiler: { styledComponents: true },
}

export default nextConfig

이거만 해도 화면에서는 제대로 스타일이 적용되는 것을 볼 수 있다.

sc example

하지만 현재 상황은 클라이언트에서만 스타일을 받아오는 상황이다.

styled-componentsCSS-in-JS 방식으로 JavaScript를 통해 스타일을 구성된다. (일반 CSS로 직렬화하는 과정이 필요하다.)

예제 코드에서 사용된 #00800을 네트워크 탭에서 검색하면 index.js 번들에 포함된 것을 확인할 수 있다.


index.js 번들에 스타일이 포함된 모습

index.js bundle

따라서 지금과 같은 상황에서 styled-components는 클라이언트에서만 스타일을 받아오게 된다.

하지만 Next.js와 같이 미리 서버에서 렌더링이 일어나는 경우 깜빡이는 문제와 스타일이 적용되지 않은 문서를 볼 수 있다.


깜빡이는 문제


스타일이 적용되지 않은 문서

스타일이 적용되지 않은 문서

문제 해결하기

감사하게도 관련 예제를 제공해주고 있다.

pages/_documents.tsx

import Document, { DocumentContext, DocumentInitialProps } from 'next/document'
import { ServerStyleSheet } from 'styled-components'

export default class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
    const sheet = new ServerStyleSheet()
    const originalRenderPage = ctx.renderPage

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />),
        })

      const initialProps = await Document.getInitialProps(ctx)
      return {
        ...initialProps,
        styles: [initialProps.styles, sheet.getStyleElement()],
      }
    } finally {
      sheet.seal()
    }
  }
}

세부적인 내용은 나중에 살펴보고 결과부터 보면 기대하는 바와 일치한다.


더 이상 깜빡거리지 않는다.


처음 문서에도 스타일이 적용된 상태로 오는 것을 확인할 수 있다.

sc 해결 2

styled-components는 어떻게 해결했을까?

_documents.tsx 코드를 다시 살펴보자.

크게 3가지 부분으로 나눌 수 있을 것 같다.


우선 서버 시트를 생성한다.

const sheet = new ServerStyleSheet()

해당 로직에서는 sheet.collectStyles을 통해 컴포넌트에서 작성했던 스타일들을 수집한다.

// Next.js에서 제공하는 기본 페이지 렌더링 메서드
ctx.renderPage = () =>
  // Next.js 기본 렌더링 로직을 참조하는 변수
  originalRenderPage({
    // Next.js 전체 react tree(App 컴포넌트)를 감싸기 위한 고차 컴포넌트(HOC)
    enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), // 스타일 수집
  })

html에 스타일 태그를 추가한다.

여기서 sheet.getStyleElement을 통해 수집했던 스타일을 React Element로 반환하게 해준다.

return {
  ...initialProps,
  styles: [initialProps.styles, sheet.getStyleElement()], // 수집된 스타일을 React Element로 반환
}

추가된 스타일 태그

추가된 스타일 태그

styled-components 코드 살펴보기

동작을 좀 더 이해해보자.

ServerStyleSheet 코드


시작점인 ServerStyleSheet을 통해 생성된 인스턴스에서 앞서 살펴본 2가지 동작이 발생한다.

  • collectStyles: 스타일 수집
  • getStyleElement:수집된 스타일을 React Element로 반환

collectStyles의 코드는 다음과 같다.

collectStyles 코드


collectStyles

collectStyles(children: any): JSX.Element {
  if (this.sealed) {
    throw styledError(2);
  }

  return <StyleSheetManager sheet={this.instance}>{children}</StyleSheetManager>;
}

StyleSheetManager가 등장한다.

StyleSheetManager 코드


StyleSheetManager

export function StyleSheetManager(props: IStyleSheetManager): JSX.Element {
  const [plugins, setPlugins] = useState(props.stylisPlugins);
  const { styleSheet } = useStyleSheetContext();

  const resolvedStyleSheet = 스타일 시트를 설정하는 함수

  const stylis = 스타일 코드로 만들어주는 함수

  ...

  const styleSheetContextValue = useMemo(
    () => ({
      shouldForwardProp: props.shouldForwardProp,
      styleSheet: resolvedStyleSheet,
      stylis,
    }),
    [props.shouldForwardProp, resolvedStyleSheet, stylis]
  );

  return (
    <StyleSheetContext.Provider value={styleSheetContextValue}>
      <StylisContext.Provider value={stylis}>{props.children}</StylisContext.Provider>
    </StyleSheetContext.Provider>
  );
}

해당 컨텍스트를 통해 스타일을 수집 및 변환해서 StyleSheetManager에서 주입한 sheet에 반영될 것으로 예상된다.


다음으로 getStyleElement 코드이다.

getStyleElement 코드


getStyleElement

getStyleElement = () => {
  if (this.sealed) {
    throw styledError(2)
  }

  const props = {
    [SC_ATTR]: '',
    [SC_ATTR_VERSION]: SC_VERSION,
    dangerouslySetInnerHTML: {
      __html: this.instance.toString(),
    },
  }

  const nonce = getNonce()
  if (nonce) {
    ;(props as any).nonce = nonce
  }

  // v4 returned an array for this fn, so we'll do the same for v5 for backward compat
  return [<style {...props} key="sc-0-0" />]
}

dangerouslySetInnerHTML가 눈에 띈다.

this.instance.toString()을 통해 앞서 시트에 수집한 스타일들을 문자열로 반환한다.

그 다음 dangerouslySetInnerHTML을 통해 DOM에 주입하는데 style 태그에 반환하는 것을 알 수 있다.

마무리

이전까지는 서버 렌더링이 이루어질 때 CSS-in-JS가 문제가 많다고 듣기만 했지 깊게 파고들지는 못했다.

이번 기회에 직접 코드를 구성해보면서 어떠한 경우에 문제가 발생하고 어떻게 해결하는지 알게 되었다.

그리고 직접 styled-components 코드도 살펴보면서 흥미롭게 학습한 시간이 되었던 것 같다.

참고 문서

@2023 powered by jgjgill