본문 바로가기

FrontEnd

Styled-Components의 비밀 파헤치기

반응형

styled-components의 내부 구현을 살펴봅니다.

원문 링크 : https://www.joshwcomeau.com/react/demystifying-styled-components/

 

Demystifying styled-components

For so many React devs, styled-components seems kinda magical. It isn't at all clear how it uses traditional CSS features under-the-hood, and that lack of clarity can cause real problems when things go awry. In this post, we'll learn exactly how styled-com

www.joshwcomeau.com

핵심 아이디어

공식 문서의 예제를 살펴봅시다.

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

styled-components는 각각 DOM 노드에 해당하는 헬퍼 메서드 컬렉션을 제공합니다.
DON 노드는 h1, 헤더, 버튼 및 기타 수십 가지가 있습니다(line 및 path와 같은 SVG 요소도 지원합니다!).
헬퍼 메서드는 "태그드 템플릿 리터럴"로 알려진 모호한 JavaScript 기능을 사용하여 CSS 덩어리로 호출됩니다.
지금은 다음과 같이 이해할 수 있습니다.
const Title = styled.h1(`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`);​

  • h1은 스타일드 컴포넌트의 헬퍼 메서드이며 단일 인수인 스트링으로 호출합니다.
  • 헬퍼 메서드는 작은 컴포넌트 팩토리입니다.
  • 호출할 때마다 완전히 새로운 React 컴포넌트를 생성합니다.
이것을 스케치해 봅시다.
// When I call this function…
function h1(styles) {
  // …it generates a brand-new React component…
  return function NewComponent(props) {
    // …which will render the associated HTML element:
    return <h1 {...props} />
  }
}

const Title = styled.h1(...)을 실행하면
NewComponent 컴포넌트가 Title 상수에 할당됩니다.
그리고 앱에서 Title 컴포넌트를 렌더링하면 <h1> DOM 노드가 생성됩니다.
h1 함수에 전달한 styles 매개변수는? 어떻게 사용되나요?
 
Title 컴포넌트를 렌더링할 때 몇 가지 일이 발생합니다.
  • 스타일을 dKamQW 또는 iOacVe와 같이 무작위로 보이는 문자열로 해싱하여 고유한 클래스 이름을 만듭니다.
  • 경량 CSS 전처리기*인 Stylis를 통해 CSS를 실행합니다.
    • less, sass와 같이 벤더 프리픽스를 처리하고, 몇가지 웹의 다루기 어려운 문제를 자동으로 해결해 줍니다.
  • 해시된 문자열을 이름으로 사용하고 스타일 문자열의 모든 CSS 선언을 포함하는 새 CSS 클래스를 페이지에 삽입합니다.
  • 반환된 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>을 렌더링하면 결과 HTML은 다음과 같이 보일 것입니다.
<style>
  .dKamQW {
    font-size: 1.5em;
    text-align: center;
    color: palevioletred;
  }
</style>
<h1 class="dKamQW">Hello World</h1>

실제 styled-components 코드베이스는 이보다 훨씬 더 복잡합니다.
우리는 많은 최적화와 개발자의 삶의 질을 개선해주는 수정을 건너뛰고 있습니다.
예를 들어  모든 렌더링에서 새 CSS 클래스가 생성될 수 있습니다.
현실 세계에서 우리는 필요할 때만 발생하도록 그 작업을 useMemo 또는 useEffect 후크 뒤에 두면 좋겠죠.

느긋한 CSS 주입

React에서는 일부 JSX를 조건부로 렌더링하는 것이 일반적입니다.
이 예에서는 ItemList 컴포넌트에 일부 prop이 제공된 경우에만 <Wrapper> 요소를 렌더링합니다.
function ItemList({ items }) {
  if (items.length === 0) {
    return "No items";
  }

  return (
    <Wrapper>
      {/* Stuff omitted */}
    </Wrapper>
  )
}

const Wrapper = styled.ul`
  background: goldenrod;
`;

styled-components가 이 경우에 우리가 제공한 CSS를 적용하지 않는다는 사실에 놀랄 것입니다.

background 선언은 DOM에 추가되지 않습니다.

 

styled-components가 정의될 ​​때마다 CSS 클래스를 열성적으로 생성하는 대신

