Demystifying styled-components

이재윤·2021년 6월 26일
0

front-end

목록 보기
3/3
post-thumbnail

본 글은 Demystifying styled-components 를 번역한 글입니다. 오역이 있을 수 있습니다.

처음 styled-components 를 사용했을때, 마법과도 같았습니다.

어찌 되었든, 문자열인 것 같기도 함수인 것 같기도 한 구문을 사용함으로써 이 도구는 임의의 CSS를 가져다 사용할 수도 있고, 우리가 항상 사용해 오던 CSS 선택자들을 전달함으로써 React 컴포넌트에 할당할 수도 있습니다.

많은 개발자들이 그렇듯이, 저 또한 내부동작에 대한 이해 없이 styled-components 의 사용법을 학습했습니다.

동작방식을 아는 것은 도움이 됩니다. 운전을 하기 위해서 자동차가 어떤 방식으로 작동하는지 이해할 필요는 없지만, 자동차가 도로변에 고장 난 채로 서있을 때 도움이 될 것입니다.

CSS 를 디버깅하는 것은 전용 도구를 추가하지 않고는(without adding in a layer of tooling magic) 어렵습니다. styled-components 를 이해함으로써, 낯선(weird) CSS 문제들을 진단하고 수정하는 데 힘을 덜 들일 수 있습니다.

이 글에서, 자동차의 후드를 열고 styled-components 를 직접 구현해 보며 동작 방식을 배울 것입니다.

💻 The big idea

공식문서에 나와있는 간단한 예시를 살펴봅시다.

const Title = styled.h1`
	font-size: 1.5em;
	text-align: center;
	color: palevioletred;
`

styled-components 는 각각의 DOM 노드와 일치하는 도우미 함수(helper method)의 모음과 함께 제공됩니다. DOM 노드에는, h1, header, button 및 수십개의 태그들이 있습니다. (심지어 선과 경로와 같은 SVG 요소들도 지원합니다.)

도우미 함수(helper method) 란?
반복되는 작업을 함수로 만들어 다른 함수에서 호출할 수 있는 함수

도우미 함수는 "taged template literals" 로 알려진 JavaScript 기능을 이용해, CSS 덩어리와 함께 호출됩니다. 지금까지는, 아래와 같이 작성할 수 있다고 가정할 수 있습니다.

const Title = styled.h1(`
	font-size: 1.5em;
	text-align: center;
	color: palevioletred;
`);

h1은 styled 객체의 도우미 함수이며, 하나의 문자열 인자를 받아 호출할 수 있습니다.

이 도우미 함수들은 작은 컴포넌트 공장입니다. 우리가 함수를 호출할 때마다, 새로운 React 컴포넌트를 얻을 수 있습니다.

이 부분을 확인해 봅시다.

// When I call this function...
function h1(styles) {
  // ...its generates a brand-new React component...
  return function NewComponent(props) {
    // ...which will render the associated HTML element:
    return <h1 {...props} />
  }
}

const Title=styled.h1(...) 을 실행시키면, NewComponentTitle 에 할당 될 것입니다. 그런다음 Title 을 렌더링하면 <h1> DOM 노드가 만들어 집니다.

h1 함수에 전달한 styles 인자는 어떻게 사용되는 것일 까요?

Title 컴포넌트를 렌더링하면, 몇가지 일이 발생합니다:

  1. dKamQWiOacVe 처럼, styles 을 임의의 문자열로 해싱한 유일한 클래스 이름을 만듭니다.
  2. 가벼운 CSS 전처리기인 Stylis를 이용해 CSS를 실행합니다.

    Stylis 란?
    필요에 따라 벤더 프리픽스를 추가해 주는 CSS 전처리기

  3. 해싱된 문자열을 이름으로 사용하고 styles 문자열의 모든 CSS 선언을 포함시켜 새로운 CSS 클래스를 페이지에 삽입합니다.
  4. 만들어진 클래스 이름을 반환된 HTML 요소에 적용합니다.

아래는 위 과정을 코드로 표현한 것입니다.

function h1(styles) {
  return function NewComponent(props) {
    const uniqueClassName = comeUpWithUniqueName(styles);
    const processedStyles = runStylesThroughStylis(styles);
    
    createAndInjectCSSClass(uniqueClassName, processedStyles);
    
    return <h1 className={uniqueClassName} {...props} />
  }
}

