본문 바로가기

React/CSS

CSS #5 - CSS-in-JS (1) Pros and Cons

많이 사용하시는 styled-component를 소개해볼까 합니다.

시작하며

npm trends (https://www.npmtrends.com/emotion-vs-sass-loader-vs-styled-components)라는 사이트에서 SASS와 CSS-in-JS들의 다운로드 수를 비교해서 제공하는 곳이 있습니다. emotion, styled-components, JSS, aphrodite, radium, glamorous, styletron, ... 등 많은 선택지가 존재하는 CSS-in-JS는 다운로드 수가 계속 증가하는 추세에 있습니다. 각각의 사용 방식에 대해서 모두를 설명드릴 수는 없겠지만 공통적으로 담겨 있는 의도와 원리에 대해서 말씀드리고자 합니다. 많이 알려진 styled-components에 대해서 먼저 다뤄 보고, 차후에 Emotion을 정리해 보도록 하겠습니다.

CSS-in-JS

“CSS-in-JS”는 외부의 파일에 CSS를 정의하는 대신에 JavaScript와 결합하는 패턴을 의미합니다. 해당 방식은 왜 도입이 되었으며, 기존에 작성을 하는 방식인 css-loader를 통해서 자바스크립트에서 import 하는 것에 비해 어떤 장점이 있었을까요? (https://css-tricks.com/the-differing-perspectives-on-css-in-js) 해당 글에 자세히 이유를 설명하고 있으며, 댓글의 의견도 매우 중요한 포인트들을 지적하고 있으니, 시간이 허락하시면 한번 보시는 것도 좋겠습니다. 간략하게 제가 말씀을 드려보겠습니다.

 

vanilla CSS, SASS는 결국은 CSS파일을 따로 작성을 해야 하고, 해당 파일들의 묶음에 대해서 지속적인 관리를 필요로 했습니다. UI에 대해서 개발자가 스타일을 변경하고자 하면 보통은 다음과 같은 과정을 거치게 됩니다.
(1) Chrome devtool에서 변경할 스타일에 대한 컴포넌트의 id / class 등 검색의 대상을 선별

(2) IDE에서 검색해 해당 컴포넌트를 가지고 있거나 해당 컴포넌트인 파일에서 import 하고 있는 CSS파일을 확인

(3) 스타일을 적용하고 있거나 적용하고 있는 것으로 의심스러운 CSS파일을 수정

물론 각 컴포넌트를 담당하는 JSX파일에 대해서 하나의 SASS파일을 만드는 것이 일반적이며 정말 필요한 경우가 아니라면 tag를 selector의 대상으로 해서 적용하지 않을 것이기에 변경하고자 하는 대상을 쉽게 찾을 수 있고 수정할 수 있습니다. 그러나 네임스페이스 전역에 적용되는 스타일시트의 작성을 프로젝트 내부에서 특정 짓지 않아서 무분별하게 * 와 같은 선택자로 적용되거나, 스타일시트 작성과 번들링의 원리를 숙지하지 않아서 다른 영역에 스타일을 적용하도록 하거나, 퍼블리셔와 협업을 하게 되어서 스타일시트 작성을 의도한 방식(EX. JSX : SCSS = 1 : 1 / 각 내부에서만 처리하기 / ...)대로 구성하지 않는다면 차후 적용된 스타일을 변경-추가하거나 특히 삭제하는 경우 어려움을 겪을 수 있습니다.

Advantages of CSS-in-JS

이에 대해서 CSS in JS방식은 다음과 같은 장점들을 제공합니다. 

 

1. Centralization to component

디렉터리 구조, 즉 리파지토리에 대해서 style/component/...로 작성하는 것을 하지 않는 것을 통해서 스타일시트들의 유지보수에 대한 관리 포인트를 줄여줍니다. 프로젝트의 성격에 따라서 앞서 말씀드린 부분처럼 style과 src의 두 대분류로 시작할 수도 있고 src속에 src/component/register/MyComponent.tsx, src/component/register/MyComponent.scss처럼 컴포넌트에 대한 스타일시트를 같은 디렉터리에 가질 수 있습니다. 전자의 경우 적용되는 스타일시트를 찾을 때 시간이 걸리며, 후자의 경우는 프로젝트 전반에 적용되고 있는 스타일시트들의 관리가 어렵고 컴포넌트 간 공통으로 적용되는 스타일시트를 어떻게 작성하고 어떤 위치에서 관리할 것인지, 그리고 어떻게 해당 부분에만 적용을 하도록 작성할 것인지에 대한 결정이 필요합니다. 이러한 고민에 대해서 CSS-in-JS가 모든 것을 해결해주진 않지만 자바스크립트 파일에 스타일을 같이 작성하는 것을 통해서 유지보수를 더 쉽게 할 수 있습니다.

 

2. Isolation

 

<div style="font-family: Comic Sans MS, font-size: 12px">
	<button>un-inherited font-family but inherited font-size</button>
	<div>
		<a href="#">inherited font-family and font-size</a>
	</div>
</div>

 

CSS에는 명시적으로 정의하지 않은 경우 부모 요소에서 자동으로 상속되는 속성이 있고, 자동으로 상속되지 않는 속성도 있습니다. 해당 부분을 JSS는 JSS-Isolate 플러그인을 사용하는 것으로 서로 단절 관계를 만들게 됩니다. 해당 방식을 통해서 의도하지 않은 스타일과 다른 파일에서 스타일을 적용하는 것을 막을 수 있습니다. 

 

3. MurmurHash

https://ansrlm.tistory.com/21에서 소개드렸던, BEM style과 CSS Module의 ICSS를 적용하는 것으로 CSS가 하나의 네임스페이스를 공유하는 상황에서 생기는 naming over-write를 방지하는 방법을 CSS-in-JS는 murmurHash(https://en.wikipedia.org/wiki/MurmurHash)를 통해서 대신합니다. JSON으로 표현된 부분을 <style/>로 변환할 때 고유한 이름을 생성하게 됩니다. Styled-component는 다음과 같이 생성합니다. (https://github.com/styled-components/styled-components/tree/main/packages/styled-components/src/utils)

 

// generateComponentId.ts of styled-component
import generateAlphabeticName from './generateAlphabeticName';
import { hash } from './hash';

export default function generateComponentId(str: string) {
  return generateAlphabeticName(hash(str) >>> 0);
}

 

// generateAlphabeticName.ts of styled-component
const AD_REPLACER_R = /(a)(d)/gi;

/* This is the "capacity" of our alphabet i.e. 2x26 for all letters plus their capitalised
 * counterparts */
const charsLength = 52;

/* start at 75 for 'a' until 'z' (25) and then start at 65 for capitalised letters */
const getAlphabeticChar = (code: number) => String.fromCharCode(code + (code > 25 ? 39 : 97));

/* input a number, usually a hash and convert it to base-52 */
export default function generateAlphabeticName(code: number) {
  let name = '';
  let x;

  /* get a char and divide by alphabet-length */
  for (x = Math.abs(code); x > charsLength; x = (x / charsLength) | 0) {
    name = getAlphabeticChar(x % charsLength) + name;
  }

  return (getAlphabeticChar(x % charsLength) + name).replace(AD_REPLACER_R, '$1-$2');
}

 

4. Vendor prefix

vendor prefix란 주요 웹 브라우저 공급자가 새로운 실험적인 기능을 제공할 때 이전 버전의 웹 브라우저에 그 사실을 알려주기 위해 사용하는 접두사를 의미합니다. 해당 처리를 통해서, 기능이 포함되어 있지 않은 이전 버전의 웹 브라우저에서도 그 기능을 사용할 수 있게 됩니다.

 

<!-- vendor prefix -->

<style>
	.button {
		background: red;                                 <!-- gradient 속성을 지원하지 않는 브라우저 -->
		background: -webkit-linear-gradient(red, yellow);<!-- 크롬과 사파리 4.0 이상 -->
		background: -moz-linear-gradient(red, yellow);   <!-- 파이어폭스 3.6 이상 -->
		background: -ms-linear-gradient(red, yellow);    <!-- 익스플로러 10.0 이상 -->
		background: -o-linear-gradient(red, yellow);     <!-- 오페라 10.0 이상 -->
		background: linear-gradient(red, yellow);        <!-- CSS 표준 문법 -->
	}
</style>

 

각 브라우저에 대해서 기능을 지원하기 위해서, CSS나 SASS는 vendor prefix를 적용해야 하며, 해당 작업을 간편하게 하기 위해서 자바스크립트(https://projects.verou.me/prefixfree)를 사용하거나 SASS mixin을 적용하는 것이 필요했습니다.

 

@mixin generateVendorPrefix($property, $value) {
	@each $vendorPrefix in 'webkit', 'moz', 'ms', 'o' {
		#{'-' + $vendorPrefix + '-' + $property}: $value;
	}
	#{$property}: $value;
}

 

CSS-in-JS는 (https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/utils/stylis.ts)에서 light-weight CSS Preprocessor(https://github.com/thysultan/stylis.js)를 가져와서 vendor prefix를 적용합니다. 그 결과로 생성된 CSS 규칙은 이런 고민점을 해결해 줍니다.

 

// stylis.ts of styled-component
...

  const stringifyRules: Stringifier = (
    css: string,
    selector = '',
    prefix = '',
    componentId = '&'
  ) => {
    let flatCSS = css.replace(COMMENT_REGEX, '');

    // stylis has no concept of state to be passed to plugins
    // but since JS is single-threaded, we can rely on that to ensure
    // these properties stay in sync with the current stylis run
    _componentId = componentId;
    _selector = selector;
    _selectorRegexp = new RegExp(`\\${_selector}\\b`, 'g');
    _consecutiveSelfRefRegExp = new RegExp(`(\\${_selector}\\b){2,}`);

    const middlewares = plugins.slice();

    if (options.prefix || options.prefix === undefined) {
      middlewares.push(prefixer);
    }

    middlewares.push(selfReferenceReplacementPlugin, stringify);

    return serialize(
      compile(prefix || selector ? `${prefix} ${selector} { ${flatCSS} }` : flatCSS),
      middleware(middlewares)
    );
  };
  
...

 

// Middleware.js of stylis.js
...

/**
 * @param {object} element
 * @param {number} index
 * @param {object[]} children
 * @param {function} callback
 */
export function prefixer (element, index, children, callback) {
  if (!element.return)
    switch (element.type) {
      case DECLARATION: element.return = prefix(element.value, element.length)
        break
      case KEYFRAMES:
        return serialize([copy(replace(element.value, '@', '@' + WEBKIT), element, '')], callback)
      case RULESET:
        if (element.length)
          return combine(element.props, function (value) {
            switch (match(value, /(::plac\w+|:read-\w+)/)) {
              // :read-(only|write)
              case ':read-only': case ':read-write':
                return serialize([copy(replace(value, /:(read-\w+)/, ':' + MOZ + '$1'), element, '')], callback)
              // :placeholder
              case '::placeholder':
                return serialize([
                  copy(replace(value, /:(plac\w+)/, ':' + WEBKIT + 'input-$1'), element, ''),
                  copy(replace(value, /:(plac\w+)/, ':' + MOZ + '$1'), element, ''),
                  copy(replace(value, /:(plac\w+)/, MS + 'input-$1'), element, '')
                ], callback)
          }
          return ''
        })
  }
}

...

 

5. Dynamic style (styled-components)

https://ansrlm.tistory.com/25에서 소개드렸던 방식으로, classNames를 통해서 동적으로 적용하는 것을 효과적으로 작성할 수 있습니다. 다수의 클래스 명칭이 필요한 경우 classNames를 사용하는 것이 사용하지 않는 것보다는 매우 가독성이 높지만, 하나의 변수에 많은 값들이 있는 것이 가독성을 저해합니다. 또한 적용할 클래스 명칭이 변수에 의해서 바뀌는 것이 파일을 유지 보수하는 것에 대해서 어려움을 줍니다. key-value에서 value에 해당하는 것을 물론 함수로 따로 밖에서 처리를 하는 방식이 있겠지만, 하나의 value에 대해서 최소 1개의 key를 할당해야 하는 것은 변함이 없기 때문에 classNames가 길어지는 것을 피할 수 없습니다.

 

import classNames from 'classnames';

...

return (
	<div className={classNames(wrapper, { red: isRed, green: !isRed, bigFont: isBig, smallFont: !isBig })}>
    
...

 

이에 대해서 styled-components는 props에 따라 다른 스타일을 적용하는 기능을 제공합니다. 해당 문법은 TTL(Tagged Template Literals)에 의해서 수행되는데, ES6의 문법인 Template Literals (https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Template_literals)을 기반으로 동작합니다. 즉, 함수의 인자를 파싱 하여 넘겨주고 이를 이용해 동적으로 스타일을 적용합니다. 실제 컴파일되어서 적용되는 방식은 classNames의 적용 방식과 동일하게 Element가 클래스 명칭을 달리 가지게 되는 것으로 동작합니다. 그러나 코드를 작성할 때 각각의 속성이 props에 해당하게 작성할 수 있어서 가독성이 높은 코드를 작성할 수 있습니다. 또한 한 파일에 작성하게 되기에 JS와 CSS가 상수와 변수를 쉽게 공유할 수 있어서 소스가 직관성을 가질 수 있습니다.

 

import React, { useState } from 'react';
import styled, { keyframes, css } from 'styled-components';

const App = () => {
  const [isRed, setIsRed] = useState(false);

  const handleChangeColor = () => {
    setIsRed(!isRed);
  };

  return (
    <AppComponent>
      <AppHeader>
        ...
        
        <AppText isRed={isRed}>
          {isRed ? 'red' : 'blue'}
        </AppText>
        <AppButton onClick={handleChangeColor}>
          click for toggling a color
        </AppButton>
        
        ...
      </AppHeader>
    </AppComponent>
  );
};

...

const AppText = styled.p`
  color: ${(props) => (props.isRed ? 'red' : '#63dafb')};
`;

const AppButton = styled.button`
  border-color: blue;
  cursor: pointer;
`;

...

 

before & after

6. Global CSS treatment

SASS에서 !global / !default를 통해서 global style을 적용하는 것에 대해서 createGlobalStyle을 사용하는 것으로 대신할 수 있습니다.

 

import { createGlobalStyle } from 'styled-components';

export const GlobalStyle = createGlobalStyle`
  /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */

  ...
  
  h1 {
    font-size: 2em;
    margin: 0.67em 0;
  }

  ...
  
`;

Disadvantages of CSS-in-JS

가독성 및 개발 가속화를 위해서 CSS-in-JS방식이 큰 도움을 주는 것은 사실이지만, 성능상의 이슈가 있습니다.

 

1. Bigger JS total size

기존 CSS 파일들에 있었어야 하는 코드들이 자바스크립트 파일들에 있기 때문에, 번들러의 결과도 bundle.css / bundle.js가 아니라 bundle.js로 귀결된다고 예상하실 수 있습니다. 그리고 styled-component 등 모듈을 사용하기 때문에 해당 용량도 차지하게 됩니다. 자바스크립트 번들 파일의 용량이 커지게 되면 하나의 파일이 최종적으로 로드되는 것에 대한 시간이 오래 걸리고, 결국 화면을 제공하는 것에 많은 시간이 걸립니다. 또한 SASS / CSS-Modules를 사용한 경우 CSSOM생성 이후 자바스크립트 파싱이 진행되는 것과 다르게 자바스크립트 파싱을 통해서 CSSOM을 생성해야 하기 때문에 사용자에게 있어서 시간 지연이 더 커지게 됩니다.

 

2. Do not create all CSSOM at first view

첫 화면에 필요한 CSS를 CSS-in-JS는 자바스크립트 파싱을 통해서 제공합니다. 그런데 필요하지 않은, 즉 인터렉션이 일어나지 않은 부분에 대해서는 생성을 하지 않습니다. 다음과 같은 예시가 있습니다. 위 사진에 있는 AppText는 첫 화면이 파란색입니다. 그래서 다음과 같은 DOM을 구성합니다.

그리고 하단의 toggle button을 클릭하게 되면 다음과 같이 빨간색으로 변하고, class 명칭도 변하게 됩니다.

이 상태에서 중간에 #63dafb에 해당하는 gZAvRS를 추가하게 되면 어떻게 될까요?

다음과 같이 스타일을 찾을 수 있으며 후순위에 적용된 클래스에 따라서 화면을 구성하는 것을 확인할 수 있습니다.

그러나 새로고침을 해서 첫 화면을 다시 로드하고 red를 적용하기 위해 bCSGYK를 추가하면 생각과 다르게 동작합니다.

이와 같은 결과는 CSS-in-JS가 인터렉션에 의해서 CSSOM을 생성한다는 반증일 수 있습니다. CSSOM을 자바스크립트에서 생성하기 때문에 잦은 인터렉션, 혹은 빠르게 반응을 해야 하는 경우 사용자 경험이 좋지 않을 수 있습니다.

 

3. Mixed logic with style

props에 따라서 스타일을 동적으로 적용할 수 있는 장점은 다른 방향으로는 props에 대한 로직을 이해하는 것에 대해서 혼란을 줄 수 있습니다. 스타일과 합쳐지는 것으로 인해서 스타일 / this.props(https://ko.reactjs.org/docs/components-and-props.html) / 이벤트 핸들러 / ... 에 대한 명확한 기준을 세우지 않게 된다면 특히 작성이 자유로운 this.props와 스타일이 서로 겹치게 될 가능성이 있습니다.

그래서

개발 단계의 편의성이 사용자 경험과 비교 단계에 놓인다는 것 자체가 어불성설일 수도 있겠습니다. 설계 단계에서 인터렉션이 많지 않거나 성능상의 차이가 크지 않을 것으로 판단한 경우에 CSS-in-JS는 매력적이라고 생각합니다. 한번 스타일 작성 방식을 선택하게 된다면, 서로 shifting 하는 것은 매우 어렵기 때문에 심사숙고해서 결정을 해야 할 것 같다는 생각이 들었습니다.

 

※ 잘못된 부분이나 궁금한 점 댓글로 작성해 주시면 최대한 답변하겠습니다. 읽어주셔서 감사합니다.

※ CSS #5 - CSS in JS (2)에서 분량 조절의 실패로 (ㅠㅠ) inline style와의 차이점과 사용 예제, 그리고 storybook을 소개하도록 하겠습니다.

'React > CSS' 카테고리의 다른 글

CSS #6 - CSS-in-JS (2) Styled-Component with Storybook  (0) 2021.06.27
CSS #4 - SASS (3) with CSS Module  (0) 2021.05.23
CSS #3 - SASS (2) with CSS Module  (0) 2021.05.01
CSS #2 - SASS (1)  (0) 2021.04.19
CSS #1 - CSS priority  (1) 2021.04.18