이 포스트는 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에서는 더욱 복잡하고 동적인 콘텐츠, 기능, 구조들이 추가됐다.
점점 웹의 규모가 커지고 컴포넌트화 되면서 CSS 작업에 여러가지 문제들이 발생했고, 해결법으로 CSS-in-JS가 등장한 것이다.
이제 직접 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); // 🟢 true4
5fn`${true} === "true"` === fn([' === "true"'], true); // 🔴 false6fn`${true} === "true"` === fn(['', ' === "true"'], true); // 🟢 true7
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
함수를 만들어 보자. 함수는 아래 순서로 작동할 것이다.
- 타겟 태그 or 컴포넌트를 받는다.
- Tagged templates로 css를 받는다.
- 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 함수로 스타일을 넘기면
hash${hashedStyle}
형식의 문자열을 return 한다. (참조한 stackoverflow)
이제 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:
Container
를styled()
로 감싸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 오픈 소스를 참고하자.
이 포스트에서 사용된 코드는 여기서 확인해볼 수 있다.