弊社では React で CSS を書くための手法として CSS Modules を全面的に採用しています。そこで CSS Modules を使った開発をより快適にするために、「happy-css-modules」というツールを作りました。
この記事ではこのツールが必要になった背景、導入方法、そしてツールの技術的な仕組みについて紹介します。
CSS Modules の問題点と、typed-css-modules による解決
CSS Modules では、デフォルトでは存在しないクラス名を使用しても、(プロジェクトの設定次第ですが) TypeScript のコンパイルエラーが出ることはありません。
import styles from './Button.module.css'; function Button() { return ( <button className={styles.botton}> // ^^^^^^^^^^^^^ (u を o に typo してる) // (プロジェクトが用意している型定義ファイルによるが) create-react-app で作られたプロジェクトでは // 存在しないクラス名も実在するかのように見せかける緩い型定義ファイルがあるため、 // styles.undefined_selector は string になる。そのためコンパイルエラーにもならない。 // ref: https://github.com/facebook/create-react-app/blob/f99167c014a728ec856bda14f87181d90b050813/packages/react-scripts/lib/react-app.d.ts#L58-L61 Click me! </button> ); }
そこでこの問題を解決するために、typed-css-modules というツールが存在します (sass/less 向けに、それぞれ typed-scss-modules/typed-less-modules というツールもあります)。このツールを使うと、*.module.css
ファイルに定義されているクラス名を検出し、そのクラス名にのみアクセス可能な型定義ファイルを生成できます。これにより未定義なクラス名を参照したとしても、コンパイルエラーとして検出できます。
// Button.module.css .button { border: 1px solid #333; } .text { color: red; }
// Button.module.css.d.ts // typed-css-modules により自動生成される型定義ファイル declare const styles: { readonly button: string; readonly text: string; }; export = styles;
// Button.tsx import styles from './Button.module.css'; function Button() { return ( <button className={styles.botton}> // ^^^^^^^^^^^^^ (u を o に typo してる) // styles の型には botton プロパティが生えていないので、styles.botton はコンパイルエラーになる。 Click me! </button> ); }
よくあるミスを防げるツールなので、CSS Modules を使用するプロジェクトでは一緒に導入していることが多いと思います。
無意味なコードジャンプ
ただし、typed-css-modules ではクラス名で定義元ジャンプをしようとしても、.module.css
ファイルではなく、.module.css.d.ts
ファイルにジャンプしてしまいます。
些細な問題のように感じますが、デザインコーディングをする時は、TSX と.module.css
の間を行ったり来たりすることが多いので、少し不便だなと感じていました。
そもそも.module.css.d.ts
にジャンプしても、そこから得られるものはほぼ無いです! せっかくならもう少し有益なものへとジャンプさせて、開発体験の向上を図りたい。id:mizdra はそう考えて、この問題を解決するツールを作ることにしました。
happy-css-modules の紹介
そして作ったものが、冒頭で紹介した「happy-css-modules」です。
CSS Modules から import したクラス名を Meta+Click (Mac なら Command+Click、Windows なら Control+Click) で、CSS Modules 側の定義場所へとコードジャンプできます。
導入方法
GitHub の Usage 通りにやってもらえたら導入できると思います。
幅広いユーザのサポートを第一に設計しているため、PostCSS/Sass/Less を使っているプロジェクトにもデフォルトで対応しています。Webpack のresolve.alias
や、Sass の--load-path
オプションを使っているプロジェクトにも、CLI オプションを渡すことで対応できます。
社内では Less/Sass プロジェクトにそれぞれ導入済みで、問題なく動作してます。もし動かないなどあれば https://github.com/mizdra/happy-css-modules/issues から報告してもらえるとありがたいです。
ツールを使いたい方向けの説明はここまでで、以降は happy-css-modules の技術的な解説や、実装の裏側についての話です。
どうやってコードジャンプを実現しているのか
happy-css-modules は .module.css.d.ts
を生成するのに加えて、.module.css.d.ts.map
ファイルも生成しています。このファイルはgenerated (.module.css.d.ts
) <=> source (.module.css
) 間のコードの対応を保持している Source Map です。tsserver (VSCode 向けの TypeScript の Language Server) が.module.css.d.ts
上のコードにジャンプしようとしたときに、この Source Map を元に.module.css
上のコードにマップし直して.module.css
側に直接ジャンプできます。
同名のクラスが複数ある場合は、ちょっとしたハックを利用してます。というのも、Source Map の仕様上、.module.css.d.ts
上の1つの位置情報 <=> .module.css
上の複数の位置情報の対応を持たせられません (generated:original = 1:1 や 多:1 はできるけど、1:多 は不可能)。そこで、同名のクラス名の型をオブジェクトリテラル型で複数定義しつつ、Intersection Types (a & b
) で合成したものを styles
の型とし、それぞれのオブジェクトリテラルごとに別々の .module.css 上のクラス定義へとマッピングします。これで同名のクラスが複数あっても、tsserver が複数のジャンプ先を suggest してくれます。
happy-css-modules の着想・作ろうと思ったキッカケ
実はこの型定義ファイルを generated、型定義を生成する元になったファイルを original とする Source Map を作成するアイデアは、tsc (TypeScript コンパイラ) が --declarationMap
として先に実装していて、Declaration Map と呼ばれてます。
id:mizdra が初めてこの技術を知った時、「何か面白いことに使えるのではないか」と思いました。Declaration Map を使うと、本来であれば型定義ファイルにコードジャンプするところを、別の場所にジャンプするよう変更できます。つまり、型定義ファイルが何かしらのファイルから自動生成されるような状況であれば、この技術を応用できるはずーー そう考えて、happy-css-modules の着想を得ました。
というものの、CSS Modules でコードジャンプをする実現するツールは、(一部機能に違いはありますが) すでにいくつかありました。
- typescript-plugin-css-modules
- TypeScript Compiler Plugin による実装なので、セットアップが少し複雑
- Sass はサポートされているが、Less は未サポート
- clinyong/vscode-css-modules
- Sass/Less や import alias もサポートしているので、機能的にはほとんどのユースケースに答えられるはず
- VSCode 拡張機能なので導入が簡単だが、VSCode でしか使えない
- Viijay-Kr/react-ts-css
- サポートしているセレクターの種類が限られている
- Less は現時点で未サポート
- VSCode 拡張機能なので導入が簡単だが、VSCode でしか使えない
そのため、作ったところで需要はそこまでなかったのですが...面白い技術が狙い通りに応用できるかどうしても試したかった... つまり技術的な好奇心が作ろうと思ったキッカケです。
やるからには上手くやる
せっかく車輪の再発明をするのだから作るのだから、より良いものを作ろう!*1と考えて、以下の目標を置きました。
- 任意の AltJS をサポートする
- どうせやるなら全部サポートできるようにしたい
- 便利な機能を全部の言語に提供できたら、最高のはず
- typed-css-modules/typed-scss-modules/typed-less-modules をまとめて置き換えられると良い
- Webpack 互換の import specifier (
@import "....";
の"..."
の部分) の解決アルゴリズムにできるだけ準拠する- Webpack で CSS を bundle しているユーザが、簡単に導入できるようにしたい
- import alias もサポートする
@ import "@style-dir/global.css";
のように alias を使っているユーザもサポートしたい
- とにかく使いやすくする
- 設定ファイル無し、CLI オプションも極力無しで、可能な限り簡単に使えるものを目指す
- 拡張性を持たせる
- プログラマブルな API を提供して、いざとなったら "なんでもできる" ようにしたい
- テストを充実させる
- 趣味プロダクトはたまにしか触らないので、新機能の実装やバグの修正をするにしても、プロダクト壊してしまわないか、ついつい不安になってしまう
- テストが充実していれば、コードの変更も気軽に自信を持ってできるようになり、メンテナンスもしやすくなるはず
そういう訳で happy-css-modules は以下のような実装になってます。
- Transformer という層を用意して、そこで AltJS => CSS への変換をするように
- https://github.com/mizdra/happy-css-modules/tree/v1.0.0/src/transformer
- 組み込みで PostCSS/Sass/Less の transformer を実装してある
- Resolver という層で import specifier の解決をするように
- https://github.com/mizdra/happy-css-modules/tree/v1.0.0/src/resolver
- 組み込みで Webpack 互換の resolver を実装してある
- Node.js 向けの API を提供
- https://github.com/mizdra/happy-css-modules/tree/v1.0.0#nodejs-api-experimental
- カスタムコマンドを実装できたり、Transformer や Resolver をカスタマイズできたり
- シンプルな CLI オプション
- https://github.com/mizdra/happy-css-modules/tree/v1.0.0#usage
- 極力 CLI オプション無しでも動くように
- import alias を使いたい時など、凝ったカスタマイズをしたい時だけ CLI オプションを渡す形に
- 充実したテスト
- ほとんどの層に対してテストを書いてる
- fixture などをインラインに書くことで、テストファイルを読んだだけで何をしているか分かるように
- https://github.com/mizdra/happy-css-modules/blob/v1.0.0/src/locator/index.test.ts#L27-L54
- インラインにしても読みやすいように、
createFixtures
やdedent
といった utility を整備したり、snapshot のフォーマットの仕方をデフォルトのものから変えたりと、結構こだわってる
- 本物の tsserver を使って、正しい位置にコードジャンプするかのテストもしてる
欲張りな目標でしたが、車輪の再発明なりに、上手くできたかなと思ってます。是非興味があればコードを読んでみてください。
まとめ
happy-css-modules を導入することで、typed-css-modules のようにミスを防ぎつつ、かつコードジャンプによって快適に開発できます。二番煎じなツールではありますが、従来のものと比較して様々なプロジェクトに簡単に導入できるようこだわって設計してます。是非導入してみてください!happy-css-modules や stylelint-no-unused-selectors を導入すれば CSS-in-JS と大差のない開発体験になりますし、これを機に CSS Modules を使ってくれる人が増えると良いなと思っています。
あわせて読みたい
*1:趣味で創作する時は常に何かしら新しいことに挑戦する - mizdra's blog に通ずる話かもしれません