🐻

Kuma UI が提唱する Hybrid Approach CSS-in-JS の仕組み

2023/12/14に公開

和製CSS-in-JSライブラリの Kuma UI はHybrid Approachという新たな手法によって、優れたパフォーマンスを実現しています。この手法の仕組みから、「なぜKuma UIが速いのか」を紹介します。

Hybrid Approach とは

Hybrid Approach とは一言で言うと、EmotionやChakra UIのような従来型のランタイムCSS-in-JSの書き味を完全に保ちながら、できる限りゼロランタイムに変換する手法です。

具体例を見てみましょう。Kuma UI では、クリックすると文字色が変わるボタンを以下のようにChakra UIっぽく書くことができます。

import { Box } from "@kuma-ui/core";
export default function App() {
  const [colored, setColored] = useState(false);

  return (
    <Box
      as="button"
      border="solid 1px black"
      padding="4px 8px"
      color={colored ? "blue" : "black"}
      onClick={() => setColored(!colored)}
    >
      Change Color
    </Box>
  );
}

このコンポーネントが持っているスタイルのうち、borderpaddingはゼロランタイムになり、colorはランタイムで処理されます。これにより、開発者体験を維持しながらもパフォーマンスを最大化することができます。

詳細はKuma UIの作者であるpoteboyさんの記事を参照してください。

https://zenn.dev/poteboy/articles/d94573793d56ed#hybrid-css-in-js-とは何か

Hybrid Approach の仕組み

それでは、上述のコンポーネントに対する変換の過程を追いかけながら、Hybrid Approachの仕組みを見てみましょう。

ビルド時に静的解析でスタイルを抽出する

まずはビルド時に ts-morph を用いてソースコードのASTを走査して、静的に定まるスタイルを抽出します[1]。今回の場合は、borderpadding が対象になり、以下のCSSを抽出することができます。

.🐻-1437279831 {
  border: solid 1px black;
  padding: 4px 8px;
}

「静的に定まるスタイル」とは、ブラウザで実行しなくても値がわかるスタイルということです。現在の Kuma UI では、プロパティの値が文字列や数値のようなリテラル、あるいは単純な数値計算であれば、静的に定まるスタイルとして抽出することができます。

// OK → `color: red;`
<Box color="red" />;

// OK → `width: 8px;`
<Box width={12 - 4} />;

// NG: 値がランタイムの変数によって動的に変わる
<Box color={isChecked ? "red" : "blue"}  />;

抽出されたスタイルのプロパティは削除し、代わりに抽出したクラスを参照するようにします。抽出できなかった動的なプロパティは残ります。

 <Box
   as="button"
-  border="solid 1px black"
-  padding="4px 8px"
+  className="🐻-1437279831"
   color={colored ? "blue" : "black"}
   onClick={() => setColored(!colored)}
 >
   Change Color
 </Box>
現時点でのコードのイメージ
export default function App() {
  const [colored, setColored] = useState(false);

  return (
    <Box
      as="button"
      className="🐻-1437279831"
      color={colored ? "blue" : "black"}
      onClick={() => setColored(!colored)}
    >
      Change Color
    </Box>
  );
}
.🐻-1437279831 {
  border: solid 1px black;
  padding: 4px 8px;
}

CSSの差し込みはバンドラに任せる

コンポーネントからはCSSのクラスを参照するようになりましたが、このままではCSS自体が読み込まれません。CSSを読み込むためにはHTMLにlinkタグやstyleタグを差し込む必要があり、ts-morphだけではできません。そのため、Kuma UI はCSSの差し込みをViteやwebpack等のバンドラに委譲します。

具体的には、抽出したCSSをインポートするコードを差し込みます。

概念コード
+import "virtual:kuma-ui-xxxxxx.css";
 export default function App() {
   const [colored, setColored] = useState(false);
 
   return (
     <Box
       as="button"
       className="🐻-1437279831"
       color={colored ? "blue" : "black"}
       onClick={() => setColored(!colored)}
     >
       Change Color
     </Box>
   );
 }

これにより、Vite(Rollup)のCSSサポートwebpackのLoaderがよしなにCSSを解決して差し込んでくれます。

ここまでで、ビルド時に抽出したスタイルを反映することができました。

現時点でのコードのイメージ
import "virtual:kuma-ui-xxxxxx.css";
export default function App() {
  const [colored, setColored] = useState(false);

  return (
    <Box
      as="button"
      className="🐻-1437279831"
      color={colored ? "blue" : "black"}
      onClick={() => setColored(!colored)}
    >
      Change Color
    </Box>
  );
}
.🐻-1437279831 {
  border: solid 1px black;
  padding: 4px 8px;
}

動的なスタイルをランタイムで計算する

ここからはビルド時ではなく、ブラウザでの実行時の話です。

ビルド時には color の値がわかりませんでしたが、ランタイムで実行すれば当然確定できます。この処理はBox コンポーネントのレンダリング処理として行われます[2]

今回の場合、初回レンダリングでは colored には useState のデフォルト値である false が入り、color"black" になります。その結果を以下のようにCSSにします。

.🦄-333024051 {
  color: black;
}

そしてビルド時の処理と同様に、不要になったプロパティを削除して、代わりに抽出したクラスを参照するようにします。合わせてasのタグをレンダリングするようにして、概念的には以下のようになります[3]

export default function App() {
  const [colored, setColored] = useState(false);

  return (
    <button
      className="🐻-1437279831 🦄-333024051"
      onClick={() => setColored(!colored)}
    >
      Change Color
    </button>
  );
}

ランタイムでCSSを差し込む

