CSSの動的型付けについて

2024/10/07に公開

はじめに

ウェブアプリケーションのスタイリング手法は様々ですが、個人的なプロジェクトでは、いまだにCSSを愛用しています。CSSを使うことで、JSバンドルのサイズを小さく抑え(例えばCSS-in-JSと比較して)、ロジックとスタイリングを分離を実現できる点が、私にとって大きなメリットです。

とはいえ、最近のスタイリング手法には魅力的な点もいくつかあります。CSSにはどんな課題があり、どうすればそれを解決できるのか、この記事で詳しく見ていきましょう。
(注:この記事ではReactとTypeScriptの用語を使用します)

詳細に入る前に、少し考えてみましょう。

CSSの型付けを自動化することって、本当に必要なのでしょうか?
どんな問題を解決できるのでしょうか?

まず思いつくのは、CSSに存在するクラスを事前に把握することで、存在しないクラスを要素に適用してしまうミスを防げることです。これは確かにメリットですが、ごく基本的な例です。もっと注目すべき点は、型付けによって、コンポーネントの見た目に関する設定と連携した型を自動生成できるようになることです。

手動によるCSSの型付けの問題点

典型的なボタンのスタイリングを例に考えてみましょう。

(デザインのソース)

この図のように、ボタンのデザインはいくつかバリエーションがあります。見た目こそ違えど、機能的にはどれも同じボタンです。スタイルをCSSに記述することで、コンポーネントのロジックを視覚的な詳細から切り離し、コードの可読性を向上させることができます。

しかし、ロジックと表示が完全に分離されているとは言えません。少なくとも、ロジック側では、ボタンのデザイン設定としてどのような種類があるのかを把握する必要があります。

interface ButtonProps {
    // ...その他のプロップス
    variant: 'contained' | 'outlined' | 'texted'
}

デザインに変更があったたびに手動で型を修正することもできますが、プロジェクトが大きくなると問題が生じる可能性があります。例えば、'texted'というバリアントがインターフェースに存在しない場合、開発者がまだ実装していないことを意味しますが、実際にはCSSに既に存在する可能性があります。

このように、Single Source of Truthの原則が破られると、ロジックと表示の分離によるメリットを上回るデメリットが生じます。では、この問題を解決する最新の解決策を見ていきましょう。

Class Variance Authority

最近よく使われているUIコンポーネントライブラリのshadcnについて簡単に触れておきます。

shadcnでは、スタイリングのためにTailwind CSSと併せてclass-variance-authorityライブラリ(cva関数)を使用しています。

この関数の使用方法の例を見てみましょう(簡略版)

const buttonVariants = cva(
    "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium",
    {
        variants: {
            variant: {
                default: "bg-primary text-primary-foreground hover:bg-primary/90",
                outline:
                    "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
                secondary:
                    "bg-secondary text-secondary-foreground hover:bg-secondary/80",
            },
            size: {
                default: "h-10 px-4 py-2",
                sm: "h-9 rounded-md px-3",
                lg: "h-11 rounded-md px-8",
            },
        },
    }
)

Tailwind CSSのクラスをTypeScriptで直接使用することで、プロパティの型付けを容易に行うことができます。

必要なのは、Tailwindのクラスを目的のプロパティ名に合わせて一度だけグループ化することです。これだけで、先に述べた型付けの問題が自動的に解決されます。

この考え方はシンプルで、CSS-in-JSを使っていれば、ジェネリック型を一つ定義するだけで同様の型付けを実現できます。しかし、今回のケースではCSSとTSは直接連携していないため、CSSのトランスパイルプロセスに介入する必要があります。

そしてここで、PostCSSが登場します!

PostCSS

はじめに

PostCSSは高速なJavaScriptベースのCSSトランスパイラで、出力のカスタマイズ性の高さが大きな特徴です。LessやSassなどのプリプロセッサと異なり、PostCSSはプラグインの組み合わせによってCSSの出力を自由に制御できます。プラグインはCSSの構文拡張だけでなく、トランスパイル過程での様々な処理(例えば、TypeScriptの型定義ファイルの生成など)も可能にします。

PostCSSの動作概要は以下の図の通りです。

ちなみに、今回の目的ではParserStringifier、その他の複雑な処理(Tokenizerなど)の内部構造を気にする必要はありません。プラグインを作成するだけで十分です。

プラグインの動作原理

まずは、一般的なPostCSSプラグインの動作を見てみましょう。

  1. プラグインへの入力はAST(Abstract Syntax Tree)です。
    これはCSSの構造とメタ情報を表す木構造データです。
  2. プラグインはASTのノードを走査し、ノードの追加、変更、削除によって構造を変化させます。
  3. プラグインは変更後のASTを出力します。

例として、標準的なプラグインであるAutoprefixerの動作を見てみましょう。

シンプルなCSSスタイルを以下のように定義します。

.example {
    user-select: none;
}

このCSSに対応するASTモデル(理解を容易にするため、一部の詳細は省略しています)は次のようになります。

{
  "type": "root",
  "nodes": [
    {
      "raws": {},
      "type": "rule",
      "selector": ".example",
      "nodes": [
        {
          "raws": {},
          "type": "decl",
          "source": {},
          "prop": "user-select",
          "value": "none"
        }
      ],
      "source": {}
    }
  ],
  "source": {},
  "inputs": []
}

Autoprefixerの役割は、追加のルールやプロパティを追加することで、クロスブラウザ互換性を確保することです。上記の例では、Autoprefixerは.exampleルールに3つのノードを追加します(オブジェクトは簡略化されています)。

