0️⃣

Zero Runtimeが登場した背景とその目的

2024/01/29に公開

概要

最近何かと話題のZero Runtime CSS in JS、多くのライブラリやフレームワークが登場してきています。筆者はZero Runtimeについては、従来のCSS-in-JSはパフォーマンスが問題なので、最近ではスタイルをビルド時に作り出すアプローチが流行り始めている、といったくらいの認識しかありませんでした。

実際に従来のアプローチがなぜ問題になり始めたのか、Zero Runtimeでは何を目指しているのかいまいち掴めていないところがあったので、改めてその登場した背景や技術的なアプローチを調べてみました。

従来のCSS-in-JS

CSS-in-JSライブラリと名のつくものはいくつもが登場しては廃れを繰り返していきましたが、その中でもReactコミュニティーにおいて今でも不動の地位を得ているのは間違いなく emotion そして、 styled-component でしょう。

CSS-in-JSとは一言で言えば、JavaScriptでCSSを制御するスタイル法のことです。

ページの状態やユーザーのアクションなど、JavaScriptで捕捉できる状態やイベントなどを元に要素のスタイルを変更することでリッチな表現を実現しようとするアプローチです。

多くのCSS-in-JSライブラリではブラウザが構築するCSSOMを、JavaScriptのAPIであるCSSStyleSheet経由で操作することでこれを実現しています。

以下は実際に emoiton がstyleを操作している箇所です。

