styled-components는 어떻게 동작할까?

요즘 react 커뮤니티에서 css-in-js (styled-components, emotion, etc)의 인기가 계속 높아지고 있습니다.

styled-components는 작년 12월에 비해 npm 주간 다운로드수가 약 2배(65만 => 135만) 상승했고, @emotion/core도 작년 12월에 비해 약 7배(25만 => 174만) 상승했습니다.

하지만 점점 styled.(*)에 대해 아시는 분들은 많아지지만, 내부 동작이 어떻게 이루어지는지 아시는 분들은 별로 없으실 겁니다.

그래서 같이 한번 styled-components를 흉내(?) 내며 어떻게 이런 매직이 가능한지 알아보도록 하겠습니다.

Tagged Template Literals

흉내 내기 전에 styled-components를 써보셨다면 많이 보셨을 styled.h1`..` 에 대해 알아보겠습니다.

styled.h1은 사실 평범한 함수인데 Tagged Template Literal이라는 es6에 추가된 문법으로 호출한 것입니다.

const name = 'John'
const location = 'seoul'

const tag = (strs, firstExpr, secondExpr) =>
  console.log(strs, firstExpr, secondExpr)

tag`나는 ${location}에 있는 ${name}이야` // [나는 ", "에 있는 ", "이야"], "seoul", "john"

해당 문법으로 함수를 호출하게 되면 첫 번째 인자로 문자열 부분만 들어간 배열이 전달되고, 나머지 인자들에는 표현식들이 순서대로 전달됩니다.

const styled = (strs, ...exprs) => [strs, exprs]

const result = styled`
  background-color: ${({ primary }) => (primary ? 'white' : primaryColor)};
  padding: 1rem;
`
console.log(result) // ["background-color: ", "; padding: 1rem;", raw: Array(2)], [({ primary }) => (primary ? 'white' : primaryColor)]

그래서 Tagged Template Literal를 사용하게 되면 손 쉽게 문자열 부분과 표현식 부분을 분리해서 배열로 받아올 수 있습니다.

나만의 styled-components 구현

//  myStyled.js
import React, { useRef, useEffect } from 'react'

const myStyled = TargetComponent => ([style]) => props => {
  const elementRef = useRef(null)

  useEffect(() => {
    elementRef.current.setAttribute('style', style)
  }, [elementRef])

  return <TargetComponent {...props} ref={elementRef} />
}

export default myStyled

// index.js
const Button = myStyled('button')`
  color: red;
  border: 2px solid red;
  border-radius: 3px;
`

ReactDOM.render(<Button>Submit</Button>, rootElement)

Live Demo Link


먼저 간단하게 styled를 구현해보면 해당 함수는 함수를 반환하는 고차 함수의 형태를 가지고 있습니다.

맨 처음 함수를 호출할 때, 생성할 html tag name을 전달해주고, 다시 두 번째 함수를 호출할 때 Tagged Template Literal 문법을 사용하여 스타일값을 전달해주면 결과적으로 functional component가 반환되며, 해당 컴포넌트가 mount 됐을 때 TargetComponent의 inline-style로 스타일을 넣어줍니다.

하지만 이렇게 구현을 하면 styled-componentsstyled.h1방식으로도 사용할 수 있지만, 우리가 만든 myStyledmyStyled('h1')방식으로만 호출해줘야 하므로 살짝 수정해야합니다.

// myStyled.js
import React, { useRef, useEffect } from 'react'
import domElements from './domElements'

const myStyled = TargetComponent => ([style]) => props => {
  ...
  (위와 동일)
}

// NEW!
domElements.forEach(domElement => {
  myStyled[domElement] = myStyled(domElement)
})

export default myStyled

// index.js
const Button = myStyled.button`
  color: red;
  border: 2px solid red;
  border-radius: 3px;
`

ReactDOM.render(<Button>Click me</Button>, rootElement)

Live Demo Link


모든 domElements를 받아와서 myStyled의 메소드로 넣어주는 로직이 추가됐습니다.

실제 styled-components코드를 봐도 위와 동일하게 동작하는데요.(styled-components github code)

사실 styled.h1을 사용하면 내부적으로는 styled('h1')으로 변경돼서 동작됐네요.

하지만 아직 부족합니다 styled-components의 경우에는 props를 이용해서 동적으로 스타일링이 가능하지만 현재 myStyled의 경우 불가능 하기 때문에 가능하게 수정해줘야 합니다.

// myStyled.js
import React, { useRef, useEffect } from 'react'
import domElements from './domElements'

