* 이 글은 How styled-components works: A deep dive under the hood 를 번역하였습니다.
CSS-in-JS는 현대 프론트엔드 개발, 특히 React 커뮤니티에서 점점 더 보편화되고 있습니다. 그중에서도 styled-components는 태그 템플릿을 사용해 스타일을 정의하는 것만으로도 일반적인 React 컴포넌트를 생성할 수 있어 두드러집니다.
이 라이브러리는 CSS 모듈화와 같은 중요한 문제를 해결하고, 중첩과 같은 CSS 외의 기능도 제공하며, 이러한 모든 기능을 별도의 설정 없이 사용할 수 있습니다. 개발자는 CSS 클래스의 고유한 이름에 대해 고민할 필요가 없으며, 클래스 자체에 대해 신경 쓸 필요도 없습니다. 그렇다면 이 강력한 기능은 어떻게 가능할까요?
Magic Syntax
const Button = styled.button`
color: coral;
padding: 0.25rem 1rem;
border: solid 2px coral;
border-radius: 3px;
margin: 0.5rem;
font-size: 1rem;
`;
여기서 styled.button은 단순히 styled('button')의 단축 표현일 뿐이며, 사용 가능한 HTML 요소 목록에서 동적으로 생성된 여러 함수 중 하나입니다.
태그 템플릿에 익숙하다면 button이 단순히 함수에 불과하며 배열 파라미터 안에 간단한 문자열과 함께 호출될 수 있다는 것을 알 수 있을 것입니다. 이 코드를 풀어보겠습니다.
const Button = styled('button')([
'color: coral;' +
'padding: 0.25rem 1rem;' +
'border: solid 2px coral;' +
'border-radius: 3px;' +
'margin: 0.5rem;' +
'font-size: 1rem;'
]);
이제 styled가 단순히 컴포넌트 생성기일 뿐이라는 것을 알았으니, 그 구현이 어떻게 생겼을지 상상해볼 수 있습니다.
태그템플릿
function highlight(strings, ...values) {
return strings.reduce((result, string, i) =>
`${result}${string}<strong>${values[i] || ''}</strong>`, '');
}
const name = "Alice";
const age = 25;
// 태그 템플릿 사용
const message = highlight`Hello, my name is ${name} and I am ${age} years old.`;
console.log(message);
// 출력: "Hello, my name is <strong>Alice</strong> and I am <strong>25</strong> years old."
Reinvent styled-components
const myStyled = (TargetComponent) => ([style]) => class extends React.Component {
componentDidMount() {
this.element.setAttribute('style', style);
}
render() {
return (
<TargetComponent {...this.props} ref={element => this.element = element } />
);
}
};
const Button = myStyled('button')`
color: coral;
padding: 0.25rem 1rem;
border: solid 2px coral;
border-radius: 3px;
margin: 0.5rem;
font-size: 1rem;
`;
이 구현은 상당히 간단합니다. 팩토리는 클로저에 저장된 태그 이름을 기반으로 새 컴포넌트를 생성하고, 마운트된 후에 인라인 스타일을 설정합니다.
하지만 우리 컴포넌트의 스타일이 props에 따라 달라진다면 어떻게 될까요?
const primaryColor = 'coral';
const Button = styled('button')`
background: ${({ primary }) => primary ? 'white ' : primaryColor};
color: ${({ primary }) => primary ? primaryColor : 'white'};
padding: 0.25rem 1rem;
border: solid 2px ${primaryColor};
border-radius: 3px;
margin: 0.5rem;
`;
컴포넌트가 마운트되거나 props가 업데이트될 때 스타일의 interpolations을 평가하도록 구현을 업데이트해야 합니다.
const myStyled = (TargetComponent) => (strs, ...exprs) => class extends React.Component {
interpolateStyle() {
const style = exprs.reduce((result, expr, index) => {
const isFunc = typeof expr === 'function';
const value = isFunc ? expr(this.props) : expr;
return result + value + strs[index + 1];
}, strs[0]);
this.element.setAttribute('style', style);
}
componentDidMount() {
this.interpolateStyle();
}
componentDidUpdate() {
this.interpolateStyle();
}
render() {
return <TargetComponent {...this.props} ref={element => this.element = element } />
}
};
const primaryColor = 'coral';
const Button = myStyled('button')`
background: ${({ primary }) => primary ? primaryColor : 'white'};
color: ${({ primary }) => primary ? 'white' : primaryColor};
padding: 0.25rem 1rem;
border: solid 2px ${primaryColor};
border-radius: 3px;
margin: 0.5rem;
font-size: 1rem;
`;
여기서 가장 까다로운 부분은 스타일 문자열을 얻는 것입니다.
const style = exprs.reduce((result, expr, index) => {
const isFunc = typeof expr === 'function';
const value = isFunc ? expr(this.props) : expr;
return result + value + strs[index + 1];
}, strs[0]);
우리는 모든 문자열 조각을 표현식의 결과와 하나씩 연결합니다. 표현식이 함수인 경우 컴포넌트의 속성(properties)을 전달하여 호출됩니다.
이 간단한 팩토리의 API는 styled-components에서 제공하는 API와 유사해 보이지만, 실제 구현은 훨씬 흥미롭게 작동합니다. inline 스타일을 사용하지 않기 때문입니다.
styled-components를 가져와서 컴포넌트를 생성할 때 무슨 일이 일어나는지 좀 더 자세히 살펴보겠습니다.
styled-components Under The Hood
Import styled-components
앱에서 라이브러리를 처음 가져오면, styled 팩토리를 통해 생성된 모든 컴포넌트를 카운트하기 위한 내부 카운터 변수를 생성합니다.
call styled.tag-name factory
const Button = styled.button`
font-size: ${({ sizeValue }) => sizeValue + 'px'};
color: coral;
padding: 0.25rem 1rem;
border: solid 2px coral;
border-radius: 3px;
margin: 0.5rem;
&:hover {
background-color: bisque;
}
`;
styled-components가 새로운 컴포넌트를 생성할 때, 내부 식별자인 componentId도 생성됩니다. 식별자는 다음과 같이 계산됩니다.
counter++;
const componentId = 'sc-' + hash('sc' + counter);
앱에서 첫 번째 styled 컴포넌트의 componentId는 sc-bdVaJa입니다.
현재 styled-components는 MurmurHash 알고리즘을 사용하여 고유한 식별자를 생성한 후, 해시 번호를 알파벳 이름으로 변환합니다.
식별자가 생성되면 styled-components는 페이지의 <head>에 새로운 HTML <style> 요소를 삽입하고(첫 번째 컴포넌트이며 요소가 아직 삽입되지 않은 경우), 나중에 사용할 수 있도록 이 요소에 componentId가 포함된 특별한 주석 마커를 추가합니다.
이 경우 다음과 같은 결과를 얻습니다.
<style data-styled-components>
/* sc-component-id: sc-bdVaJa */
</style>
새 컴포넌트가 생성되면, 팩토리에 전달된 대상 컴포넌트(이 경우 'button')와 componentId가 정적 필드에 저장됩니다.
StyledComponent.componentId = componentId;
StyledComponent.target = TargetComponent;
보시다시피, 단순히 styled 컴포넌트를 생성하는 것만으로는 성능에 전혀 부담이 가지 않습니다. 수백 개의 컴포넌트를 정의하고 사용하지 않더라도, 얻는 것은 주석이 포함된 하나 이상의 <style> 요소뿐입니다.
styled 팩토리를 통해 생성된 컴포넌트에는 중요한 점이 하나 더 있습니다. 이들은 hiddenBaseStyledComponent라는 클래스에서 상속되며, 이 클래스는 여러 생명주기 메서드를 구현합니다. 이 클래스가 담당하는 기능을 살펴보겠습니다.
componentWillMount() 위의 Button 인스턴스를 생성하고 페이지에 마운트해 보겠습니다.
ReactDOM.render(
<Button sizeValue={24}>I'm a button</Button>,
document.getElementById('root')
);
BaseStyledComponent의 생명주기 메서드 componentWillMount()가 실행됩니다. 이 메서드는 몇 가지 중요한 작업을 수행합니다:
1. 태그 템플릿 평가
알고리즘은 우리가 커스텀 myStyled 팩토리에서 구현한 것과 매우 유사합니다.
<Button sizeValue={24}>I'm a button</Button>
우리는 다음과 같은 evaluatedStyles 문자열을 얻습니다.
font-size: 24px;
color: coral;
padding: 0.25rem 1rem;
border: solid 2px coral;
border-radius: 3px;
margin: 0.5rem;
&:hover {
background-color: bisque;
}
2. CSS 클래스 이름 생성
고유한 props를 가진 각 컴포넌트 인스턴스는 componentId와 evaluatedStyles 문자열을 기반으로 동일한 MurmurHash 알고리즘을 통해 생성된 자체 CSS 클래스 이름을 가집니다.
const className = hash(componentId + evaluatedStyles);
우리의 Button 인스턴스의 경우 생성된 className은 jsZVzX입니다. 이 클래스 이름은 generatedClassName으로 컴포넌트 상태에 저장됩니다.
3. CSS 전처리
여기서 초고속 stylis CSS 전처리기가 도움을 주어 유효한 CSS 문자열을 얻을 수 있도록 합니다.
const selector = '.' + className;
const cssStr = stylis(selector, evaluatedStyles);
Button 인스턴스에 대한 최종 CSS는 다음과 같습니다.
.jsZVzX {
font-size: 24px;
color: coral;
padding: 0.25rem 1rem;
border: solid 2px coral;
border-radius: 3px;
margin: 0.5rem;
}
.jsZVzX:hover{
background-color: bisque;
}
4. CSS 문자열을 페이지에 삽입
이제 CSS는 컴포넌트의 주석 마커 바로 다음에 페이지의 <head> 내 <style> 요소에 삽입됩니다.
<style data-styled-components>
/* sc-component-id: sc-bdVaJa */
.sc-bdVaJa {} .jsZVzX{font-size:24px;color:coral; ... }
.jsZVzX:hover{background-color:bisque;}
</style>
보시다시피, styled-components는 규칙이 없는 CSS 클래스 형태로 componentId(.sc-bdVaJa)도 함께 삽입합니다.
render() CSS 처리가 완료되면, styled-components는 이제 해당 className을 가진 요소를 생성하기만 하면 됩니다.
const TargetComponent = this.constructor.target; // In our case just 'button' string.
const componentId = this.constructor.componentId;
const generatedClassName = this.state.generatedClassName;
return (
<TargetComponent
{...this.props}
className={this.props.className + ' ' + componentId + ' ' + generatedClassName}
/>
);
styled-components는 3개의 클래스 이름을 가진 요소를 렌더링합니다:
- this.props.className — 부모 컴포넌트에 의해 전달된 선택적 클래스 이름.
- componentId — 컴포넌트의 고유 식별자로, 컴포넌트 인스턴스가 아닌 컴포넌트 자체를 식별합니다. 이 클래스는 실제 CSS 규칙을 가지지 않지만, 다른 컴포넌트를 참조해야 할 때 중첩 선택자에서 사용됩니다.
- generatedClassName — 각 컴포넌트 인스턴스마다 고유하며, 실제 CSS 규칙을 포함합니다.
이렇게 해서 최종 렌더링된 HTML 요소는 다음과 같습니다.
<button class="sc-bdVaJa jsZVzX">I'm a button</button>
componentWillReceiveProps() 이제 Button이 마운트된 상태에서 props를 변경해 보겠습니다.
이를 위해 Button에 대해 더 상호작용적인 예제를 만들어야 합니다.
let sizeValue = 24;
const updateButton = () => {
ReactDOM.render(
<Button sizeValue={sizeValue} onClick={updateButton}>
Font size is {sizeValue}px
</Button>,
document.getElementById('root')
);
sizeValue++;
}
updateButton();
버튼을 클릭할 때마다 componentWillReceiveProps()가 호출되며, 증가된 sizeValue prop과 함께 다음과 같은 작업을 수행합니다:
- 태그 템플릿을 평가합니다.
- 새로운 CSS 클래스 이름을 생성합니다.
- 스타일을 stylis로 전처리합니다.
- 전처리된 CSS를 페이지에 삽입합니다.
몇 번 클릭한 후 개발자 도구에서 생성된 스타일을 확인하면 다음과 같은 결과를 볼 수 있습니다.
<style data-styled-components>
/* sc-component-id: sc-bdVaJa */
.sc-bdVaJa {}
.jsZVzX{font-size:24px;color:coral; ... } .jsZVzX:hover{background-color:bisque;}
.kkRXUB{font-size:25px;color:coral; ... } .kkRXUB:hover{background-color:bisque;}
.jvOYbh{font-size:26px;color:coral; ... } .jvOYbh:hover{background-color:bisque;}
.ljDvEV{font-size:27px;color:coral; ... } .ljDvEV:hover{background-color:bisque;}
</style>
각 CSS 클래스의 유일한 차이점은 font-size 속성이며, 사용되지 않는 CSS 클래스는 제거되지 않습니다. 그 이유는 제거하는 것이 성능에 부담을 주는 반면, 유지하는 것은 성능에 영향을 주지 않기 때문입니다.
여기에는 작은 최적화가 하나 있습니다. 스타일 문자열에 보간이 없는 컴포넌트는 isStatic으로 표시되며, 이 플래그는componentWillReceiveProps()에서 동일한 스타일에 대한 불필요한 계산을 건너뛰기 위해 확인됩니다.
Performance tips
styled-components의 내부 동작 방식을 알면 성능에 더욱 집중할 수 있습니다.
버튼 예제에는 이스터 에그가 하나 있습니다. (힌트: 버튼을 200번 이상 클릭해 보세요. styled-components가 콘솔에 숨겨진 메시지를 표시할 것입니다. 정말입니다! 😉)
너무 궁금하다면, 메시지는 다음과 같습니다.
Over 200 classes were generated for component styled.button.
Consider using the attrs method, together with a style object for frequently changed styles.
Example:
const Component = styled.div.attrs({
style: ({ background }) => ({
background,
}),
})`width: 100%;`
리팩토링 후 Button은 다음과 같은 모습입니다.
const Button = styled.button.attrs({
style: ({ sizeValue }) => ({ fontSize: sizeValue + 'px' })
})`
color: coral;
padding: 0.25rem 1rem;
border: solid 2px coral;
border-radius: 3px;
margin: 0.5rem;
&:hover {
background-color: bisque;
}
`;
하지만 모든 동적 스타일에 이 기법을 사용해야 할까요? 아닙니다.
저의 개인적인 규칙은 결과 수가 불확정적인 모든 동적 스타일에는 style 속성을 사용하는 것입니다.
예를 들어, 워드 클라우드나 서버에서 다른 색상으로 로드된 태그 목록처럼 글꼴 크기를 사용자 정의할 수 있는 컴포넌트가 있다면 style 속성을 사용하는 것이 좋습니다. 그러나 기본, 기본(primary), 경고(warn) 등과 같은 다양한 버튼이 하나의 컴포넌트에 있다면, 스타일 문자열에서 조건부 보간(interpolation)을 사용하는 것이 괜찮습니다.
아래 예제에서는 개발 버전을 사용하지만, 프로덕션 번들에서는 항상 styled-components의 프로덕션 빌드를 사용하는 것이 좋습니다.
프로덕션 빌드는 더 빠르며, React와 마찬가지로 개발 경고를 많이 비활성화할 뿐만 아니라, 개발 버전이 Node.appendChild()를 사용하는 것과 달리 CSSStyleSheet.insertRule()을 사용하여 스타일을 페이지에 삽입합니다.
또한 babel-plugin-styled-components 사용을 고려하세요. 이 플러그인은 스타일을 로딩 전에 최소화하거나 전처리할 수 있습니다.
Conclusion
styled-components의 워크플로우는 매우 간단합니다.
컴포넌트가 렌더링되기 직전에 필요한 CSS를 즉석에서 생성하며, 태그 문자열을 평가하고 브라우저에서 CSS를 전처리함에도 불구하고 충분히 빠릅니다.
이 글은 styled-components의 모든 측면을 다루지는 않았지만, 주요한 부분에 초점을 맞추고자 했습니다.
< 참고자료 >
[사이트] #Midum
<기타> How styled-components works: A deep dive under the hood
'기타' 카테고리의 다른 글
똑똑!! 레포지토리님 어디쯤이신가요? (7) | 2024.10.20 |
---|---|
글또라는 퍼즐 한 조각 (7) | 2024.10.12 |
오늘보다 더 나은 내일을 살고 있나요? (2) | 2024.01.19 |
어제보다 더 나은 오늘을 살고 있어요? (2) | 2023.12.31 |
Chrome의 내부 동작 #2 (0) | 2023.12.14 |