💈

ゼロランタイムCSS in JSとは?結局、何を使えばいい?- フロントエンドの現在 -

2023/10/29に公開

はじめに ~CSS in JSの終焉とゼロランタイムへの移行~

フロントエンドの技術は目まぐるしく変化しています。

そんな中で、フロントエンドフレームワークにおいてはReactが軍配を上げており、cssにおいてはCSS in JSが主流になりつつあります。

余談ですが、筆者はかつてはcssの命名や設計、CSS Lint等々多岐に渡り効率的なCSSの運用について頭を悩ませていましたが、CSS in JSのemotionとMUIのようなコンポーネントライブラを使うことが多くなり、css周りについて考えることが無くなり開発が楽になりました。最近のプロジェクトではcssやscssファイルがプロジェクト内に存在していないような状況です。

しかし、なんと既にCSS in JSがオワコンと言われ始めているではないですか!なぜオワコンと言われているのか理由を探ってみたところ以下の記事を見つけました。

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

ここからは上の記事の内容の解説になります。

Why We're Breaking Up with CSS-in-JS(私たちがCSS-in-JSをやめる理由)

Sam Maguraは、CSS in JSライブラリ、Emotionの2番目に活発なメンテナとして知られています。彼の経験と知識から、CSS in JSの利用には一定の問題点があると指摘しています。彼は、初めはCSS in JSの利点に魅かれていたものの、Spotチームとしての実際の使用中に遭遇したパフォーマンスの問題やその他の課題を通じて、その限界を感じるようになったようです。

CSS in JSのデメリット

  1. ランタイムのオーバーヘッド: CSS in JSは、コンポーネントがレンダリングされる際に、スタイルをドキュメントに挿入するためのプレーンCSSに変換する必要があります。この変換プロセスは追加のCPUサイクルを必要とします。このオーバーヘッドは、アプリケーションのパフォーマンスに影響を与える可能性があります。
  2. バンドルサイズの増加: CSS in JSライブラリのJavaScriptをダウンロードする必要があるため、サイトを訪れるユーザーのバンドルサイズが増加します。例として、Emotionは7.9 kB(minzipped)で、styled-componentsは12.7 kBです。これらのライブラリは巨大ではありませんが、全体としてのサイズは増加します。
  3. React DevToolsの cluttered: EmotionなどのCSS in JSライブラリは、内部的なコンポーネントをReactのツリーに挿入します。これにより、React DevToolsがclutteredになり、デバッグが難しくなる可能性があります。
  4. ブラウザの追加作業: CSSルールを頻繁に挿入することは、ブラウザに多くの追加作業を強いることになります。特に、Reactの並行レンダリングモードでは、新しいルールを挿入すると、ブラウザはそのルールが既存のツリーに適用されるかどうかを確認する必要があります。これにより、Reactがレンダリングしている間、すべてのCSSルールとすべてのDOMノードに対してスタイルの再計算が行われることになり、これは非常に遅いです。
  5. SSRやコンポーネントライブラリとの互換性: Emotionをサーバーサイドレンダリングや他のEmotionを使用するコンポーネントライブラリと組み合わせて使用する際には、さまざまな問題が発生する可能性があります。これには、Emotionのインスタンスが複数ロードされる、スタイルの挿入の順序を完全に制御できない、React 17とReact 18の間でEmotionのSSRサポートが異なる、などの問題が含まれます。

CSSライブラリのこれから

CSS in JSのような新しいスタイリング方法が登場する中、従来のCSSライブラリやフレームワークも進化を続けています。SassやLessのようなプリプロセッサは、変数やミックスインなどの機能を提供して、CSSの記述をより効率的にします。

また、Tailwind CSSのようなユーティリティファーストのCSSフレームワークも人気を集めており、クラス名を組み合わせることで迅速にデザインを構築することができます。

CSS in JSの問題点を考慮すると、従来のCSSライブラリや新しいユーティリティファーストのアプローチが、今後のWeb開発においても重要な役割を果たすことが予想されます。

