🎨

CSS-in-JSのパラダイムシフト

2023/04/05に公開1

2023年現在、Reactでは多種多様なスタイリング手法が用意されています。

代表どころで言うとCSS ModulesやTailwind、CSS-in-JSなどが有名です。筆者の個人的な好みでは、これらの選択肢の中でもCSS-in-JSを用いたスタイルが特に好きですが、CSS-in-JSライブラリ群の中にはランタイムでスタイリング処理がなされる為にパフォーマンス上の問題を抱えているとの指摘を受けているものもあり、最近は人気が下火になっているように感じています。

そこで本記事では、CSS-in-JSが生まれた背景から遡り、各ライブラリの内部実装を確認しながらそれぞれのライブラリの仕組み・メリット・問題点を明らかにし、CSS-in-JSのパラダイムシフトを追ってみたいと思います。

CSS-in-JSの登場

CSS-in-JSという言葉が最初に公の場で登場したのは、2014年にFacebookのエンジニアであるChristopher "vjeux" Chedeau氏によって提唱されたことが発端だと言われています[1]

彼はCSSの問題点として、グローバル変数の汚染や使用されなくなったCSSクラスの削除が困難であることなどを挙げました。

出典:https://speakerdeck.com/vjeux/react-css-in-js

Facebook社内ではこれらの問題に対処するためにJavaScriptを用いてインラインスタイルをオブジェクト化するという手法が講じられました。

スタイルオブジェクトはプレーンなJavaScriptのため、スタイルの条件分岐を行うことやpropsから新たにスタイルをオーバーライドする事が容易となりました。

var style = {
  text: {
    backgroundColor: '#4e69a2',
    color: 'white'
  }
}

<div style={style.text} />

CSS-in-JSライブラリの台頭

しかし、上記手法は完璧ではありませんでした。インラインスタイルでは:hover:activeのような擬似クラスや擬似要素、メディアクエリがサポートされておらず、この手法ではJavaScriptで全てのスタイリングを表現することができなかったのです。

CSSが持つ全ての機能をJavaScriptで表現するために、Reactコミュニティではいくつかのライブラリの開発が進められました。そうして生まれたのがJavaScriptオブジェクトで作成された動的なCSSオブジェクトをランタイムで<style />タグに挿入するという手法を用いたライブラリ群でした。

いくつかのライブラリが興亡を繰り返した後に、その後後発で登場したEmotionstyled-components等のライブラリが現在に至るまで根強い人気を博しています。

例えばEmotionでは、以下の様に記述することで、擬似要素や擬似クラスもJavaScriptで表現することができます。

import { css } from '@emotion/css'
const color = 'white'
render(
  <div
    className={css`
      background-color: hotpink;
      &:hover {
        color: ${color};
      }
    `}
  >
    Hover to change color.
  </div>
)

classNamecss関数を挿入していることからも分かる通り、EmotionではインラインスタイルではなくCSSクラスを生成しています。

ランタイムCSS-in-JSの仕組み

先ほどランタイムCSS-in-JSライブラリ群の仕組みとして「JavaScriptオブジェクトで作成された動的なCSSオブジェクトをランタイムで<style />タグに挿入することでCSSを表現している」と述べました。

本章では、Emotionのソースコードを交えながら上記仕組みがどのように実装されているかもう一歩踏み込んで見てみましょう。

まず初めに、ランタイムで<style />タグに挿入する機能は@emotion/sheetパッケージが提供するStyleSheetというクラスが担っています。

CSSオブジェクトを<style />タグに挿入する際は必ずinsertというメンバ関数が呼ばれ、そこで既に<style />タグがある場合は追加、ない場合はタグの作成が行われます。

class StyleSheet {
  // 省略
  insert(rule: string) {
    this._insertTag(createStyleElement(this))
  }
}

