[오픈소스][MUI] MUI 컴포넌트는 어떻게 만들어질까? (2)

Gyuwon Lee·2023년 3월 9일
0

디자인 시스템

목록 보기
5/6
post-thumbnail

MUI 컴포넌트는 어떻게 만들어질까? (1) 에서 이어지는 글입니다.

사내 디자인 시스템 라이브러리 프로젝트는 여태껏 얇고 길게 이어지다가, 최근 대대적인 개편(!) 선언과 동시에 무기한 중단되었다.

개편이 선언된 워크샵에서 왜 from scratch 가 아니라 MUI를 베이스로 디자인 시스템을 만들기 시작했는지를 들을 수 있었다. 디자인 시스템을 구축하는 것은 상당히 공수가 드는 일인데, 사전 구축된 UI 라이브러리인 MUI를 활용함으로써 체계를 조직하는 데 드는 비용을 현저히 줄이고 디자인 및 구현에만 집중할 수 있었기 때문이었다.

워크샵이 끝나고, 내가 개발하며 느꼈던 장점은 무엇이었는지 생각해 봤다. 개발자 입장에서 가장 편했던 건 역시 palette 및 디자인 token 등을 하나하나 확인할 필요 없이 theme 객체에 접근해 미리 정의된 값을 사용하기만 하면 됐다는 점이다.

이렇게 여러 컴포넌트들이 공통된 값들을 공유하게 함으로써 재사용성을 높이는 구조는 꼭 디자인 시스템이 아니더라도, 잘 파악해 두면 어디든 적용해볼 수 있는 좋은 레퍼런스라는 생각이 들었다.
시간이 여유로워진 김에, 다시 MUI 소스코드를 열심히 뜯어보았다.

createStyled() 차근히 짚어보기

앞선 삽질 끝에 남은 힌트는 아래와 같았다:

  • theme, ownerState 는 콜백 함수 의 파라미터다.
    • 콜백 함수는 어떤 함수를 즉시 실행시키지 않고, 원하는 타이밍에 실행시키기 위해 다른 함수의 파라미터로 넘겨진 함수다.
    • 따라서, 이 함수가 ‘실행’ 되는 위치에서 theme 값을 알 수 있는 것은 맞다.
  • styled 뿐만 아니라 sx props, components 객체에서도 theme 에 접근할 수 있다.
    • 따라서 sx와 components 객체의 스타일링 역시 styled 안에서 resolve 되는 형태일 것으로 추측해볼 수 있다.
  • createStyled 안에서 sx 와 component 객체를 핸들링하는 로직이 있었는지 찾아보자.

이제 위의 추측대로, component 객체나 sx 에 접근하는 부분이 있는지 찾아보았다.

그나마 createStyled() 함수에 조금 익숙해져서, 다시 처음부터 짚어 보니 로직도 조금 더 잘 들어올 뿐더러 컴포넌트 객체의 styleOverrides 와 variants 를 가져오는 듯한 함수 두개를 찾을 수 있었다.

const getStyleOverrides = (name, theme) => {
  if (theme.components && theme.components[name] && theme.components[name].styleOverrides) {
    return theme.components[name].styleOverrides;
  }

  return null;
};

const getVariantStyles = (name, theme) => {
  let variants = [];
  if (theme && theme.components && theme.components[name] && theme.components[name].variants) {
    variants = theme.components[name].variants;
  }

  const variantsStyles = {};

  variants.forEach((definition) => {
    const key = propsToClassKey(definition.props);
    variantsStyles[key] = definition.style;
  });

  return variantsStyles;
};

theme 의 component 객체에 접근해, 해당 component 의 styleOverrides 또는 variants 에 값이 있는 경우 해당 값(스타일)들을 리턴해주는 함수인 듯하다.

그럼 이 함수를 호출하는 부분에서 theme 을 어떻게 넣어 주는지 찾기만 하면…!

if (componentName && overridesResolver) {
  expressionsWithDefaultTheme.push((props) => {
    const theme = isEmpty(props.theme) ? defaultTheme : props.theme;
    const styleOverrides = getStyleOverrides(componentName, theme);

    if (styleOverrides) {
      const resolvedStyleOverrides = {};
      Object.entries(styleOverrides).forEach(([slotKey, slotStyle]) => {
        resolvedStyleOverrides[slotKey] =
          typeof slotStyle === 'function' ? slotStyle({ ...props, theme }) : slotStyle;
      });
      return overridesResolver(props, resolvedStyleOverrides);
    }

    return null;
  });
}

다시 콜백 지옥으로 돌아왔다. 또 콜백이다!

expressionsWithDefaultTheme 은 보통 빈 배열 (밑에 설명해두겠다. 빈 배열로 초기화되는경우가 많을 것이라고 생각한다.) 이다. 이 빈 배열에 prop 을 받아 해당 prop 에 theme 이 있는지에 따라 변수 theme 을 정의하고, 이 theme 을 사용해 getStyleOverrides 를 호출해서 결과를 리턴하는 콜백 함수를 push 하고 있는 코드다.