<Title>Hello World</Title> 을 렌더링하면, 결과는 아래와 같이 됩니다.

<style>
  .dKamQW {
  	font-size: 1.5em;
  	text-align: center;
  	color: palevioletred;
  }
</style>

<h1 class="dKamQW">Hello World</h1>

💻 Lazy CSS Injection

리액트에서, 조건에 따라 JSX를 렌더링 하는 것은 흔한 일입니다. 이번 예시에서는, ItemList 컴포넌트가 요소를 가지고 있을 경우에만 <Wrapper> 렌더링 할 것입니다.

이 경우, CSS와 styled-components가 아무런 일을 하지 않는 것이 놀라울 수 있습니다. 이러한 이유는 background 가 DOM에 추가되지 않았기 때문입니다.

styled-components가 정의될 때 CSS 클래스들이 생성되는 대신, 컴포넌트가 렌더링될 때까지 기다린 후 해당 스타일을 페이지에 삽입합니다.

큰 규모의 웹사이트에서, 사용하지 않는 수백 킬로바이트의 CSS를 브라우저에 전송하는 것은 보편적이지 않습니다. styled-components를 사용하면, 작성한 CSS가 아닌 렌더링된 CSS에만 집중할 수 있습니다.

이것이 가능한 이유는, JavaScript의 클로저 때문입니다. styled.h1으로 부터 생성된 모든 컴포넌트는 CSS 문자열을 가진 그들만의 스코프가 있습니다. 수 초/분/시간 후에 Wrapper 컴포넌트를 렌더링할 때, 우리가 작성한 스타일들에 접근할 수 있습니다.

CSS 삽입을 미룬 또 한 가지 이유는 스타일의 보강(interpolated styles) 때문입니다. 이 내용은 이 글 끝에 살펴보겠습니다.

💻 Dynamically adding CSS rules

createAndInjectCSSClass 함수가 어떤 방식으로 동작하는지 궁금했을 수도 있습니다. 정말 CSS 클래스들을 JS 안에서 만들 수 있을까요?

네 가능합니다! <style> 태그를 만들고 그 안에 CSS 문자를 넣는 직관적인 방법이 있습니다.

const styleTag = document.createElement('style');

document.head.appendChild(styleTag);

const newRule = document.createTextNode(`
.dKamQw {
	font-size: 1.5em;
	text-align: center;
	color: palevioletred;
}
`);

styleTag.appendChild(newRule);

이 방법은 동작하지만 무겁고 느립니다. 좀 더 현대적인 방법은 CSS의 DOM 버전인 CSSOM을 사용하는 것입니다. CCSOM은 JavaScript를 사용하여 CSS 규칙들을 추가하고 제거하는 친숙한 방법들을 제공합니다.

const styleSheet = document.styleSheets[0];

styleSheet.insertRule(`
.dKamQW {
	font-size: 1.5em;
	text-align: center;
	color: palevioletred;
}
`);

오랫동안 CSSOM으로 생성된 스타일들은 Chrome 개발자 도구를 이용해 편집할 수 없었습니다. 개발자 도구에서 회색으로 칠해져있는 부분을 본 적이 있다면, 편집 제한 때문입니다.

그러나 다행이도, Chrome 85부터 바꼈습니다. 관심이 있다면, Chrome 팀이 어떻게 CSS-in-JS 라이브러리에 대한 지원을 개발 도구에 추가했는지에 대한 블로그 게시물을 읽어보세요.

💻 Generating helpers with functional programming

초반에 styled.h1을 따라하기 위해 h1 함수를 만들었습니다.

function h1(styles) {
  return function NewComponent(props) {
    const uniqueClassName = comeUpWithUniqueName(styles);
    const processedStyles = runStylesThroughStylis(styles);
    
    createAndInjectCSSClass(uniqueClassName, processedStyles);
    
    return <h1 className={uniqueClassName} {...props} />
  }
}

이 방법은 동작하지만, 많은 도우미 함수가 필요합니다! 우리는 button, link, footer, marquee 태그가 필요합니다.

또 다른 문제도 있습니다. 아래에서 보겠지만, styled은 함수로 직접 호출될 수 있습니다.

const AlternativeSyntax = styled('h1')`
	font-size: 1.5em;
`

