styled-components는 어떻게 동작할까?
최근 react 커뮤니티에서는 css-in-js
(styled-components, emotion, etc)의 인기가 날이 갈수록 높아지고 있습니다.
styled-components
는 2019년 기준 작년 12월에 비해 npm 주간 다운로드 수가 약 2배(65만 => 135만) 상승했고, @emotion/core
도 작년 12월에 비해 약 7배(25만 => 174만) 상승했습니다.
하지만 styled
함수에 대한 사용법을 아시는 분들은 점차 많아지지만, 내부적으로 어떤 동작이 이루어지는지 아시는 분들은 별로 없으실 겁니다.
그래서 같이 한번 styled-components
의 styled
함수를 흉내(?) 내며 어떻게 이런 매직이 가능한지 같이 알아보도록 하겠습니다.
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)
먼저 간단하게 styled
를 구현해보면 해당 함수는 함수를 반환하는 고차 함수의 형태를 가지고 있습니다.
맨 처음 함수를 호출할 때, 생성할 html tag name을 전달해주고, 다시 두 번째 함수를 호출할 때 Tagged Template Literal
문법을 사용하여 스타일값을 전달해주면 결과적으로 functional component가 반환되며, 해당 컴포넌트가 mount 됐을 때 TargetComponent
의 inline-style로 스타일을 넣어줍니다.
하지만 이렇게 구현을 하면 styled-components
는 styled.h1
방식으로도 사용할 수 있지만, 우리가 만든 myStyled
는 myStyled('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)
모든 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)
Tagged Template Literal
의 표현식들과 문자열들을 합쳐 하나의 문자열(스타일)로 만들어주는 코드가 useEffect
안에 추가됐습니다.
표현식이 함수로 전달하는 경우 말고 변수 형태로 전달하는 경우도 있을 수 있기 때문에 먼저 해당 표현식이 함수인지 아닌지를 확인하고 함수일 경우 인자로 props를 넘겨주면서 호출하고 아닐 경우 해당 표현식을 반환합니다.
이렇게 간단하게 styled-components
를 흉내내봤는데요, 하지만 이렇게 사용하기에는 인라인 스타일을 사용하기 때문에 보안상 이슈도 있고, scss와 같이 nesting selector도 사용 못합니다.
그래서 styled-components
에서는 위 문제들을 해결하기 위해 추가적인 작업들을 해주는데요 같이 한번 알아보도록 하겠습니다.
styled-components의 내부 동작원리
앱에서 맨 처음 styled-components
를 import하면 styled-components
는 자신을 통해 생성된 모든 컴포넌트의 개수를 계산하는 내부 카운터 변수를 만듭니다.
그 이후 styled-components
로 새 컴포넌트를 만들게 되면, 내부 식별자 componentId
가 생성되고 내부 카운터의 값이 1 올라갑니다.
componentId
는 MurmurHash 알고리즘으로 생성하며 그 이후 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
와 더해서 고유한 className
을 MurmurHash 알고리즘을 이용해 생성합니다.
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>