해당 스타일을 페이지에 삽입하기 전에 컴포넌트가 렌더링될 때까지 기다립니다.

 

이것은 좋은 일입니다!

더 큰 웹사이트에서 수백 킬로바이트의 사용되지 않은 CSS가 브라우저로 전송되는 것은 드문 일이 아닙니다.

styled-components를 사용하면 작성한 CSS가 아닌 렌더링한 CSS에 대해서만 비용을 지불합니다.

(물론 기술적으로 css는 js 내부에 이미 포함되어 있긴 합니다.

이럴 경우 SSR에서 장점이 있는데, CSS가 크리티컬 렌더링 패스를 막지 않고 화면을 먼저 그릴 수 있도록 해줍니다.)

 

이것이 동작하는 이유는 JavaScript에 클로저가 있기 때문입니다.
styled.h1에서 생성된 모든 컴포넌트에는 CSS 문자열을 포함하는 자체 작은 범위가 있습니다.
Wrapper 컴포넌트를 렌더링할 때 초/분/시간이 지나도 우리가 작성한 스타일에 독점적으로 액세스할 수 있습니다.

CSS 삽입을 연기하는 또 다른 이유가 있습니다.

보간된 스타일 때문입니다.

우리는 이 기사의 끝부분에서 그것들을 다룰 것입니다.


동적으로 CSS 추가하기

createAndInjectCSSClass 함수가 어떻게 동작하는지 궁금할 것입니다.
JS를 이용해 새로운 CSS 클래스를 생성할 수 있습니까? 네! 할 수 있습니다.
한 가지 간단한 방법은 <style> 태그를 만든 다음 raw 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);

이 방법은 효과가 있지만 번거롭고 느립니다.
보다 현대적인 방법은 DOM의 CSS 버전인 CSSOM을 사용하는 것입니다.
CSSOM은 JavaScript를 사용하여 CSS 규칙을 추가하거나 제거하는 보다 친숙한 방법을 제공합니다.
const styleSheet = document.styleSheets[0];
styleSheet.insertRule(`
.dKamQW {
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
}
`);

오랫동안 CSSOM을 통해 생성된 스타일은 Chrome 개발자 도구에서 수정할 수 없었습니다.
devtools에서 "회색으로 표시된" 스타일을 본 적이 있다면 다음과 같은 제한 때문입니다.

그러나 고맙게도 이것은 Chrome 85에서 변경되었습니다.
Chrome 팀에서 styled-components와 같은 CSS-in-JS 라이브러리에 대한 지원을 devtools에 추가한 방법에 대한 블로그 게시물(blog post)을 작성했습니다.
 

함수형 헬퍼 메서드

이전에 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} />
  }
}

앞으로 더 많은 헬퍼 함수가 필요합니다!
버튼과 링크, 바닥글, 옆면과 선택 윤곽*이 필요합니다.
또 다른 문제도 있습니다.
앞으로 살펴보겠지만 styled는 직접 함수로서 호출할 수 있습니다.
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);
    createAndInjectCSSClass(uniqueClassName, styles);
    return <Tag className={uniqueClassName} {...props} />
  }
}
styled.h1 = styled('h1');
styled.button = styled('button');
// ...And so on, for all DOM nodes!

 

함수형 프로그래밍의 커링이라는 기술을 사용합니다. 이를 통해 Tag 아규먼트를 "프리 로드"할 수 있습니다.
// This:
styled.h1(`
  color: peachpuff;
`);
// …is equivalent to this:
styled('h1')(`
  color: peachpuff;
`);

변수를 렌더링한다고?

 
위의 코드에서 우리는 함수 매개변수인 태그를 가져와 마치 컴포넌트(<Tag>)인 것처럼 렌더링합니다.
이상하지 않나요? Tag 컴포넌트를 정의하지 않았습니다.
태그는 문자열을 보유하는 변수일 뿐입니다. 이렇게 하면 오류가 발생하지 않습니까?
 
