React 개발자라면 styled-components
나 emotion
에 익숙할 것이다. 이 두 라이브러리에서 쓰이는 문법은 일반적인 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는 생소하지만 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:24px
는 styled('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;
}
이제 직접 만든 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가 숙련되지 않아서 타입이 좀 이상할 수 있습니다.)
Map
객체는 Cache 역할을 한다. 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 attributeprimary
. 이런 오류가 나타난다. 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
styled-components
를 만드시게 된 계기가 무엇이었나요? 불편했던 게 있어서 해결하려고 하셨던 건지 궁금하네요. 그리고 글 일부에styled-components
에서 끝에s
가 빠진 게 있네요. 실제로 라이브러리 다운받을 때styled-component
도 있어서 고쳐주시면 좋겠어요 :)