mizdra's blog

ぽよぐらみんぐ

コードジャンプ可能な CSS Modules を実現する happy-css-modules の紹介

弊社では React で CSS を書くための手法として CSS Modules を全面的に採用しています。そこで CSS Modules を使った開発をより快適にするために、「happy-css-modules」というツールを作りました。

TSX ファイルから CSS Modules のクラス名を Command+Click して、.module.css ファイルの定義場所にコードジャンプしている様子の動画です。
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ファイルにジャンプしてしまいます。

typed-css-modules では、TSX ファイルから CSS Modules のクラス名を Command+Click すると.module.css.d.ts にコードジャンプしてしまう様子の動画。
typed-css-modules では .module.css.d.ts にコードジャンプしてしまう。

些細な問題のように感じますが、デザインコーディングをする時は、TSX と.module.cssの間を行ったり来たりすることが多いので、少し不便だなと感じていました。

そもそも.module.css.d.tsにジャンプしても、そこから得られるものはほぼ無いです! せっかくならもう少し有益なものへとジャンプさせて、開発体験の向上を図りたい。id:mizdra はそう考えて、この問題を解決するツールを作ることにしました。

happy-css-modules の紹介

そして作ったものが、冒頭で紹介した「happy-css-modules」です。

TSX ファイルから CSS Modules のクラス名を Command+Click して、.module.css ファイルの定義場所にコードジャンプしている様子の動画です。冒頭に掲載した動画と同じものです。
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側に直接ジャンプできます。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

Source Map を駆使してジャンプ先を .module.css に切り替える仕組み。Source Map の内容は分かりやすいよう、実際のものから簡略化してます。

同名のクラスが複数ある場合は、ちょっとしたハックを利用してます。というのも、Source Map の仕様上、.module.css.d.ts 上の1つの位置情報 <=> .module.css 上の複数の位置情報の対応を持たせられません (generated:original = 1:1 や 多:1 はできるけど、1:多 は不可能)。そこで、同名のクラス名の型をオブジェクトリテラル型で複数定義しつつ、Intersection Types (a & b) で合成したものを styles の型とし、それぞれのオブジェクトリテラルごとに別々の .module.css 上のクラス定義へとマッピングします。これで同名のクラスが複数あっても、tsserver が複数のジャンプ先を suggest してくれます。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

同名のクラスが複数ある場合のジャンプ先切り替えの仕組み。

happy-css-modules の着想・作ろうと思ったキッカケ

実はこの型定義ファイルを generated、型定義を生成する元になったファイルを original とする Source Map を作成するアイデアは、tsc (TypeScript コンパイラ) が --declarationMap として先に実装していて、Declaration Map と呼ばれてます。

id:mizdra が初めてこの技術を知った時、「何か面白いことに使えるのではないか」と思いました。Declaration Map を使うと、本来であれば型定義ファイルにコードジャンプするところを、別の場所にジャンプするよう変更できます。つまり、型定義ファイルが何かしらのファイルから自動生成されるような状況であれば、この技術を応用できるはずーー そう考えて、happy-css-modules の着想を得ました。

というものの、CSS Modules でコードジャンプをする実現するツールは、(一部機能に違いはありますが) すでにいくつかありました。

そのため、作ったところで需要はそこまでなかったのですが...面白い技術が狙い通りに応用できるかどうしても試したかった... つまり技術的な好奇心が作ろうと思ったキッカケです。

やるからには上手くやる

せっかく車輪の再発明をするのだから作るのだから、より良いものを作ろう!*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 は以下のような実装になってます。

欲張りな目標でしたが、車輪の再発明なりに、上手くできたかなと思ってます。是非興味があればコードを読んでみてください。

まとめ

happy-css-modules を導入することで、typed-css-modules のようにミスを防ぎつつ、かつコードジャンプによって快適に開発できます。二番煎じなツールではありますが、従来のものと比較して様々なプロジェクトに簡単に導入できるようこだわって設計してます。是非導入してみてください!happy-css-modules や stylelint-no-unused-selectors を導入すれば CSS-in-JS と大差のない開発体験になりますし、これを機に CSS Modules を使ってくれる人が増えると良いなと思っています。

あわせて読みたい

developer.hatenastaff.com

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.