React에서 우리는 Tag와 같은 PascalCase 이름이 커스텀 컴포넌트이고
버튼과 같은 소문자 이름이 HTML 엘리먼트라고 믿도록 되어 있습니다.
하지만 그렇게 간단하지 않습니다.
다음은 우리가 하고 있는 이상한 일의 간단한 예제입니다.
function App() {
  const Tag = 'button';
  return <Tag>Hello</Tag>
}
render(<App />)

JSX가 일반 JavaScript로 컴파일되는 방법을 살펴보는 것이 도움이 됩니다. 다음은 Tag 예제에서 일어나는 일입니다.
const Tag = 'button';
React.createElement(Tag, {}, "Hello");

Tag 변수이므로 다음을 가리키는 문자열로 해석됩니다.
React.createElement('button', {}, "Hello");

PascalCase 이름은 변수처럼 취급되는 반면 소문자 이름은 문자열로 취급된다고 말하는 것이 더 정확합니다.
<Button> // React.createElement(Button);
<button> // React.createElement('button');
const Tag = 'button';
<Tag> // React.createElement(Tag);
<tag> // React.createElement('tag');

 


커스텀 컴포넌트 감싸기

styled-components의 가장 멋진 점 중 하나는
우리의 커스텀 컴포넌트와 합성할 수 있다는 것입니다! 다음은 예입니다.
function Message({ children, ...delegated }) {
  return (
    <p {...delegated}>
      You've received a message: {children}
    </p>
  );
}

const UrgentMessage = styled(Message)`
  background-color: pink;
`;

render(
  <UrgentMessage>
    We're having a fire sale!
  </UrgentMessage>
);
언뜻 보기에 마법처럼 보입니다.
이러한 스타일을 커스텀 컴포넌트에 어떻게 적용합니까?
열쇠는 props 위임(deligated)에 있습니다.
deligated 객체를 로깅하면 다음과 같이 나타납니다.
function Message({ children, ...delegated }) {
  console.log(delegated);
  // { className: 'OkjqvF' }
  return (
    <p {...delegated}>
      You've received a message: {children}
    </p>
  );
}

className은 어디에서 왔습니까? styled helper 함수 입니다.
const styled = (Tag) => (styles) => {
  return function NewComponent(props) {
    const uniqueClassName = comeUpWithUniqueName(styles);
    createAndInjectCSSClass(uniqueClassName, styles);
    return <Tag className={uniqueClassName} {...props} /> // here
  }
}

 

 
작업 순서는 다음과 같습니다.
  • Message 컴포넌트를 합성하는 스타일드 컴포넌트 UrgentMessage를 렌더링합니다.
  • 고유한 클래스 이름(OkjqvF)을 만들고 className prop으로 Tag 변수(Message 구성 요소)를 렌더링합니다.
  • 메시지가 렌더링되고 className prop을 <p> 요소에 전달합니다.
 
이것은 컴포넌트 내부의 HTML 노드에 className prop을 적용하는 경우에만 동작합니다.
아래 예제는 동작하지 않습니다.
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> 요소에 위임하여 이 패턴을 잠금 해제합니다.

고맙게도 많은 서드파티 컴포넌트(예: react-router의 Link 컴포넌트)가 이 규칙을 따릅니다.


styled-components 합성하기

자체적인 컴포넌트를 래핑하는 것 외에도 styled-components를 함께 합성할 수도 있습니다.
const Button = styled.button`
  background-color: transparent;
  font-size: 2rem;
`
const PinkButton = styled(Button)`
  background-color: pink;
`;
render(
  <PinkButton>Hello World</PinkButton>
);

나는 라이브러리가 이러한 스타일을 어떻게든 "병합"하여 모든 선언을 포함하는 새로운 "메가 클래스"를 생성한다고 생각했습니다.
그러나 실제로는 두 개의 별개의 클래스를 생성합니다. 생성된 HTML/CSS를 확인하면 다음과 같이 보일 것입니다.
<style>
.abc123 {
  background-color: transparent;
  font-size: 2rem;
}
.def456 {
  background-color: pink;
}
</style>
<button class="abc123 def456">Hello World</button>

이 프로세스의 목표는 PinkButton 스타일이 Button 스타일을 "확장-extend(상속)"하도록 하는 것입니다.
충돌이 발생하면 PinkButton이 승리해야 합니다.
JavaScript에서 복잡한 작업을 수행하는 대신 CSS에 의존하여 어려운 작업을 수행할 수 있습니다!
 