ゼロランタイムCSS in JSへの移行

上の記事で述べられているように、オーバーヘッドの発生に問題があるようですね。オーバーヘッドが発生することで、全体的なページの読み込み速度やレンダリング速度が落ちてしまいます。そこで、登場したのがゼロランタイムCSS in JSです。

ゼロランタイムCSS in JSとは?

ゼロランタイムCSS in JSは、JavaScript 内でCSSを書く技術であり、かつビルド時に実際のCSSファイルを生成するものを指します。通常のCSS in JSと比較したメリットは、JavaScriptの実行時間のコストを削減しながら、JavaScript内でCSSを書くという利便性を保持できることです。

ゼロランタイムCSS in JSのメリット

ゼロランタイムCSS in JSとは、ランタイム中にCSSを動的に生成・注入しないCSS in JSのアプローチです。これは、ビルド時にすべてのスタイルが生成され、ランタイム中には追加のJavaScriptのオーバーヘッドが発生しないという特徴があります。

  1. パフォーマンス: ゼロランタイムのアプローチは、ランタイム中にスタイルを動的に生成・注入するオーバーヘッドがないため、ページの読み込み速度やレンダリング速度が向上する可能性があります。
  2. 予測可能性: スタイルがビルド時に生成されるため、ランタイム中に予期せぬスタイルの変更やサイドエフェクトが発生するリスクが低くなります。
  3. 小さなバンドルサイズ: 一部のゼロランタイムのライブラリは、不要なランタイムコードを削除することで、最終的なバンドルサイズを削減することができます。
  4. サーバーサイドレンダリング (SSR) の簡素化: 一部のCSS-in-JSソリューションは、サーバーサイドレンダリング時に追加の設定や手順が必要ですが、ゼロランタイムのアプローチはこの問題を緩和または解消することができます。
  5. 静的解析の利点: スタイルがビルド時に生成されるため、ツールやlinterが静的にスタイルを解析しやすくなります。
  6. 環境の互換性: 一部の環境やフレームワークでは、ランタイム中にスタイルを動的に注入することが難しいか、推奨されていない場合があります。ゼロランタイムのアプローチは、これらの環境でも問題なく動作します。

NextjsでのCSS in JSの使用

Next.jsの公式では、App Routerのappディレクトリ内で使用できるライブラリのリストが記載されています。現時点(2023/10/27)では、クライアントコンポーネントでの使用のみ対応しているようです。

https://nextjs.org/docs/app/building-your-application/styling/css-in-js

ゼロランタイムCSS in JSのライブラリ一覧

現存するゼロランタイムCSS in JSを調べてみました。以下が継続的に開発されているライブラリです。

  • Linaria
  • vanilla-extract
  • Panda CSS
  • Goober
  • Astroturf
  • Treat

結局、どのCSS in JSライブラリを使えばいいのか

それぞれを比較してみて、様々な観点からベストなCSS in JSを選定しようと思います。