styled 객체는 함수이며 객체입니다. 놀랍게도 JavaScript에서는 가능한 일입니다. 아래와 같이 작성할 수 있습니다.

function magic() {
  console.log('🌠');
}

magic.hands = function() {
  console.log('🔭');
}

magic(); // logs '🌠'
magic.hands(); // logs '🔭'

styled-components 클론을 새로운 요구 사항을 만족할 수 있도록 업데이트해봅시다. 이 기능을 구현하기 위해서는 함수형 프로그래밍의 아이디어를 빌려와야 합니다.

const styled = (Tag) => (styles) => {
  return function NewComponent(props) {
      const uniqueClassName = comeUpWithUniqueName(styles);
      const processedStyles = runStylesThroughStylis(styles);
      
      createAndInjectCSSClass(uniqueClassName, processedStyles);
      
      return <Tag className={uniqueClassName} {...props} />
    }
}

styled.h1 = styled('h1');
styled.button = styled('button');
// ...And so on, for all DOM nodes!

위 코드는 커링(currying)을 사용했습니다. 이를 통해 태그 인자들을 미리 로드(preload)를 할 수 있습니다.

한 번도 커링을 본 적이 없다면, 당황스러울 수 있습니다. 하지만 이 경우, 많은 도우미 함수들을 쉽게 만들 수 있어 유용합니다.

// This:
styled.h1(`
	color: peachpuff;
`);

// ...is equivalent to this:
styled('h1')(`
	color: peachpuff;
`);

💻 Wrapping custom components

styled-components의 훌륭한 기능중 하나는 사용자 지정 컴포넌트와 합칠 수 있다는 것 입니다.

예시는 아래와 같습니다.

언뜻보기에 마법 같습니다. 어떻게 사용자 지정 컴포넌트에 스타일을 지정할 수 있는 걸까요?

핵심은 prop 전파(prop delegation) 입니다. 아래는 delegated 객체를 출력한 예시 입니다.

function Message({children, ...delegated}) {
  console.log(delegated);
  // {className: 'OkjqvF'}
  
  return (
    <p {...delegated}>
    	You've received a message: {children}
    </p>
  )
}

className 은 어디서 온 것일까요? 사실 styled 도우미 함수에서 만들었습니다.

const styled = (Tag) => (styles) => {
  return function NewComponent(props) {
    const uniqueClassName = comeUpWithUniqueName(styles);
    const processedStyles = runStylesThroughStylis(styles);
      
    createAndInjectCSSClass(uniqueClassName, processedStyles);
    
    return <Tag className={uniqueClassName} {...props} />
  }
}

순서는 다음과 같습니다:

  1. Message 컴포넌트를 구성하는 styled component인 UrgentMessage 를 렌더링합니다.
  2. 유일한 클래스 명(OkjqvF)를 생성하고, Tag 변수(Message 컴포넌트)를 className prop와 함께 렌더링 합니다.
  3. className prop이 <p> 태그에 전달되며, Message가 렌더링 됩니다.

이 방법은 className prop을 컴포넌트 안에 있는 HTML 노드에 전달할 때만 동작합니다. 아래는 동작하지 않습니다.

function Message({children}) {
  /*
    Because we're ignoring the `className` prop,
    the styles will never be set.
  */
  return (
    <p>
    	You've received a Message: {children}
    </p>
  )
}

Message가 전달받은 props들을 <p>태그가 전달받음으로써 이러한 방식을 사용할 수 있습니다. 다행히 많은 third-party 컴포넌트들이 (eg.react-router Link 컴포넌트) 이 방식을 따르고 있습니다.

💻 Composing styled-components

사용자 지정 컴포넌트들을 감싸는 것 외에, styled-component들을 합칠 수도 있습니다.

저는 처음에 라이브러리가 이 스타일들을 합쳐, 모든 선언들을 포함하는 새로운 "mega class"를 만드는 것으로 생각했습니다. 하지만 사실 두개의 다른 클래스들을 만듭니다.

생성된 HTML/CSS를 살펴보면 다음과 같습니다.

<style>
  .abc123 {
  	background-color: transparent;
  	font-size: 2rem;
  }
  .def456 {
  	background-color: pink;
  }
</style>

<button class="abc123 def456">Hello World</button>

이 과정의 목표는 PinkButtonButton의 styles을 상속받는지 확인하는 것입니다. 충돌이 발생하면, PinkButton이 우선순위가 높습니다.

