직접 만드는 Styled Components

금교영·2022년 5월 21일
1
post-thumbnail

React 개발자라면 styled-componentsemotion 에 익숙할 것이다. 이 두 라이브러리에서 쓰이는 문법은 일반적인 javascript 문법과는 다르다. 처음에는 jsx같은 JavaScript를 확장한 문법이라고 생각했는데 그저javascript의 Tagged templates라는 문법에 불과했다. 자 이제 구현해야 할 결과물을 살펴보자.

✅구현할 것

const MyStyledTag = myStyled.a`
  border-radius: 3px;
  padding: 0.5rem 0;
  background: black;
  color: white;

  ${props => props.primary && `
    background: white;
    color: black;
  `}
`

const Styled = () => {
  return (
      <MyStyledTag primary={false}> false일 때 </MyStyledTag>
      <MyStyledTag primary> True일 때</MyStyledTag>
  );
};

Tagged Template를 사용해 스타일이 적용된 ReactComponent를 얻을 수 있는 myStyled 함수를 만들어보자. 목표를 달성하기 위해 차근차근 문제들을 해결하자.

처음으로 해결할 문제는 Tagged Template을 Style Object로 구조화 하는 것이다.

🧥Tagged Templates

Tagged Templates는 생소하지만 string text ${expression} string text 이런 백틱을 사용하는 문자열은 익숙할 것이다. 이런 문자열을 템플릿 리터럴이라고 한다. 템플릿 리터럴은 사실 어떤 함수의 결과 값이다. 템플릿 리터럴 앞에 함수가 있다면 템플릿 리터럴은 그 함수의 인자가 된다. 이런 함수를 Tag function이라고 한다. 함수가 없다면 기본적으로 설정된 함수가 문자열을 이어주고 반환한다.

//탬플릿 리터럴 앞에 함수가 없다면 default 함수 실행 후 반환 (기본적으로 문자열을 이어주는 함수)
var a = 5;
var b = 10;
console.log("Fifteen is " + (a + b) + " and\nnot " + (2 * a + b) + ".");
// "Fifteen is 15 and
// not 20."

// 템플릿 리터럴 앞에 함수가 있다면 함수의 인자로 작용
const Component = styled.span`color : red`
// styled.span 함수의 인자로 동작했다.

두번째의 경우를 Tagged templates라고 부른다. Tagged templates으로 쓰이는 경우 함수의 첫번째 인자는 string의 배열이고 두번째 인자부터는 ${}로 된 표현식이다. 예를 들어보자.

function myTag(strings, personExp, ageExp) {
	console.log({strings,personExp,ageExp})
}

let person = 'Mike';
let age = 28

myTag`That ${ person } is a ${ age }.`;

//ageExp: 28
//personExp: "Mike"
//strings: (3) ['That ', ' is a ', '.']

앞서 설명한 대로 첫번째 인자는 표현식(${})에 의해 잘린 문자열들의 집합이고 두번째 인자부터는 순서대로 표현식이다. 이 아이디어를 기반으로 문자열들을 받아서 CSS style 객체로 만들어주면 Styled Components를 구현할 수 있다. 예를 들어 아래와 같은 값이 인자로 주어진다고 하자.

`
  border-radius: 3px;
  padding: 0.5rem 0;
  background: black;
  color: white;

  ${props => props.primary && `
    background: white;
    color: black;
  `}
`

여기서 첫번째 인자는 문자열들의 집합 두번째 인자는 함수다. 함수를 실행한 값(문자열)과 문자열들을 합치고 : 를 기준으로 문자열을 잘라 정리하면 Style Object를 만들 수 있어 보인다. Style Object를 만드는 함수는 다음과 같다.

const getConcatedTaggedTemplates = <T,>(
  template: TemplateStringsArray,
  expressions: Function[],
  props: T
) => {
  console.log(template, expressions);
  let str = "";
  template.forEach((item, index) => {
    str += item;
    if (expressions[index]) {
      str += expressions[index](props);
      // 함수를 실행시켜 값을 얻고 합쳐준다.
    }
  });

  return str;
};

const getStyledObjectFromTaggedString = (taggedString: string) => {
  return taggedString
    .trim()
    .replace(/\n/g, "") // 정규 표현식을 이용해서 개행을 없앤다.
    .split(";") // ";"를 기준으로 문자열을 자르고
    .map((c) => c.trim().split(":")) // ":"를 기준으로 key,val로 나눠서 객체를 만든다.
    .reduce((accu: Record<string, string>, [key, val]) => {
      accu[key] = val;
      return accu;
    }, {});
};

export const parseTaggedTemplates = <T,>(
  template: TemplateStringsArray,
  expressions: Function[],
  props: T
) => {
  const concatedTaggedString = getConcatedTaggedTemplates<T>(
    template,
    expressions,
    props
  );

  const styleObject = getStyledObjectFromTaggedString(concatedTaggedString);

  return styleObject;
};

이제 Tagged Templates으로 Style Object를 만들었다. 하지만 아직 해결하지 못한 것들 투성이다. 다음으로 styled 함수를 만들어보자. 함수를 만들 때 2가지가 필수적으로 정해져야 한다. 바로 매개변수(parameter)와 반환값(return 값)이다. styled함수는 매개변수로 HTMLElment태그 이름이나 컴포넌트가 필요하다.(본 글에서는 태그 이름만 구현) 그래야 styled('a') , styled('p') 가 각각 anchor 태그와 paragraph 태그를 만들 수 있다. 그렇다면 styled 함수의 반환값은 무엇일까? 다시 한번 평소 어떻게 styled-components 를 사용하는지 살펴보자.

