React CSS-in-JS 시스템 만들기

React CSS-in-JS 시스템 만들기

June 1, 2022

이 포스트는 styled-components를 리버스 엔지니어링 하면서 얻은 지식을 이용해 간단한 CSS-in-JS 시스템을 만들어보는 내용이다.

1. CSS-in-JS?

CSS-in-JS란 스타일링에 Javascript를 사용하는 CSS 방법론이다. 그렇다면 CSS만 사용해도 잘만 스타일링이 될텐데 왜 스타일링에 Javascript를 사용할까?

웹 문서의 삼위일체인 HTML, CSS, Javascript는 깔끔한 관심사 분리를 제공한다.
HTML은 웹 문서의 구조를 만들고 CSS는 UI 스타일링을 담당하고 Javascript는 동적인 기능들을 담당했다. 과거 콘텐츠의 대부분은 정적이였기 때문에 별다른 어려움 없이 스타일링 작업을 할 수 있었다.

그러나 시간이 지남에 따라 Javascript가 엄청난 속도로 발전하면서 완전히 새로운 개발 생태계(SPA)가 도래했다.

SPA EVERYWHERE

SPA에서는 더욱 복잡하고 동적인 콘텐츠, 기능, 구조들이 추가됐다.
점점 웹의 규모가 커지고 컴포넌트화 되면서 CSS 작업에 여러가지 문제들이 발생했고, 해결법으로 CSS-in-JS가 등장한 것이다.

React: CSS in JS, vjeux (November 08, 2014)

이제 직접 React CSS-in-JS 시스템을 만들어 보자.

2. Tagged templates

만들 시스템은 styled-components를 기반으로 하므로 먼저 Tagged templates에 대해 이해가 필요하다.

