Open18

Next.js+TypeScript+CSS Modules+SCSS環境(2022.01)

noonworksnoonworks

Next.jsでCSS Modules(SCSS)を使ってTypeScriptの補完も利かせたい。

noonworksnoonworks

まずは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にエイリアスを定義

tsconfig.json(抜粋)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@styles/*": ["./src/styles/*"]
    }
  }
}

以上でCSS Modules + SCSSは使えるようになるが、これだけだと.tsx上でクラス名に補完が効かない。

noonworksnoonworks

以下のスクリーンショットのように「SCSSファイル内に存在しているクラス名が候補に出る」状態にしたい。

noonworksnoonworks

typed-scss-modulesをインストール

$ npm install -D typed-scss-modules

このスクラップのこのコメントによると、以下の設定が必要らしい。

Next.js で page として扱われるファイルの拡張子を変更する

next.config.js(抜粋)
module.exports = {
  pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
};

それに合わせて各ページのファイルも_app.page.tsxなどにリネーム。

typed-scss-modulesはSCSSを元にしてクラス名をエクスポートした.d.tsを生成する。
.tsximport styles from '@styles/Home.module.scss';としたとき、@styles/Home.module.scss.d.tsの定義が読まれるため、クラス名が補完されるようになる。

noonworksnoonworks

.d.tsを自動生成するよう、watchタスクを作成。

package.json(抜粋)
{
  "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に追記しておく。

.gitignore
# generated typed SCSS
*.module.scss.d.ts
noonworksnoonworks

Dart SASS で実装済みの@useの使い勝手が非常に良い。

global.scss
// @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);
}
_typography.d.scss
// $- で始まる変数はプライベート変数となり、
// @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)};
}
noonworksnoonworks

さらに一歩進められるなら……
前述の_typography.d.scssからこういう定義が生成できるといいかも……?

_typography.d.scss.d.ts(妄想)
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 (?) なのかもしれない……

noonworksnoonworks

各所で言われている CSS Modules が deprecated になりそうだという件 について

そもそも、どうしてwebpackのcss-loaderチームがCSS Modulesをdeprecatedにしたいのかよくわからないが……あまり使われてないのかな?
CSS ModulesはCSS in JSに対して「既存のCSS/SCSS資産をそのまま使える」「パフォーマンスが良い」「プレーンなCSS/SCSSなので取り回しがよい」「CSSの進化に追従しやすい」というメリットがあるので、なぜ不人気(?)なのかちょっとわからない。

noonworksnoonworks

調査課題:
https://qiita.com/Takepepe/items/41e3e7a2f612d7eb094a
上記記事のような、Pure DOM層とStyle層を分けるような実装は、CSS Modulesでも可能なのだろうか?
(そもそも、そこを完全に分ける必要があるのかなぁ……?)

noonworksnoonworks

試しにやってみた構成

└─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)を統合してエクスポート。

noonworksnoonworks

(1) view.tsx

view.tsx
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を受け付けるようにしておく。
感想:(まだテストはほとんどやってないけど)見るからにテストしやすそうな感じになった。

noonworksnoonworks

(2) style.module.scss

style.module.scss
.examplecontainer {
  color: red;
}

CSS Modules用のSCSSファイル。ただのSCSS。今回は使っていないが、@mixin@useを使える。
ポイント:view.tsxのルート要素に対する適当なclassをひとつ用意し、あとは子セレクタ>を使ってスタイルを指定していくことにした。
感想:ふつうのSCSS。

noonworksnoonworks

(3) index.tsx

index.tsx
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は除いている。
感想:このくらいの例だと冗長に見えるけど、実際にロジックを書くとちょうどいい分量に思える。

noonworksnoonworks

スタイルの指定方法について

今回の構成では、view.tsxはルートの要素に対するclassNameをひとつだけ受け取るようにした。

  • それゆえに、SCSSを書くときには子セレクタを駆使することになるので、ちょっと面倒かも。
  • 複雑で大きなコンポーネントの場合、いくつかのclassNameを受け取るようにしてもいいかもしれない。
    • どうせstyleはviewのDOM構造を知らないと書けないのだから、影響範囲がstyle - view間に限定されるなら、スタイリングしやすいように融通をきかせてもいいと思う
    • 渡すべきclassNameXXXViewPropsにきちんと明記しておけば、classNameが増減したときTypeScriptの型検査でエラーになってくれる
    • typed-scss-modulesのおかげでstyleに実在しないclassNameを渡してしまうこともない
    • ただし「使用されていないclassName」は検知できない
      • PostCSSとかで後からできるかなぁ?

view.tsxstyle.module.scssの依存関係は一方通行なので、一応「同じviewに別のstyleを適用したバリエーションコンポーネント」等も作れる。
例えば以下のような構成にして、共通スタイルは_common.scssに書き、outline.module.scssfilled.module.scssから@useして、index.tsxではpropsに応じてスタイルを付け替える。

    └─Example
        ├─view.tsx
        ├─_common.scss
        ├─outline.module.scss
        ├─filled.module.scss
        └─index.tsx
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} />);
};
noonworksnoonworks

今後の検証課題:

typed-scss-modulesを使って*.modules.scss.d.tsを生成しているわけだから、classNameはすべてTypeScriptの世界で管理できるはず。
逆にview.tsxのprops内で、生成された*.modules.scss.d.tsの情報を元に、渡すべきclassNameのリストを生成できれば、型安全になる……?

noonworksnoonworks

書き忘れたけど、(3)をindex.tsxにしたのは、コンポーネントを使うときに

import Example from '@component/Example';

みたいにするため。

(1)や(2)のファイル名はなんでもいいけど、コンポーネント名のフォルダ内に配置するので、シンプルな名前でいいんじゃないかと思っている。