stylex.create를 처음 봤을 때는 Emotion이나 styled-components 같은 CSS-in-JS의 변종이라고 생각했습니다. 스타일 객체를 만들어두고 렌더할 때 클래스로 바꿔주는 것인가 싶었습니다. 그러나, stylex.create는 런타임 함수가 아니라 컴파일러 매크로였고, 빌드가 끝나면 소스에 남지 않습니다.

const styles = stylex.create({
  base: {
    display: 'flex',
  },
  inline: {
    display: 'inline-flex',
  },
  alignItemsCenter: {
    alignItems: 'center',
  },
  justifyContentCenter: {
    justifyContent: 'center',
  },
});

// Dynamic styles for sizing props
const dynamicStyles = stylex.create({
  sizing: (
    width: SizeValue | null,
    height: SizeValue | null,
    maxWidth: SizeValue | null,
    minHeight: SizeValue | null,
  ) => ({
    width,
    height,
    maxWidth,
    minHeight,
  }),
});

열심히 만든 props 들 교통정리 후, merge

  // Resolve padding to per-edge values. Most specific wins, per edge:
  // edge prop -> axis prop -> `padding`.
  const resolvedPaddingInlineStart =
    paddingInlineStart ?? paddingInline ?? padding;
  const resolvedPaddingInlineEnd = paddingInlineEnd ?? paddingInline ?? padding;
  const resolvedPaddingBlockStart =
    paddingBlockStart ?? paddingBlock ?? padding;
  const resolvedPaddingBlockEnd = paddingBlockEnd ?? paddingBlock ?? padding;

  const stylexProps = mergeProps(
    themeProps('center', {axis}),
    stylex.props(
      isInline ? styles.inline : styles.base,
      (axis === 'both' || axis === 'vertical') && styles.alignItemsCenter,
      (axis === 'both' || axis === 'horizontal') && styles.justifyContentCenter,
      dynamicStyles.sizing(
        width ?? null,
        height ?? null,
        maxWidth ?? null,
        minHeight ?? null,
      ),
      resolvedPaddingInlineStart != null &&
        paddingInlineStartStyles[resolvedPaddingInlineStart],
      resolvedPaddingInlineEnd != null &&
        paddingInlineEndStyles[resolvedPaddingInlineEnd],
      resolvedPaddingBlockStart != null &&
        paddingBlockStartStyles[resolvedPaddingBlockStart],
      resolvedPaddingBlockEnd != null &&
        paddingBlockEndStyles[resolvedPaddingBlockEnd],
      xstyle,
    ),
    className,
    style,
  );

값이 정적으로 정해지는 경우

css 값이 정적 파일로 빠져나가는 수순은 다음과 같습니다.

우선 stylex로 굳이 컴파일 타임에 컴파일러가 하는 일을 두 개의 함수로 나눠 보면 이렇습니다.

// 빌드 타임에 컴파일러가 쓰는 두 가지 해시
const hashProp  = (prop) => 'k' + hash(prop);              // display     -> k1xSpc
const hashStyle = (prop, value) => 'x' + hash(prop + ':' + value); // display:flex -> x78zum5

hashProp은 값을 인자로 받지 않습니다. 이게 전부예요. display를 넣으면 무엇을 대입하든 언제나 k1xSpc가 나옵니다. style X

// 소스(ts)
const styles = stylex.create({
  base:   { display: 'flex' },
  inline: { display: 'inline-flex' },
});

// 컴파일 결과 (JS에 남는 것) - (js)
const styles = {
  base:   { k1xSpc: 'x78zum5', $$css: true },
  inline: { k1xSpc: 'x3nfvp2', $$css: true },
};

// 컴파일 결과 (CSS 파일로 빠지는 것)
// .x78zum5 { display: flex }
// .x3nfvp2 { display: inline-flex }

왼쪽 k1xSpc와 오른쪽 x78zum5가 둘 다 해시라 더 헷갈렸는데, 실은 완전히 다른 것이었습니다. 그렇다면 왜 해시를 두 개나 했을까요?

k 로 시작하는 해시 값은 프로퍼티 이름을 해싱한 값입니다. display라는 프로퍼티는 무엇을 대입하든 언제나 k1xSpc가 됩니다. 오른쪽이 실제로 DOM에 붙는 클래스명이고요.

base와 inline의 왼쪽이 똑같다는 점이 핵심이었습니다. JS 객체는 같은 키에 값을 하나만 담으니, 이 구조에서는 display가 차지할 자리가 처음부터 하나뿐입니다.

props()는 그냥 스프레드