그럼 이 콜백을 어디서 사용하는지 찾기만 하면…!

const Component = defaultStyledResolver(transformedStyleArg, ...expressionsWithDefaultTheme);

원점으로 돌아왔다. defaultStyledResolver 함수 내부적으로 expressionsWithDefaultTheme 에 push 되었던 각 콜백들을 사용하나본데…

위에서도 여기서도 딱 이 지점에서 막혀 이 다음 로직을 도무지 모르겠다. 죄다 콜백들이 넘어갔으니 분명 defaultStyledResolver 안에서 theme이 들어가고 있어야 맞는데, 정작 확인해 보면 styledEngineStyled 의 리턴값일 뿐이고, styledEngineStyled 는 또 @emotion/styled 의 styled 함수를 사용하고…

emotion 으로 먼 여정을

에라 모르겠다

// emotion/packages/styled/src/index.js

// @flow
import styled from './base'
import { tags } from './tags'

// bind it to avoid mutating the original function
const newStyled = styled.bind()

tags.forEach(tagName => {
  // $FlowFixMe: we can ignore this because its exposed type is defined by the CreateStyled type
  newStyled[tagName] = newStyled(tagName)
})

export default newStyled

이것이 emStyled 의 코드다. styled 에 bind 되어있으므로 바로 styled 로 들어가봤다.

// emotion/packages/styled/src/base.js

let createStyled: CreateStyled = (tag: any, options?: StyledOptions) => {
  ...
  return Styled
	...
}

export default createStyled;

이 createStyled 함수가 위의 styled 다. 위에서 본 MUI의 createStyled 와 형태가 같아 보인다. 하지만 로직은 조금 더 복잡해 보여 살짝 겁을 먹은 채로, 다시 리턴값인 Styled를 찾아 코드를 따라가 보았다.

const Styled: PrivateStyledComponent<Props> = withEmotionCache(
	(props, cache, ref) => {...}
)

이 Styled 는 또 withEmotionCache 에 콜백(ㅠㅠ)을 넘겨 리턴된 값이다.

// emotion/packages/react/src/context.js

let withEmotionCache = function withEmotionCache<Props, Ref: React.Ref<*>>(
  func: (props: Props, cache: EmotionCache, ref: Ref) => React.Node
): React.AbstractComponent<Props> {
  // $FlowFixMe
  return forwardRef((props: Props, ref: Ref) => {
    // the cache will never be null in the browser
    let cache = ((useContext(EmotionCacheContext): any): EmotionCache)

    return func(props, cache, ref)
  })
}

최종보스, withEmotionCache 는 생각보다 간단한(?) 함수였다.

func: (props: Props, cache: EmotionCache, ref: Ref) => React.Node

위처럼 생긴 콜백함수 func 을 인자로 받는다.

return forwardRef((props: Props, ref: Ref) => {
  let cache = ((useContext(EmotionCacheContext): any): EmotionCache)
  return func(props, cache, ref)
})

인자로 받은 func 이 ref 를 받아 실행될 수 있도록 React.forwardRef 로 감싸서 돌려주고 있다.

📌 TL;DR

아니 갑자기 여기서 마무리를? (아님)

잠시 맨 처음 의문이었던 “Button 을 import 하는 순간 어떤 일이 일어나는지” 로 돌아가 정리하고 넘어가자.

1. import Button

import Button from '@mui/material/Button';

return <Button ... />

먼저, 컴포넌트를 불러와 렌더링시킨다.

2. styled()

import styled, { rootShouldForwardProp } from '../styles/styled';