{
  "type": "root",
  "nodes": [
    {
      "type": "rule",
      "selector": ".example",
      "nodes": [
        {
          "type": "decl",
          "prop": "user-select",
          "value": "none"
        },
        {
          "type": "decl",
          "prop": "-ms-user-select",
          "value": "none"
        },
        {
          "type": "decl",
          "prop": "-moz-user-select",
          "value": "none"
        },
        {
          "type": "decl",
          "prop": "-webkit-user-select",
          "value": "none"
        }
      ]
    }
  ]
}

最終的に、次のようなCSSが出力されます。

.example {
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
}

まとめ

PostCSSを使うことで、開発者はトランスパイルされるCSSを完全に制御できるだけでなく、追加の処理を実行することもできます。このプラグインはCSSの型付けを自動化し、先に述べた問題を解決します。

typed-styles-plugin

概要

class-variance-authorityにインスパイアされたこのPostCSSプラグインは、CSSファイルからTypeScriptの型定義を動的に生成します。TypeScriptプロジェクトでの使用を想定しており、
自動的な型生成によってSingle Source of Truthを実現し、より良い懸念事項の分離を促進します。

現時点では概念実証段階であり、リポジトリにはプラグインの縮小版ビルドのみが含まれています。
ソースコードは、さらなる最適化の後で公開する予定です。
レポのリンク

クイックスタート

  1. プロジェクトにPostCSSをセットアップします。詳しい手順はPostCSSリポジトリ
    をご覧ください。

  2. プロジェクトにpostcss-style-props.min.cjsを配置します。

  3. postcss.config.cjsにプラグインを追加します。

    module.exports = {
        plugins: [
            require("autoprefixer")(),
            require("./path/to/postcss-style-props.min.cjs")(),
        ],
    };
    
  4. プラグインの準備が完了しました。

より細かい設定はこのリンクをご覧ください

構文

このプラグインは、いくつかの@ルールによってCSS構文を拡張します。
より詳細な例はレポのsyntax-exampleフォルダにあります。

@variants

cvaと同様に、コンポーネントのバリエーションを宣言します。唯一のパラメータは、ケバブケースのプロパティ名です。子@variantノードは、プロパティの可能な値を定義します。

@variants button-variant {
    @variant primary-btn {
        color: var(--text-primary);
        background: var(--bg-primary);
    }

    @variant secondary-btn {
        color: var(--text-secondary);
        background: var(--bg-secondary);
    }
}

これは、次の内容の.d.tsファイルに変換されます。

export type StyleProps = {
    buttonVariant: 'primary-btn' | 'secondary-btn'
}

@switch

ブール型のプロパティを定義するために使用します。値がtrueの場合にスタイルが適用されます。プロパティ名はパラメータとして指定します。

@switch is-loading {
    padding: 0 12px;
}

これは次のように変換されます。

export type StyleProps = {
    isLoading?: boolean
}

@combine

cvaのcompoundVariantsと同様に、特定のプロパティの組み合わせに基づいて独自のスタイルを適用できます。
variants()@combineの後に記述する必要があります。
引数はCSSプロパティのように列挙します:prop-name: prop-value。組み合わせには少なくとも2つのプロパティが必要です。 上限はありません。
@switchを使用する場合は、onoffの値を使用します。

この構文の詳細については、syntax-exampleフォルダを参照してください。

@combine variants(size: medium, is-loading: on) {
    cursor: not-allowed;
}

コメント

このプラグインは、プロパティへのコメントの追加をサポートしています。対応するルールの手前にコメントを記述するだけです。

/* ボタンのスタイル。背景色、テキスト色、ボーダー色の変更に使用します */
@variants variant {
    /* 強調ボタン。主要なアクションに使用します */
    @variant primary {
        /* いくつかのスタイル */
    }

    /* 弱強調ボタン。補助的なアクションに使用します */
    @variant secondary {
        /* いくつかのスタイル */
    }
}

これらのコメントは.d.tsファイルに含まれます。生成されたコメントは、
Storybookなどの自動ドキュメントツールで使用できますreact-storybook-exampleを参照)。

ヘルパーユーティリティ

プラグインの実行時に、プロパティをクラス名に変換するためのユーティリティが生成されます。

デフォルトでは、プラグインはsrc/utilsディレクトリを探し、ディレクトリが存在しない場合は自動的に作成します。すべてのディレクトリが存在する場合、styleProps.tsファイルが作成されます。
ファイルの内容は、プラグインの設定に従って自動的に生成されます。デフォルトでは、ファイルの内容が期待値と一致しない場合、プラグインはファイルの内容を上書きします。

ユーティリティの使用例:

import {getClassNames} from "@/utils/styleProps";

import styles from "./button.module.pcss";
import type {StyleProps} from "./button.module.pcss";

const classes = getClassNames<StyleProps>({
    props: {
        variant: props.variant,
        isLoading: props.isLoading,
    },
    styles,
});

結果

postcss-style-props は CSS の動的型付けの問題を解決します。
拡張構文を使用することで、class-variance-authority と同様の機能を実現できます。CSS クラスの変更は型にも反映されるため、Single Source of Truth を維持できます。

さらに、個別のスタイルファイルはデザイナーとの協業を容易にします。デザイナーはスタイルを直接編集したり、様々なデザインバリエーションにコメントを追加したりできます。

このプラグインが皆様のお役に立ち、改善にご協力いただければ幸いです。

💻 良いコーディングを!

Sun* Developers

Discussion