function createStyleElement(options): HTMLStyleElement {
     // 省略
  let tag = document.createElement('style')
  tag.appendChild(document.createTextNode('')
  return tag
}

StyleSheetのメンバ関数であるinsertの引数となるruleはString型でした。

しかし、実際のユースケースではオブジェクト形式でCSSを表現したいです。

そこで、JavaScriptで作成したCSSオブジェクトを<style />タグにinsert可能な形式にするために、シリアライズと呼ばれる処理を施す必要があります。

シリアライズ処理は、ライブラリが提供する独自の関数を使って行われるか、stylisなどの外部パッケージが用いられます。styled-componentsや後程紹介するLinariaなどのライブラリはstylisが内部的に使用されていますが、Emotionの場合は@emotion/serializeという独自パッケージがシリアライズ処理を担当しています。

実際のシリアライズ処理はInterpolation等や@mediaクエリの処理が必要で複雑ですが、簡略化すると以下の様になっています。

function serializeStyles(styles) {
  const serialized = []
  for (const key in styles) {
    const value = styles[key]
    const hyphenatedKey = key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)
    serialized.push(`${hyphenatedKey}:${value};`)
  }
  return serialized
}

例えばこの関数に{ backgroundColor: 'white' }というJavaScriptオブジェクトを渡すと、"background-color:white;"という形に変換され、<style />タグにinsert可能な形式になります。

ここでようやくcss関数に帰ってきます。
css関数は、以下の様に実装されており、最終的にハッシュ化されたCSSクラス名を返します。同じスタイルオブジェクトが再度css関数に渡されると、キャッシュが参照され既存のクラス名が返されます。

なお、StyleSheetインスタンスの作成、および<style />タグへのinsert処理は@emotion/cacheパッケージ内のcreateCache関数で行われます。

let createEmotion = (options): Emotion => {
  let cache = createCache(options)
  let css = (...args) => {
    let serialized = serializeStyles(args, cache.registered, undefined)
    insertStyles(cache, serialized, false)
    return `${cache.key}-${serialized.name}`
  }
}

上記一連の流れを経て、EmotionのようなランタイムCSS-in-JSライブラリはスタイルを効率的にキャッシュしながらJavaScriptオブジェクトをプレーンなCSSに変換しています。

ランタイムCSS-in-JSの問題点

先ほど紹介した通り、ランタイムでスタイルを更新するEmotionをはじめとしたCSS-in-JSライブラリにはキャッシュ機構が存在しパフォーマンスに一定の配慮がなされていますが、コンポーネントのレンダリングの度にランタイムでスタイルのシリアライズ処理と挿入処理を行う必要があるためにどうしてもパフォーマンスのオーバーヘッドがあります。

以前Emotionのコアメンテナがパフォーマンス上の理由からCSS Modulesに移行したという記事が話題になりました。

When your components render, the CSS-in-JS library must "serialize" your styles into plain CSS that can be inserted into the document. It's clear that this takes up extra CPU cycles

https://dev.to/srmagura/why-were-breaking-up-wiht-css-in-js-4g9b

また、Reactの公式ドキュメントにおいても以下の2つの理由からランタイムでstyleタグ挿入を行うCSS-in-JSライブラリは非推奨とされています。

  1. Runtime injection forces the browser to recalculate the styles a lot more often.(ランタイムインジェクションは、ブラウザによるスタイルの再計算をはるかに頻繁に行わせます。)
  2. Runtime injection can be very slow if it happens at the wrong time in the React lifecycle.(Reactのライフサイクルの間違ったタイミングでランタイムインジェクションが行われると、非常に遅くなることがあります。)
    出典:useInsertionEffect

上記観点から、最近ではランタイムでのオーバーヘッドが存在しない(もしくは微々たる範囲に抑えた)ゼロランタイムCSS-in-JSを謳うライブラリが脚光を浴びています。

有名どころとしては、Linariavanilla-extractがあります。

これらのライブラリはビルド時に静的なCSSファイルを出力し、実行時にはそちらを参照するためにランタイムでシリアライズや挿入処理を行う必要がありません。また、外部ファイルはブラウザによってキャッシュされるために、インラインでスタイリングを行う場合に比べても高速です[2]

