Zero-Runtime CSS-in-JSのライブラリを作った
https://github.com/tkamenoko/casablanca-css
Vite で動く CSS-in-JS のライブラリを作った。併せてこのサイトも casablanca でスタイルを書き直した。使い方などはリンク先を参照してもらうとして、ここでは開発の背景等を説明したい。
なぜ作ったのか
「Vite で動く・設定が楽・string 記法が使える」を満たすライブラリが欲しかった。昨今はあちこちのフレームワークなどで Tailwind が推されているが、どうにも自分の好みには合わなかった。メディアクエリなどが書きにくいし、詳細にスタイルを書こうとすると結局素の CSS に近づいてしまう。コンセプトは分からなくはないが、進んで使う気にはならなかった。
他にも、有名な Zero-runtime でいえば Vanilla-extract や Panda CSS がある。これらはスタイリングに object 記法を採用している。object 記法は型安全にスタイルを記述可能で、ハイライトなどの為に追加のエディタ拡張を必要としない、といった点で string 記法よりも優れている。その一方で、プロパティ名が標準とは異なっていたり、複雑なセレクタは結局素の文字列で表現したりと、独自の記法の為にスタイルの記述がやや回りくどくなってしまう。Tailwind ほどの違和感はないが、やはり CSS は CSS のまま表現をしたい。Tagged template を用いる string 記法であれば CSS-in-JS と限りなく素のままの CSS を共存できる。
そんな Tagged template の Zero-runtime ライブラリといえば Linaria がある。この分野では先駆者といえるライブラリで人気・知名度共に群を抜いている。これを採用できればよかったのだが、いくらか見逃せない欠点があることは否めない。まず、導入方法がやや複雑化している点だ。多様な環境に対応するためなのだろうが、最低限必要なパッケージだけでも数種類あって、過不足ない選択は初見ではまずわからないだろう。さらに、Babel のセットアップも他のビルドプロセスとどのように共存させればよいのか簡単には判別できない。さらにその複雑なセットアップ故なのか、特定の環境で動作しないこともある。少なくとも、執筆時点における Rakkas の最新版である 0.7.0-next.49 では上手く動作しなかった。
欲しい物が見当たらないのであれば自力で作るしかない。しかし、前述の Zero-Runtime CSS-in-JS ライブラリたちはそれなりの開発規模によってその機能を実現している。果たして自分一人で実現できるものだろうか?いや、すべてを自分で実装する必要はない。ここは「巨人の肩」を大いに活用させていただこう。
どう動くのか
Zero runtime CSS-in-JS は以下の流れで実現されている。
- JS コードからスタイル部分を抽出
- 抽出したスタイルを評価
- クラス名を生成し、CSS に変換
- JS のスタイル部分をクラス名に置換
これらに加えて Composition 対応・JSX への対応・適切な CSS ファイル名の生成といった機能も実用性のためには必要となる。一見するとどれも面倒に思えるが、実は Vite の機能を活用すれば自力で実装する範囲はごく一部で済む。即ち、CSS Modules や Virtual Modules、さらには組み込みのトランスパイル機能や最適化に至るまで、可能な限りのことを Vite に任せることが可能なのだ。
クラス名を生成する際、CSS Modules を利用すればファイル内でのみ名前が衝突しないようにすればよく、グローバルでの固有なクラス名はビルド時に自動生成される。さらに、このクラス名に JS の const 宣言による変数名を利用すれば、JS 側の制約によって固有のクラス名が自動的に実現できる。JS 側に割り当てるクラス名は生成された*.module.cssファイルからインポートしてstyles["className"]のようにすれば問題ない。これでやるべきことが一つ減らせた。
CSS ファイルを生成する際、ファイル名を適切に選ばなければ何らかのコンフリクトを起こしてしまうかもしれない。そこで、Prefix 付きの Virtual Modules を生成すればこの悩みを解決できる。Prefix+対象ファイルパス+.module.cssという一貫した形でユニークな CSS ファイル名を実現できる。
Vite は様々なファイル形式に標準で対応している。TypeScript や JSX がその代表格である。この機能のおかげで、スタイルの評価時には純粋な JS のことだけを考えることができるようになる。styled API だけはやや特殊で、JSX の変換前にコードの変更を行う必要がある。といっても、実際には対応するスタイル部分を styled API から css タグによるスタイル宣言に置き換えているだけである。これによって、JSX の styled API を他のスタイル宣言と区別なく扱えるようになる。
つまり、casablanca がやっているのは評価用にファイル変換 → CSS の評価 → 仮想モジュールの生成 → 各種変数への割り当て、という部分だけである。その他面倒な部分、つまりクラス名の生成だとか最適化だとかはすべて Vite に任せている。
ファイルの変換には Babel を用いている。この変換の過程でどこでスタイルの宣言をしているか・変数名はなにか・Composition で依存しているものはあるか、といったことを収集し、最終的なビルド結果に利用している。評価の準備 → 評価 → 割り当て、という順になる都合上、Babel による変換は複数回に分けて行われている。これはパフォーマンス上の問題になり得るが、現状では HMR を実行しても遅延が気にならない程度の速度を実現できている。どうしても問題があれば Rust 製のツールに移行することはあるかもしれない。
モジュールの評価には基本的にnode:vmのvm.Moduleを使用しているが、一部モジュールの解決には動的インポートや Vite 経由での評価を行っている。意外なことに、vm.Moduleは自力では import の依存解決を一切行わない。また、Vite 固有の機能に依存するファイルなども存在しており、これら Node.js 標準外の動作もvm.Moduleは当然対応していない。よって、ここだけは自力で実装を行う必要があった。
今できないこと
「不完全な ESM パッケージ」というべきものが存在する。デュアルパッケージ化を package.json の module フィールドのみで実装し、exports を使用していない場合である。しかも、これらのパッケージは*.esm.jsなどのような非標準の拡張子を用いていることが多く、それによってこのようなパッケージはvm.Moduleにおいて正常に解決されない、という事態が発生してしまう。ESM ファイルを commonjs だと誤認してしまうのだ(誤認というよりはパッケージが非標準の記法を用いていることが根本原因だれけど)。
有名どころでもこのようなパッケージングは罷り通っている。これは歴史的事情によるもので、Webpackなどのバンドラによる独自実装を現在でも変わらず引き継いでいる結果、現在でも不完全な ESM パッケージが残ってしまっている。実際のところ、大抵のバンドラは後発だとしても実用上この独自実装に対応しており、普段は全く意識することなく扱えてしまう。さすがにこのままでは業務には使えないので、近く評価部分を別パッケージに切り出し、対策を行う予定。