さて、ビルド時は抽出したCSSをバンドラに差し込んでもらいましたが、バンドルはビルド時の処理ですから、ランタイムでは使えません。

代わりにランタイムでは useInsertionEffect を使ってheadにstyleタグを差し込みます。これはCSS-in-JSだけのためのHooksで、useEffectと似ていますが、コンポーネントのレンダリング結果をDOMに反映する前に実行されます。つまり、以下の流れで処理されます。

useEffectを使ってしまうと、先にDOMへの反映が行われるため、一瞬だけスタイルが反映されていない状態で表示されてしまいます(FOUC[4])。useInsertionEffectを使うと、スタイルを反映してから画面に表示することができます。

ここまでで、ビルド時に抽出したスタイルと、ランタイムに残ったスタイルの両方を反映することができました。

SSRでランタイムCSSを反映する

この時点でVite + Reactの構成では完璧に動作します。しかし、Next.jsではページ表示時にFOUCが発生してしまいます。

なぜなら、useInsertionEffectはuseEffectと同様にSSRでは実行されず、結果的にSSRで出力されたHTMLにはランタイムCSSが差し込まれないからです。その後にブラウザ側でレンダリングが走ってから初めてuseInsertionEffectが実行されて、スタイルが反映されます。

これはuseServerInsertedHTMLという別のHooksで解決します。

まず、Boxコンポーネントのレンダリング処理で、コンテキスト経由で取得したランタイム CSS を登録しておくためのクラス(StyleSheetRegistry)のインスタンスに、抽出したCSSを保存しておきます。

まず、ReactのコンテキストにランタイムCSSを保持しておくためのクラス(StyleSheetRegistry)のインスタンスを持っておきます。そして、Boxコンポーネントのレンダリング処理で抽出したランタイムCSSをインスタンスに保存しておきます。それをuseServerInsertedHTMLの関数内で取得して、style要素として返すようにします[5]

実装イメージ
useServerInsertedHTML(() => {
  const css = /** StyleSheetRegistryからCSSを取り出す処理 */
  return <style dangerouslySetInnerHTML={{ __html: css }} />;
});

useServerInsertedHTMLは、サーバー側でコンポーネントをレンダリングした後、HTMLを出力する前に実行され、戻り値のstyle要素がHTMLに差し込まれます。つまり、以下のように実行されます。

これでSSR結果のHTMLにstyleタグが含まれるようになり、FOUCが解消されました。

Hybrid Approach のこれから

先述の通り、Kuma UIは静的なスタイルをビルド時に抽出します。ここで、以下のスタイルは静的でしょうか。

constant.ts
export BORDER_COLOR = "#3f3f3f";
import { BORDER_COLOR } from "./constant.ts";

<Box borderColor={BORDER_COLOR}>foo</Box>;

これは理論的には静的です。borderColorの値は"#3f3f3f"に確定できます。しかし、現在のKuma UIでは変数のインポート元まで追ってチェックすることができず、これは動的なスタイルとしてランタイムで処理されてしまいます。

また、本当に動的なスタイルはビルド時に抽出できないのでしょうか。先ほどの例に戻って考えてみます。

<Box
  as="button"
  color={colored ? "blue" : "black"}
  onClick={() => setColored(!colored)}
>
  Change Color
</Box>

このときcolorの値は定まりませんが、colorというプロパティがあることは分かっています。この性質に着目すると、動的である値の部分はCSS変数にしておくことで、静的なCSSとして抽出できます。

.🐻-333024051 {
  color: var(--kuma-333024051-color);
}

そして、CSS変数はインラインのstyle属性で動的に埋め込むことができます。これによりランタイムCSSなしに動的なスタイルを実現できます。

<button
  className="🐻-333024051"
  style={{ "--kuma-333024051-color": color }}
  onClick={() => setColored(!colored)}
>
  Change Color
</Box>

ただしこのアプローチも万能ではなく、<Box {...props}> のようにスプレッド構文を使った場合はプロパティの存在すらわからないため対応できません。

このように、Hybrid Approachにおけるランタイムとゼロランタイムの境目は解析エンジンの性能によって変わります。この点でKuma UIはまだ発展途上であり、さらに改善の余地があります。

こういった改善を積み重ねて当面は、(ほぼ)ゼロランタイムかつRSC対応しているEmotion完全互換を目指して開発していく方針です。

https://twitter.com/kuma__ui/status/1734947953486487856

Kuma UI の React Server Component 対応については以下の記事が詳しいです。

https://zenn.dev/readyfor_blog/articles/939991bd64c2c3

おわりに

Kuma UIは今のところ日本人メンテナ4人[6]が中心となって開発しています。Discordで日本語の相談もウェルカムですので、ぜひ使ったりコントリビュートしたりしてもらえると嬉しいです。

また、少しでも応援してくれる方はスターを頂けると励みになります!

https://github.com/kuma-ui/kuma-ui

脚注
  1. 現在は部分的にBabelも用いていますが、ts-morphに一本化する方針 (Issue) ↩︎

  2. https://github.com/kuma-ui/kuma-ui/blob/407a55/packages/core/src/components/Box/react/DynamicBox.tsx ↩︎

  3. 実際にはbuttonBoxコンポーネントのレンダリング結果となるため、Appコンポーネントが直接レンダリングするわけではない ↩︎

  4. Flash Of Unstyled Content と呼ばれる現象 ↩︎

  5. https://github.com/kuma-ui/kuma-ui/blob/407a55/packages/next-plugin/src/registry.tsx#L10-L14 ↩︎

  6. poteboyさん、Naritomiさん、Kotaroさん、僕 ↩︎

Discussion