なお、これらのライブラリではビルドタイムでCSSを出力するために動的なスタイルの変更が行えないと考えられるかもしれませんが、本来ランタイムCSS-in-JSライブラリであってもprops経由での動的なスタイルの変更は行うべきではありません。

先ほど内部実装から確認した通り、ランタイムCSS-in-JSでは一連のCSSオブジェクトをシリアライズした結果をハッシュ化しているので、値が変わる度にシリアライズ処理が行われます。キャッシュへの参照はハッシュ値から行うため、この処理は動的なスタイリングを施している限り避けられません。つまり、どれだけ些細な変更であったとしても必ず再計算と追加領域の確保が必要となります。

この理由により、例えランタイムCSS-in-JSライブラリであっても動的なスタイリング処理はインラインスタイル内で行うべきです。このことはEmotionの公式ドキュメント内でも触れられています。

ゼロランタイムCSS-in-JSの仕組み

ランタイムオーバーヘッドのないCSS-in-JSライブラリを作るためには、ビルドタイムでJavaScriptの字句・構文解析及び変換処理を行う必要があります。ビルドタイムで成果物に対して何かしらの処理を加える方法はいくつかありますが、Webpackの様なモジュールバンドラを用いる手法や、Babel Pluginの様に生成されたバンドルに対して処理を加える手法が一般的です。

抽出対象となるソースコードを AST(抽象構文木)に変換し、ASTを解析することでJavaScriptオブジェクトからCSSを抽出して外部ファイルに出力を行います。ASTについてここでは詳しい説明を省きますが、簡単に言うとソースコードの字句解析・構文解析(BabelではこれらをまとめてParseと呼ぶ)して木構造で表現したものを指します。

例えば、const sum = (a, b) => a + bというJavaScriptコードはParse処理を経て以下の様な構造に変換されます。

ASTで表現されたsum関数
{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "sum"
          },
          "init": {
            "type": "ArrowFunctionExpression",
            "params": [
              {
                "type": "Identifier",
                "name": "a"
              },
              {
                "type": "Identifier",
                "name": "b"
              }
            ],
            "body": {
              "type": "BinaryExpression",
              "operator": "+",
              "left": {
                "type": "Identifier",
                "name": "a"
              },
              "right": {
                "type": "Identifier",
                "name": "b"
              }
            }
          }
        }
      ],
      "kind": "const"
    }
  ]
}

Babelでは、Parse処理の次段階としてTransformという処理が行われます。ここでは受け取ったASTを走査して、各Nodeの作成、削除、変換処理を行います。Babelプラグインは一般にこの段階に対して処理を行います[3]

上記を踏まえ、本章ではゼロランタイムCSS-in-JSの中でも人気を誇るLinariaの内部実装を見ながらゼロランタイムCSS-in-JSが実際にどのように実装されているかを見ていきましょう。

まずは先ほどのEmotionと同様にcss関数が定義されている箇所を探します。定義箇所を見てみると何も処理は書かれておらず、この関数が呼び出された場合にエラーを起こす様になっています。

packages/core/src/css.ts
const css: CSS = () => {
  throw new Error(
    'Using the "css" tag in runtime is not supported. Make sure you have set up the Babel plugin correctly.'
  );
};

この理由として、この関数はBabelプラグインが正しく設定されていない場合にランタイムエラーを起こすようになっており、実際のビルド処理ではASTを解析してソースコード内のcss関数を見つけ、css関数の中で定義されたスタイルから一意なクラス名を生成し、最後に生成されたクラス名を用いて、ソースコード内のcss関数を置き換えているからです。

