Part11_CSS-in-JS
김용현
책 읽기

Study

11장 CSS-in-JS

11.1 CSS-in-JS란

1) CSS-in-JS와 인라인 스타일의 차이

CSS-in-JS는 CSS-in-CSS보다 더 강력한 추상화 수준을 제공한다. CSS-in-JS를 통해 자바스크립트 사용하여 스타일을 선언적이고 유지보수할 수 있는 방식으로 표현할 수 있다.

인라인스타일: HTML 요소 내부에 직접 스타일을 적용하는 방식, HTML 태그의 style 속성을 사용하여 인라인 스타일을 적용할 수 있다.

인라인 스타일의 예시와 DOM 노드 연결 방식이다.

const textStyles = {
  color: "white",
  backgroundColor: "black",
};
 
const SomeComponent = () => {
  return <p style={textStyles}>inline style!</p>;
};
<p style="color: white; background-color: black;">inline style!</p>

인라인은 DOM노드에 속성으로 스타일을 추가했다.

CSS-in-JS 예시와 DOM 노드 연결 방식이다.

import styled from "styled-components";
 
const Text = styled.div`
  color: white;
  background: black;
`;
 
// 다음처럼 사용
const Example = () => <Text>Hello CSS-in-JS</Text>;
<style>
  .hash136s21 {
    background-color: black;
    color: white;
  }
</style>
 
<p class=”hash136s21”>Hello CSS-in-JS</p>
 

CSS-in-JS는 DOM 상단에 <style>태그를 추가했다.

CSS-in-JS를 사용하면 실제로 CSS가 생성되기 때문에 미디어 쿼리, 슈도 선택자 등과 같은 CSS기능을 쉽게 사용할 수 있다. styled-components 같은 라이브러리는 SASS의 기능까지 지원한다.

CSS-in-JS의 장점은 다음과 같다.

  1. 컴포넌트로 생각할 수 있다
  2. 부모와 분리할 수 있다.
  3. 스코프를 가진다.
  4. 자동으로 벤더 프리픽스가 붙는다.
  5. 자바스크립트와 CSS 사이에 상수와 함수를 쉽게 공유할 수 있다.

2) CSS-in-JS 등장 배경

리액트 컴포넌트의 스타일리을 위해 순수 CSS만 사용할 수 있지만 라이브러리를 적용할 수도 있다. 라이브러리는 크게 두 가지로 나뉜다.

  • CSS Preprocessor
    • sass/scss
    • less
    • stylus
  • CSS-in-JS
    • styled-components
    • emotion

2014년 메타의 엔지니어 크리스토퍼 쉬도는 규모가 크고 동적인 웹 애플리케이션을 유지보수하기 위해 해결해야 할 css의 문제점을 7가지로 분류하여 설명하면서 해결책으로 CSS-in-JS 개념을 제시했다.

크리스토퍼 쉬도가 언급한 CSS의 7가지 문제점은 다음과 같다.

  • Global Namespace (글로벌 네임스페이스): 모든 스타일이 전역 공간을 공유하므로 중복되지 않는 CSS 클래스 이름을 고민해야한다.
  • Dependencies (의존성): CSS의 의존성과 자바스크립트의 의존성이 달라서 사용하지 않는 스타일이 포함되거나 꼭 필요한 스타일이 누락되는 문제가 발생한다. → 현재는 번들러의 발전으로 거의 해결
  • Dead Code Elimination (불필요한 코드 제거): 기능 추가 · 수정 · 삭제 과정에서 불필요한 CSS를 삭제하기 어렵다.
  • Minification (최소화): 클래스 이름을 최소화하기 힘들다.
  • Sharing Constants (상수 공유): 자바스크립트와 상태 값을 공유할 수 없다. → 현재는 CSS Variable이 도입되어 CSS의 공식 기능으로 제공
  • Non-deterministic Resolution (비결정적 해결): CSS 로드 순서에 따라 스타일 우선순위가 달라진다.
  • Isolation (고립): CSS의 외부 수정을 관리하기 어렵다.

이때를 기점으로 CSS-in-JS를 구현하기 위한 움직임이 있었고 그 결과 styled-components, emotion과 같은 CSS-in-JS 라이브러리가 등장하게 되었다.

3) CSS-in-JS 사용하기

import styled from "@emotion/styled";
 