styled('a')`font-size:24px`;
// 혹은 
styled.a`font-size:24px`;

앞서 템플릿 리터럴은 함수의 인자로 쓰인다고 설명했다. 따라서 font-size:24pxstyled('a')의 인자로 동작한다. 따라서 styled('a')는 즉 styled 함수의 반환값은 함수다. 정리하면 styled 는 HTMLElment 태그 이름을 인자로 받고 함수를 반환하는 함수다. 구현 코드를 살펴보자. ****

function styled<T extends keyof JSX.IntrinsicElements>(component: T) {
  const tagFunction = <I,>(                                             // (1)
    initialStyles: TemplateStringsArray,
    ...interpolations: ((p: JSX.IntrinsicElements[T] & I) => any)[]
  ) => {
    return function (props: JSX.IntrinsicElements[T] & I) {             // (2)
      const style = parseTaggedTemplates<JSX.IntrinsicElements[T] & I>( // (3)
        initialStyles,
        interpolations,
        props
      );

      return (
        <>
          {React.createElement(component, {
            style: style,
            ...props,
          })}
        </>
      );
    };
  };
  return tagFunction;
}
  1. 반환하는 함수는 템플릿 리터럴을 인자로 전달받는Tag Function 이다.
  2. Tag Function은 JSX Element를 반환한다.
  3. 여기서 style 객체를 만든다.

이제 직접 만든 styled 함수를 이용해 컴포넌트를 랜더링해보자.

const MyStyledTag = styled("a")<{ primary: boolean }>`
  padding: 0.5rem 0;
  background: black;
  color: white;
  ${(props) =>
    props.primary &&
    `
  background: white;
  color: black;
`}
`;

const Example= () => {
  return (
    <div
      style={{
        height: "100vh",
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
      }}
    >
      <MyStyledTag primary={false}> false일 때 </MyStyledTag>
      <MyStyledTag primary> True일 때</MyStyledTag>
    </div>
  );
};

결과 화면

아직 구현하지 않은 것이 있다. styled('a') 는 잘 동작하지만 styled.a는 동작하지 않는다. 이를 해결하기 위해서는 Proxy를 이용해야한다.

Proxy는 기본적인 동작(속성 접근, 할당, 순회, 열거, 함수 호출 등)의 새로운 행동을 정의할 때 사용한다.

  • MDN

우리가 새롭게 정의할 행동은 속성 접근이다. Proxy객체는 styled 함수의 a 프로퍼티에 접근하는 행동을 가로채 정의된 새로운 행동을 실행한다.

interface TagFunction<T extends keyof JSX.IntrinsicElements> {
  <I>(
    initialStyles: TemplateStringsArray,
    ...interpolations: ((p: JSX.IntrinsicElements[T] & I) => any)[]
  ): (props: JSX.IntrinsicElements[T] & I) => JSX.Element;
};

type HTMLStyledTag = {
  [K in keyof JSX.IntrinsicElements]: TagFunction<K>;
};

const createStyledProxy = () => {
  const componentCache = new Map<string, any>();              // (1)
  return new Proxy(styled, { 
    get: (_target, key: keyof JSX.IntrinsicElements) => {     // (2)
      if (!componentCache.has(key)) {
        componentCache.set(key, styled(key));
      } 
      return componentCache.get(key)!;
    },
  }) as HTMLStyledTag& typeof styled; 
};|

(Typescript가 숙련되지 않아서 타입이 좀 이상할 수 있습니다.)

  1. Map 객체는 Cache 역할을 한다.
  2. 프로퍼티 접근을 프록시가 가로채고 캐시에 key로 값이 존재하는지 살펴보고 있으면 그 값을 반환하고 아니면 styled 함수를 호출하고 캐시에 저장한 후 반환한다.
 const myStyled = createStyledProxy();

const MyStyledTag = myStyled.a<{ primary: boolean }>`
  padding: 0.5rem 0;
  background: black;
  color: white;
  ${(props) =>
    props.primary &&
    `
  background: white;
  color: black;
`}
`;

같은 결과를 볼 수 있다..!

Received false for a non-boolean attribute primary. 이런 오류가 나타난다. style 객체에 필요한 props를 리액트 엘리멘트를 만들 때 제거하지 않고 그대로 전달 했기 때문이다. 본 글에서 따로 처리하지 않았다.

정리

본 글을 작성하기 위해서 Styled Component 라이브러리 코드를 직접 보았다. 라이브러리는 워낙 방대해서 하나도 이해하지 못할 거라 생각했는데 쪼개고 쪼개다 보니 큰 그림이 보였다. 구현을 마치고 Typescript 실력에 참담함을 느꼈다. Typescript는 아직도 초보 수준인 것 같다. 오픈소스에 기여할 수 있을 정도의 실력을 갖추기를 바라며 계속 해서 자주 사용하는 라이브러리를 분석할 계획이다.

참고한 글

https://github.com/styled-components/styled-components/issues/1198#issuecomment-402102081

https://dev.to/dekel/tagged-template-literals-the-magic-behind-styled-components-2f2c

https://kschoi.github.io/cs/styled-component-syntax/

profile
SW Engineer를 꿈꾸는 👨‍🌾

2개의 댓글

comment-user-thumbnail
2023년 2월 12일

styled-components를 만드시게 된 계기가 무엇이었나요? 불편했던 게 있어서 해결하려고 하셨던 건지 궁금하네요. 그리고 글 일부에 styled-components에서 끝에 s가 빠진 게 있네요. 실제로 라이브러리 다운받을 때 styled-component도 있어서 고쳐주시면 좋겠어요 :)

1개의 답글