// NEW!
const myStyled = TargetComponent => (strs, ...exprs) => props => {
  const elementRef = useRef(null)

  useEffect(() => {
    // NEW!
    const style = exprs.reduce((result, expr, index) => {
      const isFunc = typeof expr === 'function'
      const value = isFunc ? expr(props) : expr

      return result + value + strs[index + 1]
    }, strs[0])

    elementRef.current.setAttribute('style', style)
  })

  return <TargetComponent {...props} ref={elementRef} />
}

domElements.forEach(domElement => {
  myStyled[domElement] = myStyled(domElement)
})

export default myStyled

// index.js
const Button = myStyled.button`
  color: ${props => (props.color ? props.color : 'red')};
  border: 2px solid red;
  border-radius: 3px;
`

ReactDOM.render(<Button color="blue">Click me</Button>, rootElement)

Live Demo Link


Tagged Template Literal의 표현식들과 문자열들을 합쳐 하나의 문자열(스타일)로 만들어주는 코드가 useEffect안에 추가됐습니다.

표현식이 함수로 전달하는 경우 말고 변수 형태로 전달하는 경우도 있을 수 있기 때문에 먼저 해당 표현식이 함수인지 아닌지를 확인하고 함수일 경우 인자로 props를 넘겨주면서 호출하고 아닐 경우 해당 표현식을 반환합니다.

이렇게 간단하게 styled-components를 흉내내봤는데요, 하지만 이렇게 사용하기에는 인라인 스타일을 사용하기 때문에 보안상 이슈도 있고, scss와 같이 nesting selector도 사용 못합니다.

그래서 styled-components에서는 위 문제들을 해결하기 위해 추가적인 작업들을 해주는데요 같이 한번 알아보도록 하겠습니다.

styled-components의 내부 동작원리

앱에서 맨 처음 styled-components를 import하면 styled-components는 자신을 통해 생성된 모든 컴포넌트의 개수를 계산하는 내부 카운터 변수를 만듭니다.

그 이후 styled-components로 새 컴포넌트를 만들게 되면, 내부 식별자 componentId가 생성되고 내부 카운터의 값이 1 올라갑니다.

componentIdMurmurHash 알고리즘으로 생성하며 그 이후 hash number를 알파벳 문자로 변경시킵니다.

counter++
const componentId = 'sc-' + hash('sc' + counter)

식별자가 생성되면, styled-components는 해당 컴포넌트가 앱에서 처음 생성된 컴포넌트이면서, element가 아직 DOM에 추가되지 않았을 경우 <head>에 <style> element를 삽입하고 componentId가 포함된 주석을 나중에 사용 할 <style> element에 추가합니다.

<style data-styled>/* sc-component-id: sc-bdVaJa */</style>

새 컴포넌트가 생성되면 해당 컴포넌트의 componentId로 미리 생성해놓은 componentId를 할당해주고 target으로 생성할 element tag name(h1, button, etc...)을 할당해 줍니다.

StyledComponent.componentId = componentId
StyledComponent.target = TargetComponent

그다음 위에서 같이 만들어본 myStyled처럼 Tagged Template Literals내 스타일을 파싱하고 componentId와 더해서 고유한 classNameMurmurHash 알고리즘을 이용해 생성합니다.

const className = hash(componentId + evaluatedStyles)

그다음 stylis CSS preprocessor를 이용해서 유효한 CSS를 얻습니다.(여기서 nesting selector를 파싱하고, auto prefix(-webkit-, -ms-, etc..)를 합니다.)

const selector = '.' + className
const cssStr = stylis(selector, evaluatedStyles)

그다음 앞서 미리 삽입해둔 <head>안 <style> element 주석 바로 다음에 CSS를 삽입합니다.

componentId도 빈 CSS 셀렉터로 삽입합니다.

<style data-styled-components>
  /* sc-component-id: sc-bdVaJa */
  .sc-bdVaJa {} .jsZVzX{font-size:24px;color:coral;}
  .jsZVzX:hover{background-color:bisque;}
</style>

마지막으로 TargetComponent의 className에 componentId와 생성한 className을 넣어준 뒤 렌더링 합니다.

const TargetComponent = this.constructor.target // h1, button, etc....
const componentId = this.constructor.componentId
const generatedClassName = this.state.generatedClassName

return (
  <TargetComponent
    {...this.props}
    className={
      this.props.className + ' ' + componentId + ' ' + generatedClassName
    }
  />
)

최종적으로 아래와 같이 렌더링 됩니다.

<button class="sc-bdVaJa jsZVzX">I'm a button</button>

Reference

comment

Comments