📖

CSS Modulesを型安全にする仕組み

2023/11/07に公開

CSS Modulesのつらみ

styled-componentsなどのCSS-in-JS系ライブラリからCSS Modulesに移行すると、クラス名の補完が効かないことでフラァストレーション⚡️を感じることはありませんか?
私はめちゃめちゃありました。

そこで私のチームではtyped-css-modulesというライブラリを使用して、
CSSファイルから自動でクラス名の型を生成し安寧を手に入れています。

そんな便利なtyped-css-modulesについてコードリーディングし仕組みを追っていこう!というのがこの記事の目的です。

typed-css-modulesの使い方

- src/
    | myStyle.module.css

上記のようなファイル構成のプロジェクトがあり、myStyle.module.cssの中身は以下のようになっているとします。

myStyle.module.css
.foo-bar {
  margin-top: 10px;
}

typed-css-modulesをインストールした状態でtcm(typed-css-modulesの略)コマンドを実行します。

tcm src

実行後型ファイルが生成されます。

- src/
    | myStyle.css
    | myStyle.css.d.ts [生成された型ファイル]
myStyle.css.d.ts
declare const styles: {
  readonly "foo-bar": string;
};
export = styles;

この型ファイルのおかげで、CSSファイルをstylesという名前でdefault importすることで型による補完やチェックが効くようになります。

import styles from './myStyle.css'

export const Component: React.FC = () => {
  return (
	<div className={styles['foo-bar'] }> ...
	                                             //↑補完が効く!
  );
};


存在しないクラスの参照ももちろん型エラーに!!最高!!

typed-css-modules内部で何が起こっているのか

型生成の仕組みを実現するために、下記の流れで処理を行っていきます。

  1. module.cssファイルを全て精査する
  2. module.cssファイル内にあるクラスを抽出する
  3. 抽出したクラス名を元に型ファイルを生成する

1. module.cssファイルを全て精査する

https://github.com/Quramy/typed-css-modules/blob/a09e0ab64238725cc90ccfa6c12bd0febc52fb25/src/run.ts#L64-L65

globでパターンに合うファイルを全て取得し、writeFileという関数に渡しています。
特に不思議な箇所はありません。

2. module.cssファイル内にあるクラスを抽出する

一番複雑な処理ですが、css-modules-loader-coreという外部パッケージの処理をコピーして実装されています。

https://github.com/Quramy/typed-css-modules/blob/a09e0ab64238725cc90ccfa6c12bd0febc52fb25/src/css-modules-loader-core/index.js#L1-L2

css-modules-loader-coreの関数のインターフェースは次のとおりです。

core.load( sourceString , sourcePath , pathFetcher ) =>
Promise({ injectableSource, exportTokens })

このPromiseでラップされているexportTokensの戻り値は以下のようになっています。

exportTokens {
  'appForm-fieldLabel-title': '_applications_flow_src_features_AppForm_Canvas_Field_FieldLabel_FieldLabel_module__appForm-fieldLabel-title',
  'appForm-fieldLabel-required': '_applications_flow_src_features_AppForm_Canvas_Field_FieldLabel_FieldLabel_module__appForm-fieldLabel-required'
}

このオブジェクトのキーを取得するとこでクラス名を抽出できることがわかります。

https://github.com/Quramy/typed-css-modules/blob/a09e0ab64238725cc90ccfa6c12bd0febc52fb25/src/file-system-loader.ts#L72-L77

https://github.com/Quramy/typed-css-modules/blob/a09e0ab64238725cc90ccfa6c12bd0febc52fb25/src/file-system-loader.ts#L98-L102

https://github.com/Quramy/typed-css-modules/blob/a09e0ab64238725cc90ccfa6c12bd0febc52fb25/src/dts-creator.ts#L59

3. 抽出したクラス名を元に型ファイルを生成する

https://github.com/Quramy/typed-css-modules/blob/a09e0ab64238725cc90ccfa6c12bd0febc52fb25/src/dts-content.ts#L144-L153

rawTokenListはcss-modules-loader-coreから取得したクラス名の配列です。
これらをmapして
readonly "foo-bar": string; といったresultを生成していきます。

https://github.com/Quramy/typed-css-modules/blob/a09e0ab64238725cc90ccfa6c12bd0febc52fb25/src/dts-content.ts#L65-L79

stylesの型定義と先程生成したresultを並べていきます。
これで型定義ファイルの一丁あがりです🍜

myStyle.css.d.ts
declare const styles: {
readonly "foo-bar": string; // result
};
export = styles;

css-modules-loader-coreの仕組み

css-modules-loader-coreにメインの実装があります。以上!
…だとちょっと寂しい気もするので、css-modules-loader-coreについても軽くコードリーディングしてみたいと思います。

以下はcss-modules-loader-coreの記述です。
https://github.com/css-modules/css-modules-loader-core/blob/3d8377003ea87f8a32d094b36b1d7bd2abba360f/src/index.js#L17-L21

exportTokensにクラス名のキーが入っているのですが、postcssのプラグインを登録することでexportTokensを取得しています。

https://postcss.org/api/ でpostcssのインターフェースを確認すると、第一引数にAcceptedPlugin[]を受け取れるようです。

https://github.com/css-modules/css-modules-loader-core/blob/3d8377003ea87f8a32d094b36b1d7bd2abba360f/src/parser.js#L13-L17
コールバックの第一引数にcssのノードが格納されるので、ここからクラス名を取得できます。

https://github.com/css-modules/css-modules-loader-core/blob/3d8377003ea87f8a32d094b36b1d7bd2abba360f/src/parser.js#L39-L48

まとめ

typed-css-modulesの内部ではcss-modules-loader-coreの機能を用いてクラス名を取得していました。
css-modules-loader-coreはpostcssのプラグインという形で実装されており、pluginのコールバックの第一引数からクラス名を取得しています。

もしクラス名を羅列・取得したい機会があれば同じ方法で実装できそうですね🏋️‍♀️

おまけ:型ファイルの削除について

実はtyped-css-modulesの仕組みだとwatchでcssファイルが削除されても型ファイルが残ってしまいます。(実はこれを解決したくてtyped-css-modulesのコードリーディングを始めました。)

typed-css-modulesのwatchの実装にはchokidarchokidar.watch()が使われているので、下記のようにファイルが削除された時用のhookを作り、紐づく型ファイルを削除することができます。

run.ts
    const watcher = chokidar.watch([filesPattern.replace(/\\/g, '/')]);
    watcher.on('add', writeFile);
    watcher.on('change', writeFile);
    watcher.on('unlink', deleteFile);

PRを作ったので、もし全貌に興味のある方はみてください。
https://github.com/Quramy/typed-css-modules/pull/237

参考

調査のためのスクラップ

サイボウズ フロントエンド

Discussion