ライブラリ リンク 主な特徴 静的CSSの生成 TypeScriptサポート SSRサポート GitHubスター数 開発時期
Linaria https://github.com/callstack/linaria ビルド時にCSSファイルにCSSが抽出される。ほぼすべての現代のフレームワークと互換性あり。Reactのpropsに基づく動的なスタイルを使用できる(css変数を使用)。CSSソースマップでスタイルが定義された場所を簡単に見つけられる。stylelintでJS内のCSSをLint。必要に応じてSassやPostCSSなどの任意のCSSプリプロセッサを使用。@linaria/atomicでアトミックスタイルをサポート。通常のCSSと比べて、セレクタがスコープ内であり、スタイルはコンポーネントと同じファイル内にあり、リファクタリングが容易。他のCSS in JSライブラリとの相互運用可能。JavaScriptなしで動作。 ビルド時にCSSを抽出し、それを静的CSSファイルとして出力 TypeScriptサポートを提供しますが、設定が少し複雑になる可能性あり なし 10.9k 2017/5/21(最新: 2023/10/3)
vanilla-extract https://github.com/vanilla-extract-css/vanilla-extract ローカルにスコープされたクラス名とCSS変数でスタイルを書き、ビルド時に静的CSSファイルを生成。標準のCSSをほんの少し抽象化。任意のフロントエンドフレームワークで動作/フレームワークなしでも動作。ローカルにスコープされたCSS変数、@keyframes、@font-faceルール。グローバルなしで同時に複数のテーマをサポートする高レベルのテーマシステム。変数ベースのcalc式を生成するためのユーティリティ。CSSTypeを通じての型安全なスタイル。開発とテストのためのオプションのランタイムバージョン。動的なランタイムテーマのためのオプションAPI ビルド時に静的CSSを生成 TypeScriptを利用した型安全なスタイリングを提供 なし 8.8k 2021/2/21(最新: 2023/9/16)
Panda CSS https://github.com/chakra-ui/panda ビルド時にスタイルオブジェクトやスタイルプロップを抽出。モダンなCSS出力 - カスケードレイヤー@layer、CSS変数など。ほとんどのJavaScriptフレームワークと互換性あり。stitches(https://stitches.dev/)のようなレシピとバリアントをサポート。同時に複数のテーマをサポートするための高レベルのデザイントークン。コード生成を通じての型安全なスタイルとオートコンプリート。Chakra UI、Vanilla Extract、Stitches、Tailwind CSS、Styled Systemなどのプロジェクトからのインスピレーションを受けて開発された。 ビルド時に静的CSSを生成 TypeScriptを利用した型安全なスタイリングを提供 SSGおよびSSRをサポート 3.7k 2022/1/24(最新: 2023/10/29)
Goober https://github.com/cristianbote/goober 1KB未満の軽量なフットプリント。既存のstyledパターンをより小さなフットプリントで実現したいという思いから開発。styled-componentsやemotionと同様のstyledパターンをサポート。SSRのベンチマークでは、他の主要なCSS-in-JSライブラリよりも高速。Gooberはvanilla JavaScriptやReact、Vue.js、Angular、Svelteなどのフレームワークと互換性あり。プラグインやマクロを通じて、BabelやNext.js、Gatsbyなどとの統合が容易。スタイルの共有や自動プレフィックス付けなどの機能をサポート。主要なブラウザをサポートし、Babelを使用することで古いブラウザもサポート可能。 ビルド時にextractCss関数を使用して静的CSSを抽出し、それを<head>タグに注入する。主にサーバーサイドレンダリング時に利用 明示的なTypeScriptサポートについての情報が限られている SSRに対応し、extractCss関数を提供 3k 2019/1/27(最新: 2023/4/19)
Astroturf https://github.com/astroturfcss/astroturf JavaScriptファイル内でCSSを書くことができるが、ランタイムレイヤーを追加せず、既存のCSS処理パイプラインとともに動作する。フレームワーク固有のCSS処理が必要となる柔軟性の喪失や、ランタイムスタイルの解析なしでCSSを完全に静的に保持する。Sass、PostCSS、Lessなどを使用しながら、JavaScriptファイル内でスタイル定義を書くことができる。ランタイムのオーバーヘッドなしで、JavaScript内でのスタイリングの利点を享受しつつ、既存のCSSツールとの互換性を維持。フレームワークと互換性があり、Reactのprops機能をサポートしている。 ランタイムレイヤーを追加せずに、既存のCSS処理パイプラインとともに静的CSSを生成 TypeScriptサポートを提供するが、サードパーティのライブラリを必要とする なし 2.2k 2016/10/16(最新: 2023/5/17)
Treat https://github.com/seek-oss/treat フレームワークに依存しないライブラリ。テーマ対応と静的抽出によるスタイル定義軽量ランタイムでバンドルサイズを最適化。webpack, React, TypeScriptのサポート。レガシーブラウザもサポート。 ビルド時にすべてのCSSルールを生成し、生成されたCSSスタイルのみをバンドル TypeScriptを利用した型安全なスタイリングを提供 なし 1.2k 2019/5/12(最新: 2021/4/28)