CSS에는 충돌을 해결하는 방법을 제어하는 ​​복잡한 규칙 계층이 있습니다.
  • ID 선택자 #btn은 클래스 선택자 .btn을 이기게 됩니다.
    • 이는 tag 선택자인 button을 이긴다는 것을 의미합니다.

하지만 이 경우에는 두 개의 클래스가 있습니다! 두 선택자 .abc123 및 .def456은 동일하게 일치합니다.

따라서 알고리즘은 보조 규칙으로 되돌아갑니다.
 
스타일시트에 정의된 규칙의 순서를 살펴봅니다. .def456은 .abc123 다음에 정의되기 때문에 이깁니다.
우리의 버튼은 분홍색이 될 것입니다.
 
중요한 설명: 클래스를 적용하는 순서는 중요하지 않습니다
<style>
.red {
  color: red;
}
.blue {
  color: blue;
}
</style>
<p class="blue red">Hello</p>
<p class="red blue">Hello</p>

나중에 정의한 규칙이 승리한다

styled-components 라이브러리는 CSS 규칙이 올바른 순서로 삽입되어 스타일이 올바르게 적용되도록 세심한 주의를 기울입니다.
이것은 사소한 문제가 아닙니다.
우리는 React에서 모든 종류의 동적 작업을 수행하고 라이브러리는 합리적인 순서가 유지되도록 보장하면서 지속적으로 클래스를 추가/제거합니다.
 
좋습니다. styled-components 클론을 계속 작업해 봅시다.
두 클래스를 모두 적용하도록 코드를 업데이트해야 합니다. 다음과 같이 결합할 수 있습니다.
const styled = (Tag) => (styles) => {
  return function NewComponent(props) {
    const uniqueClassName = comeUpWithUniqueName(styles);
    const processedStyles = runStylesThroughStylis(styles);
    createAndInjectCSSClass(uniqueClassName, processedStyles);
    const combinedClasses =
      [uniqueClassName, props.className].join(' ');
    return <Tag {...props} className={combinedClasses} />
  }
}

PinkButton을 렌더링할 때 Tag 변수는 Button 컴포넌트와 같습니다.
PinkButton은 고유한 클래스 이름(def456)을 생성하고 이를 className 소품으로 Button에 전달합니다.
우리는 styled-components가 styled-components를 렌더링하는 재귀의 놀라운 영역에 들어왔습니다.
 
  • PinkButton을 렌더링할 때 Button도 렌더링합니다.
  • styled-components는 abc123 또는 def456과 같은 고유한 클래스를 생성합니다.
  • 모든 클래스는 놓여야 하는 DOM 노드에 적용됩니다.
  • styled-components는 이러한 규칙을 올바른 순서로 삽입하여 PinkButton의 스타일이 Button의 충돌을 덮어쓰도록 합니다.

Interpolated styles (보간된 스타일)

styled-components 클론을 거의 완료했지만 목록에 보간된 스타일이라는 작업이 하나 더 있습니다.

때때로 CSS는 React prop에 의존합니다. 예를 들어 image는 maxWidth prop을 사용할 수 있습니다.

const ContentImage = styled.img`
  display: block;
  margin-bottom: 8px;
  width: 100%;
  max-width: ${p => p.maxWidth};
`;
render(
  <>
    <ContentImage
      alt="A running shoe with pink laces and a rainbow decal"
      src="/images/shoe.png"
      maxWidth="200px"
    />
    <ContentImage
      alt="A close-up shot of the same running shoe"
      src="/images/shoe-closeup.png"
    />
  </>
)

이 이미지를 렌더링한 후의 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 running shoe with pink laces and a rainbow decal"
  src="/images/shoe.png"
  class="sc-bdnxRM JDSLg"
/>
<img
  alt="A close-up shot of the same running shoe"
  src="/images/shoe-closeup.png"
  class="sc-bdnxRM eXyedY"
/>

첫 번째 클래스인 sc-bdnxRM은 렌더링된 React 컴포넌트(ContentImage)를 고유하게 식별하는 데 사용됩니다.
스타일을 제공하지 않으며 목적에 따라 무시할 수 있습니다.
 
