Next.js+TypeScript+CSS Modules+SCSS環境(2022.01)
Next.jsでCSS Modules(SCSS)を使ってTypeScriptの補完も利かせたい。
参考にした:
まずはTypeScriptフラグをつけてcreate-next-app
$ npx create-next-app@latest example --ts
$ cd example
Next.jsのドキュメントにある通り、sass
をインストールすればCSS ModulesでSCSSを使えるようになる
$ npm install sass --save-dev
ディレクトリ構成はとりあえず以下のようにしている。
-
src/styles
以下にグローバルなSCSSを配置- アプリ全体で読み込む
global.scss
- 色やサイズ等の変数(デザイントークン)定義
- 各種mixin
- アプリ全体で読み込む
- 各コンポーネントのSCSSは各コンポーネントのファイルと同じフォルダに配置
グローバルなSCSSや定義のインポートをしやすくするため、tsconfig.json
にエイリアスを定義
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@styles/*": ["./src/styles/*"]
}
}
}
以上でCSS Modules + SCSSは使えるようになるが、これだけだと.tsx
上でクラス名に補完が効かない。
以下のスクリーンショットのように「SCSSファイル内に存在しているクラス名が候補に出る」状態にしたい。
typed-scss-modulesをインストール
$ npm install -D typed-scss-modules
このスクラップのこのコメントによると、以下の設定が必要らしい。
Next.js で page として扱われるファイルの拡張子を変更する
module.exports = {
pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
};
それに合わせて各ページのファイルも_app.page.tsx
などにリネーム。
typed-scss-modules
はSCSSを元にしてクラス名をエクスポートした.d.ts
を生成する。
.tsx
でimport styles from '@styles/Home.module.scss';
としたとき、@styles/Home.module.scss.d.ts
の定義が読まれるため、クラス名が補完されるようになる。
.d.ts
を自動生成するよう、watch
タスクを作成。
{
"scripts": {
"tsm": "tsm src --watch --implementation sass --nameFormat none --exportType default --aliasPrefixes.@ src/"
}
}
-
src
: 監視対象ディレクトリ。 -
--implementation sass
: Dart SASSを使うことを明示。 -
--nameFormat none --exportType default
: CSSのクラス名をどう変換するかの設定。some-class
とかsome_class
とかsomeClass
とか。ここでは「何の変換もしない」設定にしているため、SCSS内でsome-class
のような名前を付けたら.tsx
ではstyles['some-class']
のように指定することになる。 -
--aliasPrefixes.@ src/
: エイリアスを設定する。今回は@styles/
がsrc/styles/
になってほしいので、@
がsrc/
であると指定。
ウォッチタスクを起動させておけば、SCSSファイルの変更を感知して.scss.d.ts
が生成される。
$ npm run tsm
運用上、ファイル名を以下のようにすることにした:
- コンポーネント専用のSCSSファイルは
コンポーネント名.module.scss
- 生成される定義ファイルは
コンポーネント名.module.scss.d.ts
- 生成される定義ファイルは
- mixin定義や変数定義は
_ファイル名.d.scss
- SCSSの慣習として、アンダースコアで始まるファイル名は「CSSを生成しない」ことを表す
- それに加えてTypeScript式の
d
をつけて、「定義のみである」ことを明示してみた
- その他グローバルなSCSSファイルは
ファイル名.scss
- コンポーネントから読み込まないので定義ファイルは生成しない
生成された.scss.d.ts
はバージョン管理不要なので.gitignore
に追記しておく。
# generated typed SCSS
*.module.scss.d.ts
Dart SASS で実装済みの@use
の使い勝手が非常に良い。
// @useでインポートする
@use "@styles/tokens.d/alias/basic.d";
@use "@styles/tokens.d/core/typography.d";
body {
// 変数に名前空間があるかのように使える。
// また、ファイル名の .d は無視してくれるようだ。
color: basic.$text-color;
@include typography.basic-font-family();
@include typography.line-height(middle);
}
// $- で始まる変数はプライベート変数となり、
// @useでインポートした先からは見えない
$-line-heights: (
tall: 1.7,
middle: 1.5,
short: 1.3,
);
// @useで名前空間のようなことができるので
// 名前の衝突を恐れずにmixinをエクスポートできる
@mixin line-height($size) {
line-height: #{map-get($-line-heights, $size)};
}
さらに一歩進められるなら……
前述の_typography.d.scss
からこういう定義が生成できるといいかも……?
const LineHeights = [
'tall',
'middle',
'short',
] as const;
export type LineHeights = typeof LineHeights[number];
declare function lineHeight(size: LineHeights): void;
私が必要としているのは CSS in JS ならぬ TS to SCSS (?) なのかもしれない……
各所で言われている CSS Modules が deprecated になりそうだという件 について
- CSS Modulesという技術そのものがdeprecatedになるという話ではない
-
webpackのcss-loaderはCSS Modules対応をdeprecatedにしたいっぽい
- あまり詳しくないんだけど、現状一番多く使われているCSS Modulesの実装はこれなのかな?
- Next.jsではwebpackのcss-loaderを使わず独自実装していて、CSS Modulesのサポートを止めるつもりはないらしい
- つまり、Next.js上で使ってるなら、webpackのcss-loaderがdeprecatedになろうが気にしなくていいんじゃない?というのが私の結論
そもそも、どうしてwebpackのcss-loaderチームがCSS Modulesをdeprecatedにしたいのかよくわからないが……あまり使われてないのかな?
CSS ModulesはCSS in JSに対して「既存のCSS/SCSS資産をそのまま使える」「パフォーマンスが良い」「プレーンなCSS/SCSSなので取り回しがよい」「CSSの進化に追従しやすい」というメリットがあるので、なぜ不人気(?)なのかちょっとわからない。
WAI-ARIA対応まで考える場合の参考資料メモ:
調査課題:
(そもそも、そこを完全に分ける必要があるのかなぁ……?)
試しにやってみた構成
└─components
└─Example
├─view.tsx (1)
├─style.module.scss (2)
└─index.tsx (3)
簡単に説明すると:
(1) view.tsx
でDOMを定義。
(2) style.module.scss
でCSS(SCSS)を記述。
(3) index.tsx
で(1)(2)を統合してエクスポート。
view.tsx
(1) export type ExampleViewProps = {
text: string;
className?: string;
};
const ExampleView: React.VFC<ExampleViewProps> = ({ text, className }) => {
return (
<span className={className || ''}>{text}</span>
);
};
export default ExampleView;
ここにはロジックはほぼ書かない。propsに応じて表示するものを分けたりする程度。ほとんどピュアなDOM。
ポイント:ルート要素に対するclassName
を受け付けるようにしておく。
感想:(まだテストはほとんどやってないけど)見るからにテストしやすそうな感じになった。
style.module.scss
(2) .examplecontainer {
color: red;
}
CSS Modules用のSCSSファイル。ただのSCSS。今回は使っていないが、@mixin
や@use
を使える。
ポイント:view.tsx
のルート要素に対する適当なclassをひとつ用意し、あとは子セレクタ>
を使ってスタイルを指定していくことにした。
感想:ふつうのSCSS。
index.tsx
(3) import ExampleView, { ExampleViewProps } from './view';
import styles from './style.module.scss';
export type ExampleProps = Omit<ExampleViewProps, 'className'>;
const Example: React.VFC<ExampleProps> = (props) => {
return <ExampleView className={styles.examplecontainer} {...props} />;
};
export default Example;
(1)と(2)をインポートし、スタイルを適用したあとエクスポートする。ロジック(hooksを使うところ)もここに書く。
ポイント:外部に公開するPropsからはclassName
は除いている。
感想:このくらいの例だと冗長に見えるけど、実際にロジックを書くとちょうどいい分量に思える。
スタイルの指定方法について
今回の構成では、view.tsx
はルートの要素に対するclassName
をひとつだけ受け取るようにした。
- それゆえに、SCSSを書くときには子セレクタを駆使することになるので、ちょっと面倒かも。
- 複雑で大きなコンポーネントの場合、いくつかの
className
を受け取るようにしてもいいかもしれない。- どうせstyleはviewのDOM構造を知らないと書けないのだから、影響範囲がstyle - view間に限定されるなら、スタイリングしやすいように融通をきかせてもいいと思う
- 渡すべき
className
をXXXViewProps
にきちんと明記しておけば、className
が増減したときTypeScriptの型検査でエラーになってくれる -
typed-scss-modules
のおかげでstyleに実在しないclassName
を渡してしまうこともない - ただし「使用されていない
className
」は検知できない- PostCSSとかで後からできるかなぁ?
view.tsx
とstyle.module.scss
の依存関係は一方通行なので、一応「同じviewに別のstyleを適用したバリエーションコンポーネント」等も作れる。
例えば以下のような構成にして、共通スタイルは_common.scss
に書き、outline.module.scss
やfilled.module.scss
から@use
して、index.tsx
ではpropsに応じてスタイルを付け替える。
└─Example
├─view.tsx
├─_common.scss
├─outline.module.scss
├─filled.module.scss
└─index.tsx
import outline from './outline.module.scss';
import filled from './filled.module.scss';
const Example: React.VFC<ExampleProps> = (props) => {
return (<ExampleView className={
props.outline
? outline.wrapper
: filled.wrapper
} {...props} />);
};
今後の検証課題:
typed-scss-modules
を使って*.modules.scss.d.ts
を生成しているわけだから、className
はすべてTypeScriptの世界で管理できるはず。
逆にview.tsx
のprops内で、生成された*.modules.scss.d.ts
の情報を元に、渡すべきclassName
のリストを生成できれば、型安全になる……?
書き忘れたけど、(3)をindex.tsx
にしたのは、コンポーネントを使うときに
import Example from '@component/Example';
みたいにするため。
(1)や(2)のファイル名はなんでもいいけど、コンポーネント名のフォルダ内に配置するので、シンプルな名前でいいんじゃないかと思っている。