かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

CSS Modules やっていき

CSS Modules とゎ?

ざっくりいうと CSS は全部が global 定義なので、コンポーネントごとにクラス名をハッシュ化したモジュールにしてしまってスタイルの影響範囲をコンポーネントの中に閉じ込めようというもの

e.g.

/* style.css */
.logo { color: red; }
import stytles from './style.css'
'<div class="' + styles.logo + '">'

👇コンパイル

/* css */
._23_aKvs-b8bW2Vg3fwHozO { color: red; }
// 👇 import styles from './style.css' でバンドルされる中身
exports.locals = {
  logo: "_23_aKvs-b8bW2Vg3fwHozO",
};

'<div class="' + styles.logo + '">';

SCSS を CSS Modules にする最低限の webpack の設定

css-loader の module オプションを true にすれば OK

webpack.config.js

module.exports = {
  // ...
  rules: [{
    test: /\.scss$/,
    use: [
      'style-loader', // JS 内にある CSS を <head> にインラインで出力する loader
      'css-loader?modules', // CSS を JS で扱えるようにする loader
      'sass-loader',  // sass | scss の変換
    ]
  }],
}

CSS.scss ファイルとして出力する場合は mini-css-extract-plugin を使う

cf. webpack css-loader と style-loader の違いについて学んだ - かもメモ

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
  // ...
  rules: [{
    test: /\.scss$/,
    use: [
      MiniCssExtractPlugin.loader,
      'css-loader?modules',
      'sass-loader',
    ]
  }],
}

CSS Modules の Mode

CSS Modules 化で全ての CSS の指定が hash 化されてしまうと、normalize.css とか共通の utility クラスが作れなくなってしまうので、CSS Modules には hash 化される local なスタイルと共通で使える global なスタイルという概念があります。 css-loader は内部的に local なスタイルを :local(.className) {...}, global なスタイルを :global(.className) {...} という形に変換した上で local なスタイルを hash 化して最終的な CSS に変換しているようです。

SCSS では明示的に :local, :global キーワードで囲って定義してあるスタイルが local なスタイルなのか、global なスタイルなのかを表すことができます。 CSS Modules への変換方法には local, global という2つの mode があり、local スタイルと global スタイルへの変換方法が異なります。

  • local モード (default) では CSS ファイルにベタ書きしたスタイルを local なスタイルに変換する
  • global モードでは CSS ファイルにベタ書きされたスタイルを global なスタイルとして扱う

local モード

css-loader の modules オプションの mode を 'local' 又は true 又は css-loader?modules と指定する

webpack.config.js