const sheet = sheetForTag(tag)
  try {
    // this is the ultrafast version, works across browsers
    // the big drawback is that the css won't be editable in devtools
    sheet.insertRule(rule, sheet.cssRules.length)
  } catch (e) {
    if (
      process.env.NODE_ENV !== 'production' &&
      !/:(-moz-placeholder|-moz-focus-inner|-moz-focusring|-ms-input-placeholder|-moz-read-write|-moz-read-only|-ms-clear|-ms-expand|-ms-reveal){/.test(
        rule
      )
    ) {
      console.error(
        `There was a problem inserting the following rule: "${rule}"`,
        ex
      )
    }
  }

ここだけでは見ても何をしているかはわからないと思いますが、Sheet APIにStyleRuleを追加していることは雰囲気でわかると思います。

CSSOMツリーとレンダリング

Zero Runtime登場の背景知識としてブラウザがスタイルの当たった要素を画面上に描写するまでのフローを説明します。

StyleRuleが読み込まれた場合、ブラウザはそれぞれのStyleRuleについてCSSOM(CSS Object Model)を構築し、セレクタを元に木構造を構築します。DOMツリーとCSSOMツリーの構築が完了したタイミングでそれらを一つに結合して個別の要素に対して当たるスタイルを全て計算していきます(Render Tree)。そして各要素の計算されたスタイルからビューポート内での位置やサイズを計算していき(Reflow)、それらをブラウザ上で描画する(Repaint)というフローになります。


https://dev.to/gopal1996/understanding-reflow-and-repaint-in-the-browser-1jbg

ここで注意したいのは、StyleRuleは互いに干渉しうるため一つでもStyleRuleが更新された場合、render treeを再構築する必要があるということです。(ブラウザによっては最適化されている可能性あり)

問題点

CSS-in-JSはCSSOMを直接JavaScriptから制御するスタイル手法ですが、上記フローから分かるように、CSSOMが更新された場合ブラウザは全ての要素のスタイルを再計算する必要があります。CSS-in-JSによって指摘されているパフォーマンスの問題点の原因はブラウザのこの挙動によるものと言えるでしょう。

React 18からそのパフォーマンスの懸念について指摘されるようになった感じがありますが、その理由としては並行レンダリングが導入が挙げられるでしょう。

万が一CSSOMの更新が走る並行レンダリングの最中でレンダリングの中断と再開を繰り返した場合、CSSOMの更新数が増える可能性があります。従来では分割されなかったレンダリングの処理が分割されるようになり、その数だけスタイルの再計算が走る状態に対して懸念の声が上がり始め、それをきっかけにCSS-in-JSのパフォーマンスについて意識されるようになってきたように見えます。

https://github.com/reactwg/react-18/discussions/110

実際に並行レンダリングでのstyle計算のオーバーヘッドについて計測されている方がいるので、詳しく知りたい方は以下の記事を参照してみるといいかもしれません。
Style performance and concurrent rendering

今後の展望

discussionでは他にも様々なCSS-in-JSの問題点について指摘していますが、少なくともReactの開発陣はReact側からこれらの問題についてテコ入れするつもりはないようです。

開発陣が思う理想的なスタイル手法として、共通化されたスタイルはスタイルシートにまとめておき、動的に変更が必要な部分については個別の要素の style 属性で制御することを挙げています。

そしてそれを実現するための手段として、CSS Modulesのような形で静的cssのファイルを利用していくか、CSS-in-JSライクながらもビルド時にcssに吐き出すライブラリ(vanila-extractやFacebookが自作したライブラリが例に挙られている; おそらくstyleX のこと?)
を活用することなどを挙げています。またTailwindのようなUtility-FirstなCSSフレームワークもパフォーマンスを担保する上で有用であると指摘しています。

余談ですが、計算パフォーマンスの改善策としてShadow DOMを利用することでCSSの再計算スコープを限定させるアイデアにも触れられています。(これの実現の可能性は低そうですが)

Zero Runtime

従来のCSS-in-JSの諸々の問題点を踏まえて登場してきたのが Zero Runtimeを利用したCSS-in-JSライブラリです。

先ほど出てきた、

共通化されたスタイルはスタイルシートにまとめておき、動的に変更が必要な部分については個別の要素の style 属性で制御する

今のところはこれがまさにZero Runtimeを活用したCSS-in-JSの目指すところといって良いのではないかと思います。

各フレームワークのアプローチ

以上の背景から、最近ではZero Runtimeやビルド時にStyleRuleを決定するような次世代型のCSS-in-JSライブラリが多く登場されてきています。

ここでは、一部のライブラリがどのようにstyleを作り要素に割り当てるのかを、実際に書き出されるものを見て簡単に見ていきたいと思います。

StyleX

meta社が開発するCSS-in-JSライブラリ。metaが開発するプロダクトのほとんどはこのライブラリを利用しているよう。

import * as stylex from "@stylexjs/stylex";

const styles = stylex.create({
  bar: (height) => ({
    height,
  }),
  foo: {
    height: 1,
  },
});

function DynamicStyle() {
  return <div {...stylex.props(styles.bar(100))}>this is dynamic</div>;
}

function StaticStyle() {
  return <div {...stylex.props(styles.foo)}>this is static</div>;
}

上記コンポネントに対して吐き出されるHTMLとCSSは以下

<div class="page__styles.bar x1jwls1v" style="--height:100px">this is dynamic</div>
<div class="page__styles.foo xjm9jq1">this is static</div>
.x1jwls1v {
    height: var(--height,revert);
}
.xjm9jq1 {
    height: 1px;
}

ビルド時に決定できるスタイルについては静的解析時にCSSファイルに吐き出しつつも、dynamicなスタイルについてはstyle属性にCSS variableを宣言し、CSSファイル経由でスタイルを当てていることがわかります。CSS variablesを使うことでdynamicなスタイルについてもある程度の共通化を図っていることがわかります。

Panda CSS

ビルド時に全てのStyleRuleを生成するCSS-in-JSとして紹介されています。
こちらは動的なstyleの変更にstyle属性を使わず、class名の変更だけでCSS-in-JSを実現しているようです。

import { useState } from "react";
import { css } from "../../styled-system/css";

export function DynamicStyle1() {
  const [color, setColor] = useState("red.300");
  return <div className={css({ color })}>this is dynamic1 🐼</div>;
}

export function DynamicStyle2() {
  const color = "red.200";
  return <div className={css({ color })}>this is dynamic2 🐼</div>;
}

export function StaticStyle() {
  return <div className={css({ color: "red.100" })}>this is static 🐼</div>;
}

吐き出されるCSS、HTML

<div class="text_red.300">this is dynamic1 🐼</div>
<div class="text_red.200">this is dynamic2 🐼</div>
<div class="text_red.100">this is static 🐼</div>
.text_red\.200 {
    color: var(--colors-red-200);
}

.text_red\.100 {
    color: var(--colors-red-100);
}

text_red.300に対するスタイルが書き出されていないことがわかります 😢
panda cssの公式からも、このようにビルド時にスタイルを確定できないものについては正常に動作する保証がないことが記載されています。

https://panda-css.com/docs/guides/dynamic-styling

万が一動的にスタイルを変更したい場合は、recipes などを使ってmulti variantにしてあらかじめ利用しそうなStyleRuleを構築しておくかcss variablesの利用などが推奨されています。

recipesを使った例は以下のようになります。

import { css, cva } from "../../styled-system/css";

const customStyle = cva({
  base: {},
  variants: {
    color: {
      deepRed: {
        color: "red.300",
      },
      shallowRed: {
        color: "red.200",
      },
    },
  },
});

type Color = (typeof customStyle.variantMap.color)[number];

type Props = {
  color: Color;
};

export function DynamicStyle1({ color }: Props) {
  return <div className={customStyle({ color })}>this is dynamic1 🐼</div>;
}
<div class="text_red.300">this is dynamic1 🐼</div>
.text_red\.300 {
    color: var(--colors-red-300);
}

styleXのようにCSS variableを使う共通のclassがあって使う側のstyle属性で変数を定義するのではなく、すでにどの変数を利用するかを確定させたclassを指定する違いがあります。

Kuma UI

ハイブリッドアプローチを謳っているZero Runtime CSS-in-JSフレームワークです。
静的解析によってCSSファイルを書き出すものの、動的に変更している部分は従来のCSS-in-JSと同様の原理で動作するようです。

const DynamicStyle = ({ color }: { color: string }) => {
  return <Box color={color}>this is dynamic style 🐻</Box>;
};

const StaticStyle = () => {
  return <Box color="red">this is static style 🐻</Box>;
};

<DynamicStyle color="red" />
<DynamicStyle color="blue" />
<DynamicStyle color="blue" />
<StaticStyle /><div class="kuma-944610715">this is dynamic style 🐻</div>
<div class="kuma-3852842568">this is dynamic style 🐻</div>
<div class="kuma-3852842568">this is dynamic style 🐻</div>
<div class="kuma-944610715">this is static style 🐻</div>
.kuma-944610715 {
    color: red
}

興味深いのは、kuma UIが提供するBoxコンポネントに渡すプロパティ単位でclassが作られ、静的でも動的でも同じclass名が割り当てられるということです。

kuma-3852842568 のスタイルはどこへ?と思うかもしれませんがこちらは従来のCSS-in-JS通りheadタグに埋め込まれたstyleタグのCSSOMにStyleRuleを記入しているようです。

また、kuma-944610715 についても実はCSSOMに記入されているため( <DynamicStyle color="red" /> が存在するため動的なスタイルの処理が行われているからと思われる)スタイルが二重で宣言されている状態になっているようです。

参考

https://medium.com/@tkh44/writing-a-css-in-js-library-from-scratch-96cd23a017b4
https://web.dev/articles/reduce-the-scope-and-complexity-of-style-calculations?hl=ja
https://web.dev/articles/critical-rendering-path/render-tree-construction?hl=ja
https://stylexjs.com/docs/learn/
https://panda-css.com
https://www.kuma-ui.com/

Discussion