흥미로운 점은 각 이미지에 완전히 고유한 클래스가 부여된다는 것입니다!
 
무작위로 보이는 클래스 이름인 JDSLg 및 eXyedY는 실제로 적용될 스타일의 해시입니다.
다른 maxWidth prop으로 보간하면 다른 스타일 세트를 얻을 수 있으므로 고유한 클래스가 생성됩니다.
 
이것은 우리가 클래스를 "미리 생성(pre-generate)"할 수 없는 이유를 설명합니다!
동일한 styled.img 인스턴스가 항상 동일한 스타일을 생성하지는 않기 때문에
어떤 CSS가 적용될지 알기 전에 컴포넌트가 렌더링될 때까지 기다려야 합니다.
 
보간은 하나의 특정 컴포넌트 인스턴스의 스타일을 커스터마이징할 수 있는 유일한 방법은 아닙니다.
개인적으로 가장 좋아하는 방법은 CSS 변수를 사용하는 것입니다.
const ContentImage = styled.img`
  display: block;
  margin-bottom: 8px;
  width: 100%;
  max-width: var(--max-width);
`;

render(
  <>
    <ContentImage
      alt="A running shoe with pink laces and a rainbow decal"
      src="/images/shoe.png"
      style={{
        '--max-width': '200px',
      }}
    />
    <ContentImage
      alt="A close-up shot of the same running shoe"
      src="/images/shoe-closeup.png"
    />
  </>
)​

HTML을 검사하면 두 요소가 동일한 CSS 클래스를 공유한다는 것을 알 수 있습니다.
<style>
  .JDSLg {
    display: block;
    margin-bottom: 8px;
    width: 100%;
    max-width: var(--max-width);
  }
</style>
<img
  alt="A running shoe with pink laces and a rainbow decal"
  src="/images/shoe.png"
  class="sc-bdnxRM JDSLg"
  style="--max-width: 200px"
/>
<img
  alt="A close-up shot of the same running shoe"
  src="/images/shoe-closeup.png"
  class="sc-bdnxRM JDSLg"
/>

 

모던 CSS가 우리를 위해 동적인 일을 하게 함으로써 우리는 더 적은 CSS를 생산합니다.
이것은 잠재적인 성능 향상이기도 합니다.
동적 데이터가 변경될 때 완전히 새로운 CSS 클래스를 생성하여 페이지에 추가할 필요가 없습니다!
(즉, styled-components는 엄청나게 최적화된 라이브러리이므로 대부분의 상황에 엄청난 성능 차를 발생시키지 않습니다.)
 

기록 수정하기

앞서 내가 이 두 가지를 동등하게 생각할 수 있다고 말했던 것을 기억하십니까?
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;
`);

보간을 지원하기 시작하면 더 이상 동등하지 않습니다.
태그드 템플릿 리터럴은 설명하려면 완전히 별도의 블로그 게시물이 필요합니다.
알아야 할 중요한 점은 컴포넌트가 렌더링될 때 이러한 작은 보간 함수가 모두 호출되어
스타일 문자열의 "갭을 채우는" 데 사용된다는 것입니다. 다음은 간단한 스케치입니다.
const styled = (Tag) => (rawStyles, ...interpolations) => {
  return function NewComponent(props) {
    /*
      Compute the styles from the template string, the
      interpolation functions, and the provided React props.
    */
    const styles = reconcileStyles(
      rawStyles,
      interpolations,
      props
    )
    /* The rest is unchanged: */
    const uniqueClassName = comeUpWithUniqueName(styles);
    const processedStyles = runStylesThroughStylis(styles);
    createAndInjectCSSClass(uniqueClassName, processedStyles);
    const combinedClasses =
      [uniqueClassName, props.className].join(' ');
    return <Tag {...props} className={combinedClasses} />
  }
}​

컴포넌트를 렌더링할 때 reconcileStyles는 props를 통해 전달된 데이터로 보간된 각 함수를 호출할 수 있습니다.
결국에는 채워진 값이 있는 일반적인 문자열만 남게 됩니다.
props가 변경되면 프로세스가 반복되고 새로운 CSS 클래스가 생성됩니다.
반응형