템플릿 리터럴(`)의 기능중 하나인 Tagged templates는 템플릿 리터럴을 파싱하여 함수의 인자로 넘길 수 있게 해준다.

1fn`some string here` === fn(['some string here']); // 🟢 true

첫번째 인자로 문자열 배열, 나머지 인자들로 삽입된 표현식을 받으며, 인자로 템플릿 리터럴을 직접 넘기는 것과 달리 표현식 값들의 타입을 유지한채 인자로 받을 수 있다는 장점이 있다.

1const aVar = 'good';
2
3fn`this is a ${aVar} day` === fn(['this is a ', ' day'], aVar); // 🟢 true
4
5fn`${true} === "true"` === fn([' === "true"'], true); // 🔴 false
6fn`${true} === "true"` === fn(['', ' === "true"'], true); // 🟢 true
7
8fn`${true} === ${false}` === fn(['', ' === ', ''], true, false); // 🟢 true

Tagged templates로 받은 인자를 다시 원래 string 형태로 변경하는 함수를 만들어 보자.

1const getStyle = (styleStringArray, ...interpolations) => {
2 const result = [styleStringArray[0]];
3
4 for (let i = 0, len = interpolations.length; i < len; i ++) {
5 result.push(interpolations[i], styleStringArray[i + 1]);
6 }
7
8 return result.join('');
9}
10
11
12const color = '#f00';
13
14getStyle`
15 display: flex;
16 color: ${color};
17` === `
18 display: flex;
19 color: ${color};
20` // 🟢 true
  • L5: 배열에 [문자열, 표현식, 문자열, 표현식...] 순서로 값을 정렬해준다.
  • L8: 정렬된 배열을 string으로 합치면 원본 템플릿 리터럴 값이 나온다.

위 코드는 styled 함수에서 사용될 예정이다.

3. styled

Tagged templates를 기반으로 styled 함수를 만들어 보자. 함수는 아래 순서로 작동할 것이다.

  1. 타겟 태그 or 컴포넌트를 받는다.
  2. Tagged templates로 css를 받는다.
  3. css style이 적용된 컴포넌트를 return 한다.

먼저 style을 적용하지 않고 컴포넌트를 return 하는 부분까지 코드를 구성해보자.

1function styled(Tag) {
2 // Tagged templates로 style을 받는 함수를 return 한다
3}
4
5const domElements = ['a', 'abbr', 'address', 'area', ...]; // 전체 태그 리스트
6
7domElements.forEach(domElement => {
8 styled[domElement] = styled(domElement); // styled.div 형식 대응
9});
  • L2: styled 함수는 Tagged templates로 style을 받는 함수를 return 해야 한다.
  • L8: HTML 태그의 경우 styled('div')가 아니라 styled.div 형식을 주로 사용하므로, 전체 태그들을 속성으로 실행할 수 있도록 추가해준다.
1function styled(Tag) {
2 return function templateFunction(styleStringArray, ...interpolations) {
3 // Tagged templates를 설명할때 만든 getStyle을 그대로 사용한다.
4 const style = getStyle(styleStringArray, ...interpolations);
5
6 // 컴포넌트를 return 한다
7 }
8}
9
10const domElements = ['a', 'abbr', 'address', 'area', ...];
11
12domElements.forEach(domElement => {
13 styled[domElement] = styled(domElement);
14});
  • L4: Tagged templates로 받은 파라미터는 getStyle 함수로 실제 style과 동일하게 변경시켜 준다.
  • L6: templateFunction 에서 컴포넌트를 return 해주면 기본적인 styled 함수는 마무리 된다.
1function styled(Tag) {
2 return function templateFunction(styleStringArray, ...interpolations) {
3 const style = getStyle(styleStringArray, ...interpolations);
4
5 return function WrappedStyledComponent({ children, ...props }) {
6 return (
7 <Tag {...props}>
8 <p>style: {style}</p>
9 {children}
10 </Tag>
11 );
12 }
13 }
14}
15
16const domElements = ['a', 'abbr', 'address', 'area', ...]; // 전체 태그 리스트
17
18domElements.forEach(domElement => {
19 styled[domElement] = styled(domElement); // styled.div 형식 대응
20});
  • L7: 컴포넌트의 props들은 타겟 컴포넌트에 그대로 넘겨준다.
  • L8: style은 적용하지는 않고 일단 화면에 표시하도록 한다.

전체 코드는 아래와 같다.

styled.jsx

1const getStyle = (styleStringArray, ...interpolations) => {
2 const result = [styleStringArray[0]];
3
4 for (let i = 0, len = interpolations.length; i < len; i++) {
5 result.push(interpolations[i], styleStringArray[i + 1]);
6 }
7
8 return result.join('');
9};
10
11function styled(Tag) {
12 return function templateFunction(styleStringArray, ...interpolations) {
13 const style = getStyle(styleStringArray, ...interpolations);
14
15 return function WrappedStyledComponent({ children, ...props }) {
16 return (
17 <Tag {...props}>
18 <p>style: {style}</p>
19 {children}
20 </Tag>
21 );
22 }
23 }
24}
25
26const domElements = ['a', 'abbr', 'address', 'area', ...]; // 전체 태그 리스트
27
28domElements.forEach(domElement => {
29 styled[domElement] = styled(domElement);
30});
31
32export default styled;

이제 실제 App에 적용하고 결과를 확인해보자.

App.jsx

1import styled from './styled';
2
3const color = '#f00';
4
5const Container = styled.div`
6 display: flex;
7 justify-content: space-between;
8 color: ${color};
9`;
10
11const World = styled.span`
12 color: #000;
13 font-weight: bold;
14`;
15
16function App() {
17 return (
18 <Container>
19 Hello,
20 <World>World!</World>
21 </Container>
22 );
23}
24
25export default App;

Demo

"Hello, World!" 텍스트와 함께 적용할 style이 잘 표시되는걸 볼 수 있다.


이제 style을 컴포넌트에 직접 적용을 해보자. 적용을 하려면 css style을 추가할 <style> Element와 style이 적용될 className이 필요하다.

style을 관리할 Sheet class를 만들어 보자.

1class Sheet {
2 constructor() {
3 document.head.appendChild(this.ele = document.createElement('style'));
4 }
5
6 // className을 생성하는 함수가 필요하다.
7}
  • L3: 생성자 내에서 head에 <style> Element를 생성해준다.
  • L3: 생성한 <style> Element는 ele 속성으로 저장한다.

className은 style에 따라 유니크하게 생성해야 하므로 hash 함수를 만들어준다.

1const hash = (s) =>
2 `hash${s.split('').reduce((num, t) => {
3 num = (num << 5) - num + t.charCodeAt(0);
4
5 return num & num;
6 }, 0)}`;

이제 hash 함수와 <style> Element를 이용해 className 생성 함수를 만들어준다.

1const hash = (s) =>
2 `hash${s.split('').reduce((num, t) => {
3 num = (num << 5) - num + t.charCodeAt(0);
4
5 return num & num;
6 }, 0)}`;
7
8class Sheet {
9 constructor() {
10 document.head.appendChild(this.ele = document.createElement('style'));
11 }
12
13 generateClassName(style) {
14 const styledClassName = hash(style);
15
16 const rule = `.${styledClassName} { ${style} }`;
17
18 if (!this.ele.innerText.includes(rule)) {
19 this.ele.appendChild(document.createTextNode(rule));
20 }
21
22 return styledClassName;
23 }
24}
  • L14: hash로 className을 생성한다.
  • L16: selector { style } 형식의 css code를 생성한다.
  • L18: 이미 style이 존재하는지 체크하고 없는 rule이라면 추가해준다.
  • L22: class명을 return한다.

전체 Sheet 코드는 다음과 같다.

sheet.js

1const hash = (s) =>
2 `hash${s.split('').reduce((num, t) => {
3 num = (num << 5) - num + t.charCodeAt(0);
4
5 return num & num;
6 }, 0)}`;
7
8class Sheet {
9 constructor() {
10 document.head.appendChild(this.ele = document.createElement('style'));
11 }
12
13 generateClassName(style) {
14 const styledClassName = hash(style);
15
16 const rule = `.${styledClassName} { ${style} }`;
17
18 !this.ele.innerText.includes(rule) && this.ele.appendChild(document.createTextNode(rule));
19
20 return styledClassName;
21 }
22}
23
24export default Sheet;

완성된 class를 styled 함수에 적용해보자.

styled.jsx

1import Sheet from './sheet';
2
3const sheet = new Sheet();
4
5// getStyle function...
6
7function styled(Tag) {
8 return function templateFunction(styleStringArray, ...interpolations) {
9 const style = getStyle(styleStringArray, ...interpolations);
10
11 const styledClassName = sheet.generateClassName(style);
12
13 return function WrappedStyledComponent({ children, ...props }) {
14 return (
15 <Tag {...props} className={styledClassName.concat(props.className ? ` ${props.className}` : '')}>
16 {children}
17 </Tag>
18 );
19 };
20 };
21}
22
23const domElements = ['a', 'abbr', 'address', 'area', ...];
24
25domElements.forEach((domElement) => {
26 styled[domElement] = styled(domElement);
27});
28
29export default styled;
  • L11: style을 이용해 className을 생성해준다.
  • L15: className을 Tag에 추가해주고, 기존에 화면에 표시하던 style은 제거한다.

className을 넘긴후 Demo를 확인해보면 style이 적용된걸 확인할 수 있다.

Demo


마지막으로 다른 컴포넌트를 extend 해서 새로운 컴포넌트를 만들어 보자.

App.jsx

1// ...
2
3const Container = styled.div`
4 display: flex;
5 justify-content: space-between;
6 color: ${color};
7`;
8
9const Container2 = styled(Container)`
10 justify-content: center;
11 color: #666;
12`;
13
14// const World...
15
16function App() {
17 return (
18 <Container2>
19 Hello,
20 <World>World!</World>
21 </Container2>
22 );
23}
24
25export default App;

Demo

  • L9: Containerstyled()로 감싸 Container2를 만들었다.
  • L10 ~ 11: 기존의 justify-content, color style을 override 했다.

Demo를 통해 justify-content, color 속성이 잘 override 된걸 확인할 수 있다.

4. 마무리

간단한 CSS-in-JS 시스템을 만들어 보았다. 포스트를 재밌게 봤다면 템플릿 리터럴로 넘긴 function에 대한 처리 부분을 직접 구현해봐도 좋을 것 같다.

1const Container = styled.div`
2 display: flex;
3 justify-content: space-between;
4 color: ${props => props.primary ? 'white' : 'palevioletred'};
5`;

포스트에는 안나온 실제 라이브러리들의 기능과 최적화에 대해 궁금하다면 공식 문서github 오픈 소스를 참고하자.

이 포스트에서 사용된 코드는 여기서 확인해볼 수 있다.

참고