以下の観点からベストなCSS in JSを選定しようと思います。

ライブラリの保守運用
Treatは2021年から更新が止まっているため、今後の保守運用で問題が出る可能性がありそうです。

静的CSSの生成
静的CSSの生成のプロセスには違いはありますが、どのライブラリもビルド時に静的CSSを生成し、ランタイムのオーバーヘッドを削減できます。

TypeScriptサポート
vanilla-extract, Panda CSS, Treatに関しては問題なく型安全なスタイリングを提供しています。他のライブラリに関しては追加のライブラリが必要であったり、設定が複雑化したりなどがあります。

SSRサポート
Panda CSS, GooberはSSRへ対応していますが、他のライブラリに関しては情報が不足しており確実に対応しているとは言えない状況です。

パフォーマンス
様々な記事を見たところ、どのライブラリも描画時間等のパフォーマンスの差は無いようです。静的なcssに変換されているため、そこまで違いは無さそうですね。

スタイルの書き方

Linaria
import { css } from '@linaria/core';
import { modularScale, hiDPI } from 'polished';
import fonts from './fonts';

// Write your styles in `css` tag
const header = css`
  text-transform: uppercase;
  font-family: ${fonts.heading};
  font-size: ${modularScale(2)};

  ${hiDPI(1.5)} {
    font-size: ${modularScale(2.5)};
  }
`;

// Then use it as a class name
<h1 className={header}>Hello world</h1>;
import { styled } from '@linaria/react';
import { families, sizes } from './fonts';

// Write your styles in `styled` tag
const Title = styled.h1`
  font-family: ${families.serif};
`;

const Container = styled.div`
  font-size: ${sizes.medium}px;
  color: ${props => props.color};
  border: 1px solid red;

  &:hover {
    border-color: blue;
  }

  ${Title} {
    margin-bottom: 24px;
  }
`;

// Then use the resulting component
<Container color="#333">
  <Title>Hello world</Title>
</Container>;
vanilla-extract
// styles.css.ts

import { createTheme, style } from '@vanilla-extract/css';

export const [themeClass, vars] = createTheme({
  color: {
    brand: 'blue'
  },
  font: {
    body: 'arial'
  }
});

export const exampleStyle = style({
  backgroundColor: vars.color.brand,
  fontFamily: vars.font.body,
  color: 'white',
  padding: 10
});
// app.ts

import { themeClass, exampleStyle } from './styles.css.ts';

document.write(`
  <section class="${themeClass}">
    <h1 class="${exampleStyle}">Hello world!</h1>
  </section>
`);
Panda CSS
import { css } from '../styled-system/css'
import { stack, vstack, hstack } from '../styled-system/patterns'

function Example() {
  return (
    <div>
      <div className={hstack({ gap: '30px', color: 'pink.300' })}>Box 1</div>
      <div className={css({ fontSize: 'lg', color: 'red.400' })}>Box 2</div>
    </div>
  )
}
import { css } from '../styled-system/css'
import { styled } from '../styled-system/jsx'
 
// The className approach
const Button = ({ children }) => (
  <button
    className={css({
      bg: 'blue.500',
      color: 'white',
      py: '2',
      px: '4',
      rounded: 'md'
    })}
  >
    {children}
  </button>
)
 
// The style props approach
const Button = ({ children }) => (
  <styled.button bg="blue.500" color="white" py="2" px="4" rounded="md">
    {children}
  </styled.button>
)
Goober
import { h } from 'preact';
import { styled, setup } from 'goober';

// Should be called here, and just once
setup(h);

const Icon = styled('span')`
    display: flex;
    flex: 1;
    color: red;
`;