styled(ButtonBase, {...})(
  ({ theme, ownerState }) => ({
    ...theme.typography.button,
    minWidth: 64,
    padding: '6px 16px',
    borderRadius: (theme.vars || theme).shape.borderRadius,
		...
	})
...

대부분의 MUI 컴포넌트는 이렇게 생겼다. styled 함수를 통해 사전에 정의된 theme 객체를 사용한다. 즉 우리가 MUI 컴포넌트를 불러오는 것은 이 styled 함수를 호출하는 것이다.

import { createStyled, shouldForwardProp } from '@mui/system';
import defaultTheme from './defaultTheme';

export const rootShouldForwardProp = (prop) => shouldForwardProp(prop) && prop !== 'classes';

export const slotShouldForwardProp = shouldForwardProp;

const styled = createStyled({
  defaultTheme,
  rootShouldForwardProp,
});

styled 함수를 만들어주는 함수가 이 createStyled 함수인데, 보면 defaultTheme 을 인자로 받는 것을 알 수 있다. 우리가 넣어 준 custom theme 이나 sx props 는 어디에 있는 걸까?

3. createStyled()

export default function createStyled(input = {}) {
  ...
	// styled 함수
	// ex. styled('div')(...)
  return (tag, inputOptions = {}) => {
		...
    const defaultStyledResolver = styledEngineStyled(tag, {
      shouldForwardProp: shouldForwardPropOption,
      label,
      ...options,
    });
    const muiStyledResolver = (styleArg, ...expressions) => {
			...
      const Component = defaultStyledResolver(transformedStyleArg, ...expressionsWithDefaultTheme);
      ...
      return Component;
    };
    if (defaultStyledResolver.withConfig) {
      muiStyledResolver.withConfig = defaultStyledResolver.withConfig;
    }
    return muiStyledResolver;
  };
}

일단 createStyled 함수는 muiStyledResolver 라는 또다른 함수를 리턴한다. 즉 우리가 일반적으로

const styledBox = styled(Box)(
	({ theme })=>({...})
)

위와 같은 형태로 styled()() 처럼 사용할 수 있었던 것은 styled() 호출의 결과가 muiStyledResolver 라는 함수이기 때문이다. 우리는 이 함수에

const callback = ({ theme })=>({...})

theme 이 들어있는 객체를 인자로 받는 콜백함수를 넘겨 예쁘게 스타일링된 컴포넌트를 돌려받는 것이다.

그러니까 이 콜백함수를 실행시키는 위치를 찾아야 theme 을 어디서 불러오는지 알 수 있다.

const Component = defaultStyledResolver(transformedStyleArg, ...expressionsWithDefaultTheme)

우리가 전달한 콜백함수는 transformedStyleArg 내부에서 실행되는데, 이 transformedStyleArg 는 다시 defaultStyledResolver 안에서 실행된다. 이 함수를 알기 위해서는 @emotion 라이브러리로 넘어가야 한다…

4. emotion

새로운 목표, “defaultStyledResolver 의 첫 번째 인자가 실행될 때 어떤 인자를 받는지 알아보자”

let createStyled: CreateStyled = (tag: any, options?: StyledOptions) => {
  ...
  return Styled
	...
}

export default createStyled;

defaultStyledResolver는 @emotion 의 createStyled 의 결과다. @mui 패키지 밖으로 나와버렸다.

일단 여기까지 들어가보았을 때 theme 을 직접 주입하는 부분이 어딘지는 찾을 수 없었다. 하지만 MUI 의 구조를 조금은 더 이해할 수 있었다.

  • MUI는 기능 위주로 구현된 root, base 컴포넌트가 있고 styled 함수를 사용해 스타일을 덧씌우는 구조다.
  • 이 styled 함수는 뼈대 컴포넌트를 감싸, defaultTheme 및 인자로 받은 option 들을 사용해 컴포넌트에 적용되어야 할 콜백 함수들을 만든다.
    • getStyleOverrides, getVariantStyles 등
    • 실질적으로 컴포넌트에 스타일을 입히는 함수인 resolver 를 리턴한다.
      • 만들어진 콜백 함수들은 resolver 내부에서 사용된다.
      • 내부적인 원리는 알 수 없지만, 각 콜백은 prop 객체를 인자로 받아, 해당 객체에 theme 속성 즉 커스텀 테마가 있으면 커스텀 테마를 사용하고, 없는 경우 defaultTheme 을 사용하도록 구현되어 있다.
  • resolver 는 theme, ownerState 및 컴포넌트가 받은 props 값들에 접근해 커스텀된 실질적인 컴포넌트를 리턴한다.

약은 약사에게 style은 CSS에게

여기까지 코드를 살펴보다, 아주 당연한데 지금껏 놓치고 있었던 의문이 뒤늦게 들었다.

resolver 는 '컴포넌트' 를 리턴하는 함수인데, theme 객체를 사용해 style을 만드는 일은 다른 함수가 하지 않을까?

컴포넌트에 스타일을 입히는 방법은 크게 두 가지가 있다. 컴포넌트의 style 속성을 사용해 inline 방식으로 주입하든가, 내/외부 style sheet 를 사용하든가.

MUI 컴포넌트를 개발자 도구로 보면, 기본적으로 style 속성을 사용하지 않고 있다. 그 대신 Mui-{컴포넌트} prefix를 사용하는 클래스명으로 특정 컴포넌트의 세부 스타일에 접근해 제어할 수 있다. 이는 곧, 컴포넌트를 만드는 resolver는 받은 prop 및 option 등을 바탕으로 적절한 클래스명을 생성해 컴포넌트에 적용하고, 내가 넘긴 style 객체는 컴포넌트가 아닌 style sheet 내부로 resolve 된다는 뜻이다.

따라서 계속 컴포넌트를 리턴하는 코드 근처에서 콜백함수가 실행되는 부분을 찾으려던 시도가 부적절했던 것 같다. 컴포넌트를 만드는 resolver 말고, theme 객체를 사용해 콜백함수를 실행시켜 실질적인 style sheet 의 내용물을 만들어내는 함수를 찾아보면 처음에 알고자 했던 theme 의 정체와, 어떻게 global variable 처럼 theme 의 값들이 쓰일 수 있는지 알 수 있지 않을까...?!

profile
하루가 모여 역사가 된다

0개의 댓글