module.exports = {
  // ...
  rules: [{
    test: /\.scss$/,
    use: [
      MiniCssExtractPlugin.loader,
      {
         loader: 'css-loader',
         options: {
           modules: {
             mode: 'local', // mode: true でもOK
           }
         }
      },
      'sass-loader',

style.scss

// ベタ書きのスタイル
.ichigo { color: red; }
// local なスタイル
:local {
  .aoi { color: blue; }
}
// global なスタイル
:global {
  .ran { color: purple; }
}

👇 コンパイル

._2EvoXuCC19CRPsPIfLzc34 { color: red; }
.yqLGVvuQreTgr2chBe--O { color: blue; }
.ran { color: purple; }

global モード

css-loader の modules オプションの mode を 'global' に指定 webpack.config.js

module.exports = {
  // ...
  rules: [{
    test: /\.scss$/,
    use: [
      MiniCssExtractPlugin.loader,
      {
         loader: 'css-loader',
         options: {
           modules: {
             mode: 'global',
           }
         }
      },
      'sass-loader',

style.scss

// ベタ書きのスタイル
.ichigo { color: red; }
// local なスタイル
:local {
  .aoi { color: blue; }
}
// global なスタイル
:global {
  .ran { color: purple; }
}

👇 コンパイル

.ichigo { color: red; }
.yqLGVvuQreTgr2chBe--O { color: blue; }
.ran { color: purple; }

JavaScriptCSS Modules を扱う

default import したオブジェクトの中に local なスタイル指定が 元のクラス名: hash化されたクラス名 という形で渡される

/* style.scss */
.ichigo { color: red; }
:local {
  .aoi { color: blue; }
}
:global {
  .ran { color: purple; }
}

👇 local モードで JS で読み込んだ場合

import styles from './style.scss';
console.log(styles);
// => { ichigo: "_2EvoXuCC19CRPsPIfLzc34", aoi: "yqLGVvuQreTgr2chBe--O" }

// global なクラス名は import されるオブジェクトには含まれない
console.log(styles.ran); // undefined

const Soleil: FC = () => (
  <>
    <p className={styles.ichogo}>星宮いちご</p>
    <p className={styles.aoi}>霧矢あおい</p>
    <p className="ran">text 紫吹蘭</p>
  </>
);

css-loader は CSS ファイル内の local なスタイルだけを exports.local = { className: "hashed_className" } という形に変換して JavaScript に渡す。

local なスタイルと global なスタイルの扱いの違い

import されるオブジェクト内に global なスタイルの指定は含まれない。
つまり、global なスタイルは CSS Modules にしても JS ファイルのサイズには影響を与えない。 逆に言えば local なスタイルの指定は JS ファイルとして読み込まれるので、使用していないスタイルを含む CSS ファイルを import した場合 不要なJSなコードが含まれてしまうので、モジュール単位で分解されていないようなCSS を読み込むと JS のサイズが肥大してしまうので CSS ファイルの分割に注意が必要になる

SCSS から JS に変数を渡したい

SCSS / Sass で作れる変数を JS に渡して使いたいようなケース $ で始まる変数はそのままでは JS ファイルに渡すことは出来ない。 :export キーワード内に $ 無しで定義すると JS 側では変数として扱うことができる

// style.scss
$widthTablet: 720px;

:export {
  widthTablet: $widthTablet
  $widthMobile: 320px;
}
import styles from './styles.scss';
console.log(styles);
// => { widthTablet: "720px" }

$ で始まる SCSS の変数は :export キーワード内に定義しても JS には渡されません。 また、 :export キーワードブロック内にスタイルを定義しても JS では扱えず、最終的にコンパイルされた CSS:export .className {...} の様な使えないスタイルが出力されてしまうので、:export キーワード内には JS に渡したい変数だけを定義するのが良さそうです。

map な変数はそのままでは渡せない

SCSS の map な変数は、そのままおオブジェクトとして JS に渡せるのかと思ったのですがエラーになるようで、 map を展開した変数として渡してあげる必要があるようです。

// style.scss
$soleil: (
  ichogo: red,
  aoi: blue,
  ran: purple,
);

:export {
  seleil: $soleil; // => エラーになる
}

// map を展開して個別の変数にして export する必要がある
@each $name, $color in $soleil {
  :export {
    #{$name}: $color;
  }
}

👇

import styles from './styles.scss';
console.log(styles);
// => { ichogo: "red", api: "blue", ran: "purple" }

:local, :global 内に変数を定義した場合

:local { maxWidth: 720px } の様に :export でないキーワードのブロック内に JS に渡したい変数を定義しても、JS では扱うことが出来ず 最終的に出力される CSS にもこの変数定義が出力されてしまいました。

SCSS と JS 共通して扱いたい変数は必ず :export キーワード内に記述する必要がありそうです。

JS 内で使用するだけ、CSS としては不要な場合は style-loader, MiniCssExtractPlugin は使わなくてもOK

レアケースだけど、CSS と JS で変数だけを共有して使いたいような場合。CSS を出力する必要がなければ CSS として書き出すための style-loader, MiniCssExtractPlugin は使わなくても OK

webpack.config.js

module.exports = {
  // ...
  rules: [{
    test: /\.scss$/,
    use: [
      'css-loader?modules',
      'sass-loader',
    ]
  }],
}

CSS Modules を named import したい

// style.scss
.ichigo { color: red; }
:export {
  widthTablet: 720px;
}
import { ichigo, widthTablet } from './style.scss';
console.log(ichigo); // => "_2EvoXuCC19CRPsPIfLzc34"
console.log(widthTablet); // => "720px"

css-loader のオプションには namedExport オプションがありこれを使うと named import ができるようになる。 css-loader の後に実行される style-loader や MiniCssExtractPlugin にもこのオプションを指定してあげる必要がある。
この設定を忘れると default import は空オブジェクト {} になり named import は undefined になってしまう。

cf. namedExport css-loader

style-loader の場合

  • style-loader v2.0.0
  • css-loader v5.0.0

cf. namedExport style-loader

webpack.config.js

module.exports = {
  // ...
  rules: [{
    test: /\.scss$/,
    use: [
      {
        loader: 'style-loader',
        options: {
          esModule: true,
          modules: {
            namedExport: true,
          },
        }
      },
      {
        loader: 'css-loader',
        options: {
          esModule: true,
          modules: {
            namedExport: true,
          },
        }
      },
      'sass-loader', 
    ]
  }],
}

MiniCssExtractPlugin プラグインを使うパターン

  • mini-css-extract-plugin v1.1.0
  • css-loader v5.0.0

CSS ファイルを作成する mini-css-extract-plugin プラグインにも namedExport のオプションがありこれを true にしてあげれば OK

cf. namedExport MiniCssExtractPlugin

webpack.config.js

const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
  // ...
  rules: [{
    test: /\.scss$/,
    use: [
      {
        loader: MiniCssExtractPlugin.loader,
        options: {
          esModule: true,
          modules: {
            namedExport: true,
          },
        }
      },
      {
        loader: 'css-loader',
        options: {
          esModule: true,
          modules: {
            namedExport: true,
          },
        }
      },
      'sass-loader', 
    ]
  }],
}

これで CSS Modules で named import ができるようになりました!

namedExport が設定されていると default import は undefined になる

namedExport を設定した場合内部的には JS に渡される CSS のクラス名・変数が下記のような形でになっているので default import はできなくなる

// style.scss
.ichigo { color: red; }
:export {
  widthTablet: 720px;
}

JS には下記の形に変換されたものが渡される

// CONCATENATED MODULE: ./style.scss
// extracted by mini-css-extract-plugin
const widthTablet = "720px";
const ichigo = "_2EvoXuCC19CRPsPIfLzc34";
// CONCATENATED MODULE: ./src/index.js

👇 なので default import は undefined になる

import styles from './style.scss';
console.log(styles); // => undefined

CSS Modules は named import でも tree shaking はされないっぽい

mode = production でビルドされたJSファイルの中を見てみた感じ、JS に import していないクラス名も全て変数として定義されていたので CSS Modules は named import にしても tree shaking は効かないみたいです。

/* ./style.scss */
.ichigo { color: red; }
:local {
  .aoi { color: blue; }
}
:global {
  .ran { color: purple; }
}
:export {
  widthTablet: 720px;
}
/* ./base.scss */
.app { font-size: 1rem; }
import './base.scss';
import { ichigo, widthTablet } from './style.scss';

👇 ビルドされた JS ファイルの中

// CONCATENATED MODULE: ./base.css
// extracted by mini-css-extract-plugin
const app = "w54bpbuIyvtuFEpomSGPt";
// CONCATENATED MODULE: ./style.scss
// extracted by mini-css-extract-plugin
const widthTablet = "720px";
const ichigo = "_2b0mtVMhYRrlk4p9CgzKCc";
const aoi = "_3fxOvTsExbr0n7UyKw53cD";

js では import していない api も変数として定義されているので、 named import する場合も CSS ファイル内にある local 変数は全て JS に読み込まれるみたいなので named import しない場合もコンポーネントで使うスタイルだけ書かれた小さな CSS ファイルを import するように心がける必要は変わらずありそうです。

namedExport した際の ES5 対応について

namedExport された CSS Modules は const className = "hashed_class_name" の形で JS ファイルに書き出されます。
css-loader から渡されたコードは JavaScript / Typescript の loader を通らないようなので、これらで ES5 に変換する設定をしていても最終的に出力されるコードに const が残ってしまいます。

webpack.config.js

module.exports = {
  // ...
  rules: [
    {
      // css-loader で変換された JS はこの変換設定を通らない
      test: /\.js$/,
      use: [
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          "presets": [
            "@babel/preset-env"
          ],
        },
      ],
    },
    {
      test: /\.scss$/,
      use: [
        {
          loader: MiniCssExtractPlugin.loader,
          options: {
            esModule: true,
            modules: {
              namedExport: true,
            },
          }
        },
        {
          loader: 'css-loader',
          options: {
            esModule: true,
            modules: {
              namedExport: true,
            },
          }
        },
        'sass-loader', 
      },
    ]
  ],
}

流石にもう const は大丈夫だと思いますが最終的に出力されるスクリプトを Linter などでチェックしている場合は css-loader の後に babel などで ES5 の形に変換してあげる必要があります。

webpack.config.js

module.exports = {
  // ...
  rules: [
    // …
    {
      test: /\.scss$/,
      use: [
        {
          loader: MiniCssExtractPlugin.loader,
          options: {
            esModule: true,
            modules: {
              namedExport: true,
            },
          }
        },
        // JS に変換された CSS を babel で ES5 の形式に変換
        {
          loader: 'babel-loader',
          options: {
            "presets": [
              "@babel/preset-env"
            ],
          },
        },
        {
          loader: 'css-loader',
          options: {
            esModule: true,
            modules: {
              namedExport: true,
            },
          }
        },
        'sass-loader', 
      },
    ]
  ],
}

css-loader で JS 形式に変換されたコードを babel で ES5 にトランスコンパイルをすると named import したスタイルは var className = "hashed_class_name" の形式に変換されるので、最終的に出力されるコード に const が残ることはなくなりました。

まとめ

これで CSS Modules が使える設定を作ることができました。
CSS の設計は全部がグローバルなので難しい。 CSS Modules ならモジュールごとにスタイルを閉じ込めることが出きて便利!
ただし、閉じ込めるスタイルの分だけ JS ファイルが肥大化するので、本当に必要なスタイルだけを読み込む様に気をつける。
という印象でした。

つまり、CSS Modules にしても CSS はちゃんと設計する必要がある。ということだと思います。
人類は CSS 設計からは逃れられない…


[参考]