const Button = styled('button')`
    background: dodgerblue;
    color: white;
    border: ${Math.random()}px solid white;

    &:focus,
    &:hover {
        padding: 1em;
    }

    .otherClass {
        margin: 0;
    }

    ${Icon} {
        color: black;
    }
`;
interface Props {
    size: number;
}

styled('div')<Props>`
    border-radius: ${(props) => props.size}px;
`;

// This also works!

styled<Props>('div')`
    border-radius: ${(props) => props.size}px;
`;
Astroturf
import * as React from 'react';
import { css } from 'astroturf';

function Button({ children, ...props }) {
  return (
    <button
      {...props}
      css={css`
        color: blue;
        border: 1px solid blue;
        padding: 0 1rem;
      `}
    >
      {children}
    </button>
  );
}
Treat
// Button.treat.js
// ** THIS CODE WON'T END UP IN YOUR BUNDLE EITHER! **
import { style } from 'treat';

export const button = style((theme) => ({
  backgroundColor: theme.brandColor,
  height: theme.grid * 11
}));
// Button.js
import React from 'react';
import { useStyles } from 'react-treat';
import * as styleRefs from './Button.treat.js';

export const Button = (props) => {
  const styles = useStyles(styleRefs);

  return <button {...props} className={styles.button} />;
};

結論: Panda CSS

様々な観点で考えた結果、総合的にPanda CSSがいいかと思いました。ただ、スタイルの書き方が独特なので、これまでのstyled-componentやemotionのような書き方が好きな方はGooberが良いと思います。また、Panda CSSはChakra UI、Vanilla Extract、Stitches、Tailwind CSS、Styled Systemなどのプロジェクトからのインスピレーションを受けて開発されており、比較的新しいライブラリであることを考えると既存のライブラリのいいとこ取りができているように思えます。

(補足情報) Headlessコンポーネントのゼロランタイムも登場してきている

以下のKuma UIは、ヘッドレスのコンポーネントでpropsとclassNameにスタイルが記述できるハイブリッドアプローチで気軽に実装できます。

ライブラリ 主な特徴 GitHubスター リンク
Kuma UI スタイルの自動補完でシームレスな開発体験を実現。ヘッドレスのコンポーネントで、ライブラリ内のすべてのコンポーネントはスタイルが適用されていないため、ユーザーが独自のスタイルを適用するための最大の柔軟性を提供。ハイブリッドアプローチで任意の記述スタイルをサポート。RSCサポートを通じて最先端のNext.js技術を常に最新の状態に保つ。馴染みのあるAPIデザイン。ビルド時に確定できるスタイルを静的に抽出し、動的に変更される可能性のあるスタイルに対して静的な"dirty check"を実行してランタイムで注入する方法を採用。Styled System、Chakra UI、Native Base、Panda CSS、Linaria、Vanilla Extractなどのライブラリからのインスピレーションを受けてベストな特徴を組み込んでいる。 1.3k https://github.com/kuma-ui/kuma-ui

スタイルの書き方

Kuma UI
function App() {
  return (
    <Box as="main" display="flex" flexDir={["column", "row"]}>
      <Heading
        as="h3"
        className={css`
          color: red;
          @media (max-width: sm) {
            color: blue;
          }
        `}
      >
        Kuma UI
      </Heading>
      <Spacer size={4} />
      <Flex flexDir={`column`}>
        <Text as="p" fontSize={24}>
          Headless UI Component Library
        </Text>
        <Button variant='primary'>Getting Started</Button>
      </Flex>
    </Box>
  );
}

おわりに

今回は、様々なゼロランタイムCSS in JSライブラリの調査し、ベストなライブラリを選定してみました。軽量さを重視するならGooberを使用するなど、観点によってはライブラリの選定も変わってくると思うので、それぞれの特徴からプロジェクトに合ったライブラリを見つけましょう。ここまで読んでいただきありがとうございました。

Discussion