export default function extractStage() {
  // 簡略化のため一部省略
  processors.forEach((processor) => {
    processor.artifacts.forEach((artifact) => {
      if (artifact[0] !== 'css') return;
  return {
    ...extractCssFromAst(),
    };
  }
}

LinariaのBabelプラグインにおいて、Transform処理はCSSの変換処理を含めた計4段階に分けて行われています。これらのステージには、テンプレートリテラルの解析、デッドコード削除(所謂Tree Shaking)、CSSの生成と置換、そしてキャッシュ機構を用いて既に評価された依存関係を用いることでビルドタイムを省略する処理などが含まれています。

上記より、LinariaはBabelプラグインを使用してソースコード内のcss関数を変換していることが分かりました。次に、この処理によって生成されたCSSを実際にバンドラに適用する必要があります。

Linariaは、バンドルプロセスでWebpackやViteなどのバンドラをサポートしており、それぞれに対応するローダーやプラグインが提供されています。これらのローダーやプラグインは、Babelプラグインによって変換されたソースコードを処理し、生成されたCSSを適切な場所に出力します。

Vite用のローダーの実装をみてみると、BabelプラグインのTransformを経て出力されたCSSを以下のようにして実際のバンドラに含めていることが分かります。

const cssFilename = path.normalize(
  `${id.replace(/\.[jt]sx?$/, '')}_${slug}.css`
);
const cssRelativePath = path
  .relative(config.root, cssFilename)
  .replace(/\\/g, path.posix.sep);
const cssId = `/${cssRelativePath}`;

cssLookup[cssFilename] = cssText;
cssLookup[cssId] = cssText;

以上により、LinariaのようなCSS-in-JSライブラリはビルドプロセスでASTからCSSオブジェクトを抽出・変換し最終的なバンドラに含めることでゼロランタイムでの動作を実現しているのです。

終わりに

ところで、ここから先は完全に余談となりますが、筆者は一部CSSライブラリが提供するUtility Firstというアプローチがとても好きです。Utility Firstとは予めライブラリによって定義された汎用的なスタイルクラスやプロパティを組み合わせてUIを構築する手法で、コンポーネント指向との相性がとても良いと感じています。

Utility FirstなコンポーネントライブラリとしてはChakra UIがその典型で、ライブラリから提供されるUtlity Propsを使用することでインラインスタイルのような構文でコンポーネントにスタイルを適用することができます。

<Box bg="teal.500" padding="4" borderRadius="lg">
   <Text color='black'>Hello, World!</Text>
</Box>

しかし、Chakra UIは内部的にEmotionを使用しており、今後パフォーマンスの観点から新規で採用されるケースは減っていくと予想されます。

さて、このようなUtility Firstを提供するライブラリの1つに現在はメンテナンスされていないStyled Systemというライブラリがあります。このライブラリはChakra UIのようなコンポーネントライブラリとは異なりUtility関数のみを提供するライブラリで、styled-componentsと組み合わせて以下の様に使用することが想定されています。

import styled from "styled-components";
import { space, color } from "styled-system";
const Box = styled.div`
  ${space}
  ${color}
`;
const Component = () => ({
    <Box p={4} bg="orange">
      Hello, World!
    </Box>
})

DX的な観点で僕はこのライブラリが非常に気に入っていて、このような思想のライブラリはゼロランタイムCSS-in-JSの時代においても残り続けてほしいと思っています。そこで、ゼロランタイムCSS-in-JSにおいても同様の機能を提供することは可能なのかを検証するために各CSS-in-JSライブラリの内部実装を読み始めたというのがこの記事を書いた経緯になります。

そして個人的には、ゼロランタイムCSS-in-JSにおいてもほぼ同様の機能を提供することは技術的に十分可能であると結論に至りました。業務レベルに耐えうる類似ライブラリはいずれまた出現すると予想されるのでそちらに譲りますが、筆者も数日前から趣味でゼロランタイムかつUtility FirstなCSS-in-JSライブラリを作り始めたので、是非そちらも覗いていただけると幸いです。

https://github.com/poteboy/zero-styled

脚注
  1. この発表を行ったVjeux氏本人のツイートにおいても、「アイデア自体は以前から根差していたが、CSS in JSブームの始まりはこの登壇であった」と述べられています。 ↩︎

  2. ReactJS inline styles VS CSS : benchmark ↩︎

  3. Stages of Babel ↩︎

Discussion