JavaScript에서 복잡한 작업을 하는 것보다, CSS를 통해 어려운 작업을 진행할 수 있습니다. CSS에는, 충돌을 해소하는 복잡한 우선순위 규칙이 있습니다. Id 선택자인 #btn은 class 선택자인 .btn보다 우선순위가 높습니다. class 선택자는 tag 선택자인 button 보다 우선순위가 높습니다.

하지만 이 경우, 우리는 두개의 클래스들을 가지고 있습니다! 두개의 선택자인 .abc123.def456은 동일한 우선순위를 가지고 있습니다. 따라서 알고리즘은 stylesheet에 선언된 순서를 살펴보는 두번째 규칙을 적용합니다.

.def456.abc123보다 나중에 선언되었기 때문에 우선순위가 높습니다. 따라서 버튼이 핑크색일 것입니다.

중요한 설명: 클래스의 적용순서는 중요하지 않습니다. 아래의 경우를 살펴봅시다:

<style>
  .red {
  	color: red;
  }
  .blue {
  	color: blue;
  }
</style>

<p class="blue red">Hello</p>
<p class="red blue">Hello</p>

첫 번째 p 태그는 빨간색, 두 번째 p 태그는 파란색일 것으로 예상했을 것입니다. 하지만 이는 틀린예상 입니다: 두 p태그는 모두 파란색입니다. class 속성의 클래스들 순선는 중요하지 않습니다.

styled-components 라이브러리는 스타일이 올바르게 적용될 수 있게 하기 위해서 올바른 순서로 삽입될 수 있도록 세심한 주의를 기울입니다. 이는 쉬운 문제가 아닙니다. 우리는 React에서 많은 동적인 작업을 하며 라이브러리는 순서를 유지하면서 클래스들을 계속 추가/제거합니다.

이제 styled-components 클론을 작업해 봅시다.

두 클래스들을 적용하기 위해 코드를 업데이트해야 합니다. 아래와 같이 합칠 수 있습니다.

const styled = (Tag) => (styles) => {
  return function NewComponent(props) {
    const uniqueClassName = comeUpWithUniqueName(styles);
    const processedStyles = runStylesThroughStylis(styles);
    
    createAndInjectCSSClass(uniqueClassName, processedStyles);
    
    const cobinedClassess = 
          [uniqueClassName, props.className].join(' ');
    
    return <Tag {...props} className={combinedClasses} />
  }
}

PinkButton을 렌더링할 때, Tag 변수는 Button입니다. PinkButton는 유일한 클래스 이름(def456)을 생성하고, 이것을 className prop로 Button에 전달합니다.

이 과정을 수행하는 JavaScript 메커니즘은 중요하지 않습니다. 다음은 구현 세부 사항입니다. 중요한 것은 이 사항들을 이해하는 것입니다.

  1. PinkButton을 렌더링할때, Button도 렌더링 됩니다.
  2. 각각의 styled component는 abc123이나 def456와 같은 유일한 클래스를 만듭니다.
  3. 모든 클래스들이 모든 DOM에 적용이 됩니다.
  4. styled-components는 규칙들이 올바른 순서로 삽입해 PinkButton의 스타일이 Button과 겹치는 부분은 덮어쓸 수 있게 합니다.

💻 Interpolated styles

최소 기능의 styled-components 클론을 거의 마쳤으나, interpolated styles가 무엇인지 살펴보는 것이 남았습니다.

가끔 CSS는 React의 props에 의존할 때가 있습니다. 예를 들어 이미지가 maxWidth prop을 가지는 상황을 예로 들어 봅시다.

아래는 이 이미지들이 렌더링된 다음의 DOM의 모습입니다.

<style>
  .JDSLg {
  	display: block;
  	margin-bottom: 8px;
  	width: 100%;
  	max-width: 200px;
  }
  
  .eXyedY {
  	display: block;
  	margin-bottom: 8px;
  	width: 100%;
  }
</style>

<img
     alt="A Dummy Image"
     src="https://via.placeholder.com/350"
     class="sc-bdnxRM JDSLg"
/>
<img
     alt="A Dummy Image"
     src="https://via.placeholder.com/350"
     class="sc-bdnxRM eXyedY"
/>