$$css는 "이 객체는 스타일 맵이다"라는 표식이고, 실제 로직은 이 정도

function mergeProps(...styleObjects) {
  const merged = {};
  for (const obj of styleObjects.flat()) {
    if (!obj) continue;              // false, null, undefined 스킵
    Object.assign(merged, obj);      // ← 핵심: 뒤가 앞을 덮는다
  }
  delete merged.$$css;
  return { className: Object.values(merged).join(' ') };
}

props(styles.base, styles.inline);
// merged = { k1xSpc: 'x3nfvp2' }   ← x78zum5는 아예 없어짐
// { className: 'x3nfvp2' }

그래서 stylex.props(a, b, c)가 하는 일은 그냥 객체 스프레드입니다. 뒤에 오는 것이 앞의 것을 덮고, 프로퍼티당 클래스 하나만 살아남아 최종 className에 실립니다.

Emotion과 대조하면:

// Emotion: 둘 다 붙는다
<div className="css-a css-b" />
// 승자는 스타일시트 삽입 순서. import 순서 바뀌면 깨짐. 

-> 보통 여기서 머리 꺠짐 !important 넣고 난리남

// StyleX: 하나만 붙는다
<div className="x3nfvp2" />
// 승자는 인자 순서. 번들 순서와 무관.

이게 왜 중요하냐면, 충돌이 스타일시트에 닿기도 전에 끝나기 때문입니다. 임포트 순서가 바뀌면 스타일이 깨지고, 그걸 막으려고 !important가 등장하지요.

GitHub - dcastil/tailwind-merge: Merge Tailwind CSS classes without style conflicts

tailwind 도 비슷한 문제가 있어서, 이걸 정리해주는 라이브러리도 있답니다.. ㅎㅎ 그치만 이 친구는 런타임에 매 랜더마다 연산을 통해 해주는 것이고, styleX는 build 타임에 정적으로 알게 해주니 성능 면에서도 우수하다!

StyleX에서는 애초에 교통정리를 해주는 느낌입니다. Center가 소비자 override용 xstyle을 stylex.props()의 마지막 인자로 넘기는 것도 이 때문입니다. 번들 순서나 CSS 로딩 순서와 무관하게 소비자가 항상 이깁니다.

값이 런타임에 정해지는 경우

width 같은 값은 빌드 시점에 알 수 없으니 별도 처리가 필요합니다. 실행해보니 값이 300이든 400이든 100%든 클래스는 x5lhr3w 하나로 고정이었습니다. 달라지는 것은 인라인 스타일에 실리는 CSS 변수 값뿐입니다.

width: 300   →  class="x5lhr3w"  style="--x-width: 300px"
width: 400   →  class="x5lhr3w"  style="--x-width: 400px"

CSS 규칙 쪽에는 값이 아예 없습니다. .x5lhr3w { width: var(--x-width) }처럼 "변수가 뭐로 들어오든 그걸 width로 써라"라는 지시만 있고, 값은 요소에 따로 실립니다. 클래스는 값을 어디서 읽을지 가리키는 포인터고, 값 자체는 다른 채널로 오는 셈입니다.

굳이 CSS 변수를 한 겹 끼우는 이유가 있습니다. style={{width: 300}}으로 직접 넣었다면 인라인 스타일이라 명시도상 모든 클래스를 이겨버려서 소비자가 영영 덮을 수 없게 됩니다. 변수로 우회하면 선언은 클래스로 남으니 앞서 말한 머지 규칙 안에 그대로 붙잡혀 있습니다. 값은 런타임에 나르면서 override 가능성은 잃지 않는 것입니다.

정적과 동적을 가르는 기준

클래스명이 무엇마다 하나씩 생기는지 따져보니 이유가 분명했습니다.

정적 스타일에서 클래스는 프로퍼티와 값의 조합마다 하나씩 생깁니다. width: 300과 width: 400이 각각 다른 클래스를 만듭니다.

동적 스타일에서는 값이 클래스 밖으로 빠졌으니 프로퍼티당 하나로 끝납니다. 그러면 판단 기준은 그 프로퍼티가 가질 수 있는 값의 범위가 됩니다.

padding은 0부터 10까지 11단계로 닫혀 있으니 정적 맵으로 미리 만들어두는 편이 이득입니다. CSS 규칙 11개면 끝이고 앱 전체가 그걸 공유합니다. width는 픽셀이든 퍼센트든 무엇이 들어올지 알 수 없어 열려 있으니, 정적으로 나열하면 CSS가 값 가짓수만큼 무한히 자랍니다. 값 도메인이 닫혔는지 열렸는지가 두 방식을 가르는 선이었습니다