// 탬플릿 리터럴을 활용, boolean으로 primary의 타입 지정
export const Button = styled.button<{ primary: boolean }>`
  background: transparent;
  border: none;
  cursor: pointer;
  font-size: inherit;
  padding: 0;
  margin: 0;
  color: ${({ primary }) => (primary ? "red" : "blue")};
`;

대부분의 CSS-in-JS 사용 방식은 유사한데 템플릿 리터럴을 활용하여 동적인 스타일을 정의한다. props 타입을 정의하고, 이 props를 활용해서 동적인 스타일링을 구현한다.

만약 variant props의 유형에 따라 다른 스타일을 적용하고 싶다면 css 함수를 사용하여 스타일을 정의하고 variant 값에 따라 맵 객체를 생성하여 사용할 수도 있다.

import { css, SerializedStyles } from "@emotion/react";
import styled from "@emotion/styled";
 
type ButtonRadius = "xs" | "s" | "m" | "l";
 
export const buttonRadiusStyleMap: Record<ButtonRadius, SerializedStyles> = {
  xs: css`
    border-radius: ${radius.extra_small};
  `,
  s: css`
    border-radius: ${radius.small};
  `,
  m: css`
    border-radius: ${radius.medium};
  `,
  l: css`
    border-radius: ${radius.large};
  `,
};
 
export const Button = styled.button<{ radius: string }>`
  ${({ radius }) => css`
    /* ...기타 스타일은 생략 */
    ${buttonRadiusStyleMap[radius]}
  `}
`;

만약 RoundButton, SquareButton 등 여러 버튼 컴포넌트를 구현해야 한다면, 공통적인 버튼 스타일을 따로 정의한 다음에 각 컴포넌트 스타일에서 이를 확장하여 구현할 수 있다.

// CommonButton 이라는 공통적인 스타일의 버튼을 RoundButton, SquareButton으로 각각 확장
const RoundButton = styled(CommonButton)`
  // 스타일링
`;
const SquareButton = styled(CommonButton)`
  // 스타일링
`;

11.2 유틸리티 함수를 활용하여 styled-components의 중복 타입 선언 피하기

컴포넌트에서 사용하는 props 뿐만 아니라 styled-components로 전달되는 스타일 관련 props의 타입도 정의해줘야한다.

보통 styled-components에 넘겨주는 타입은 props에서 받은 타입과 동일하다. 이때 TS에서 제공하는 Pick, Omit 같은 유틸리티 타입을 활용할 수 있다.

1) props 타임과 styled-components 타입의 중복 선언 및 문제점

유틸리티 타입을 사용하지 않을 경우에는 다음과 같은 불편함이 생길 수 있다.

여기에서 styled-components에 넘겨주는 타입은 props와 똑같은 타입이지만 StyledProps로 따로 정의해줘야 하는 점 때문에 코드 중복이 발생한다. 또한 props의 타입이 변경되면 StyledProps도 변경되어야 한다.

interface Props {
  height?: string;
  color?: keyof typeof colors;
  isFull?: boolean;
  className?: string;
  // ...
}
 
export const Hr: VFC<Props> = ({ height, color, isFull, className }) => {
  // ...
  return (
    <HrComponent
      height={height}
      color={color}
      isFull={isFull}
      className={className}
    />
  );
};
 
interface StyledProps {
  height?: string;
  color?: keyof typeof colors;
  isFull?: boolean;
}
 
const HrComponent = styled.hr<StyledProps>`
  height: ${({ height }) => height || "10px"};
  margin: 0;
  background-color: ${({ color }) => colors[color || "gray7"]};
  border: none;
 
  ${({ isFull }) =>
    isFull &&
    css`
      margin: 0 -15px;
    `}
`;

만약 컴포넌트가 커지고 여러 styled-components를 활용할 때는 중복되는 타입과 관리해야 하는 포인트가 늘어나게 된다. 이때 Pick, Omit 같은 유틸리티 타입을 활용할 수 있다.

const HrComponent = styled.hr<Pick<Props, "height" | "color" | "isFull">>`
  // ...
`;

이 코드는 Pick을 사용해 Props에서 height, color, isFull 세가지 속성을 가져왔다.

styled-components 뿐만 아니라 상속받는 컴포넌트나 props 등의 경우에도 Pick이나 Omit 같은 유틸리티 타입을 활용하면 중복되는 타입을 피할 수 있어 유지보수적인 측면에서 긍정적인 효과를 얻을 수 있다.