첫 번째 클래스인 sc-bdnxRM은 렌더링 된 React 컴포넌트(ContentImage)를 구별하기 위해 사용됐습니다. 이 클래스는 어떤 스타일도 제공하지 않기 때문에, 이 글에서는 무시하도록 하겠습니다.

흥미로운 사실은 각각의 이미지들이 완전히 다른 클래스를 가지고 있다는 점입니다.

무작위로 만들어진 것 같은 JDSLgeXyedY는 각각의 적용된 스타일의 실제 해쉬값 입니다. 우리가 다른 maxWidth prop을 추가한다면, 다른 스타일들이 적용될 것이고 그로 인해 다른 클래스가 생성됩니다.

이것이 클래스를 미리 생성하지 않는 이유입니다! 컴포넌트가 렌더링 될 때까지 기다려야 CSS가 적용됩니다. 같은 styled.img 인스턴스라 하더라도 항상 같은 스타일들을 만드는 것은 아니기 때문입니다.

Interpolation이 컴포넌트를 커스텀할 수 있는 유일한 방법은 아닙니다. 개인적으로 좋아하는 방법은 CSS 변수를 사용하는 것입니다. 예시는 아래와 같습니다.

const ContentImage = styled.img`
	display: block;
	margin-bottom: 8px;
	width: 100%;
	max-width: var(--max-width);
`

render(
  <>
  	<ContentImage
  		alt="A Dummy Image"
  		src="https://via.placeholder.com/350"
  		style={{
  			'--max-width': '200px'
  		}}
  	/>
	<ContentImage
		alt="A Dummy Image"
		src="https://via.placeholder.com/350"
	/>
  </>
)

HTML을 검사해보면 두 요소가 동일한 CSS 클래스를 가지고 있음을 확인할 수 있습니다.

<style>
  .JDSLg{
  	display: block;
  	margin-bottom: 8px;
  	width: 100%;
  	max-width: var(--max-width);
  }
</style>

<img
     alt="A Dummy Image"
     src="https://via.placeholder.com/350"
     class="sc-bdnxRM JDSLg"
     style="--max-width: 200px"
/>
<img
     alt="A Dummy Image"
     src="https://via.placeholder.com/350"
     class="sc-bdnxRM JSDLg"
/>

최신 CSS에 보다 다양한 기능들이 추가됨으로써, 짧은 CSS를 작성할 수 있습니다. 동적으로 데이터를 바꿀때, 새로운 CSS 클래스를 만들어 페이지에 붙일 필요가 없어 성능적인 이점이 있습니다.

하지만 styled-components는 최적화가 매우 잘돼있는 라이브러리입니다. 따라서 대부분의 상황에서 괄목할만한 큰 차이는 없을것 입니다.

💻 Correction the record

글 윗부분에서 아래의 두 코드가 동일하다고 이야기한 것을 상기해 보십시오.

const Title = styled.h1`
	font-size: 1.5em;
	text-align: center;
	color: palevioletred;
`

const Title = styled.h1(`
	font-size: 1.5em;
	text-align: center;
	color: palevioletred;
`)

inperpolation을 알고 난 후부터, 둘은 더 이상 같다고 할 수 없습니다.

Tagged template literal은 복잡해서(wild), 어떤 방식으로 동작하며 어떤 역할을 수행하는지를 설명하려면 독자적인 주제로 글을 써야합니다.

중요한 것은 컴포넌트가 렌더링 될 때, interpolation 함수들이 호출되고 스타일 문자를 채우기 위해 사용된다는 것입니다.

아래는 예시입니다.

const styled = (Tag) => (rawStyles, ...interpolations) => {
	return function NewComponent(props) {
    	const styles = reconcilStyled(
          rawStyles,
          interpolations,
          props
        );
        
        const uniqueClassName = comeUpWithUniqueName(styles);
      	const processedStyles = runStyledThroughStylis(styles);
      	createAndInjectCSSClass(uniqueClassName, processedStyles);
      	const combinedClasses = [uniqueClassName, props.className].join(' ');
      return <Tag {...props} className={combinedClasses} />
    }
}

컴포넌트를 렌더링할 때, reconcileStyles은 props를 통해 전달받은 데이터를 이용해 interpolated 함수를 실행합니다.

props가 바뀌면, 위 과정은 반복되고 새로운 클래스가 만들어 집니다.

0개의 댓글