CSS Modules をもっと試す

2018年2月20日 0 開発 , , , ,

業務プロジェクトへ CSS Modules 導入を検討中。しかし JS/CSS ともに大きく設計変更されるため、いきなり採用するのは怖い。というわけで、そこそこの規模と複雑さを持つ examples-electron/audio-player で試してみた。このプロジェクトは Electron 製の簡易音楽プレーヤーになる。JS/CSS まわりはこんな感じ。

  • JavaScript
    • AltJS は Babelbabel-preset-env、ES.next 範疇の機能と構文のみ使用、ターゲット設定は electron なので少し古い Chorome 相当になる
    • View は React
    • Flux は material-flux、いずれ reduxmobx あたりへ乗り換える予定
    • Bundler は webpack
    • Electron の Main/Renderer プロセスともに Bundle/Transpile 対象
  • CSS
    • AltCSS は Stylus
    • Bundler/Transpiler ともに stylus で完結
    • CSS クラスは BEM、定義で楽するため Stylus のネスト機能に強く依存している
    • IcoMoon で生成した Icon Font を利用

このような環境に CSS Modules (with Sass) を導入して遭遇した問題や対策を記録する。

webpack.config.js

Sass を試す の CSS Modules (with Sass) + webpack 構成を踏襲しながら少し修正。Babel や package.json は CSS Modules とあまり関係ないので webpack.config.js のみ掲載。webpack v3 の設定になる。近くリリースされるという webpack v4 で変更が必要になるかもしれないけど、CSS Modules 部分はそのまま維持できると思う。

import WebPack from 'webpack'
import MinifyPlugin from 'babel-minify-webpack-plugin'
import ExtractTextPlugin from 'extract-text-webpack-plugin'

export default (env) => {
  const MAIN = env && env.main
  const PROD = !!(env && env.prod)

  return {
    target: MAIN ? 'electron-main' : 'web',
    entry: MAIN ? './src/js/main/App.js' : './src/js/renderer/App.js',
    output: {
      path: PROD ? `${__dirname}/dist/src/assets` : `${__dirname}/src/assets`,
      filename: MAIN ? 'main.js' : 'renderer.js'
    },
    devtool: PROD ? '' : 'source-map',
    node: {
      __dirname: false,
      __filename: false
    },
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader'
          }
        },
        {
          test: /\.scss$/,
          use: ExtractTextPlugin.extract([
            {
              loader: 'css-loader',
              options: {
                modules: true,
                localIdentName: PROD ? '[hash:base64]' : '[name]-[local]-[hash:base64:5]',
                url: false,
                importLoaders: 1,
                sourceMap: !(PROD),
                minimize: PROD ? { autoprefixer: false } : false
              }
            },
            {
              loader: 'sass-loader',
              options: {
                outputStyle: PROD ? 'compressed' : 'expanded',
                sourceMap: !(PROD)
              }
            }
          ])
        }
      ]
    },
    plugins: PROD ? [
      new MinifyPlugin({
        replace: {
          'replacements': [
            {
              'identifierName': 'DEBUG',
              'replacement': {
                'type': 'numericLiteral',
                'value': 0
              }
            }
          ]
        }
      }, {}),
      new WebPack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('production')
      }),
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ] : [
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ]
  }
}

デバッグ用 CSS クラス名

CSS Modules は CSS クラス名を自動的にハッシュ化してくれるのだが、このままだと DevTools から確認したいときに困る。Source Maps があれば元のスタイルを参照できるものの、HTML 要素の class 属性から React コンポーネントを人間が識別するのは難しい。よって css-loader の options を工夫する。

{
  localIdentName: PROD ? '[hash:base64]' : '[name]-[local]-[hash:base64:5]'
}

デバッグ時は [name]-[local]-[hash:base64:5] としておく。生成されたクラス名は AudioPlayerControl-player-1sCWC のような感じとなり人間にもわかりやすい。終端のハッシュでユニークさも担保される。モジュールとクラス名だけでユニークさを保証できるならハッシュを外してもよい。私は保険のためにつけてる。

リリース版についてはお好みで。私はエンド ユーザー仕向けなので実装の詳細は見せないほうがよいと考えており、それを明示するための難読化としてハッシュ化することにした。エンド ユーザーに開発の協力をあおぐとしてもデバッグ版を提供すればよい。

Sass の @import と CSS Modules の注意点

Sass の SCSS 内で @import するとその単位で参照が解決される。これと CSS Modules を組み合わせる場合は注意が必要。例えば Test.scss に

.test {
  color: red;
}

を定義して Content.scss と Footer.scss がそれぞれ @import したとする。これらを更に JavaScript から参照すると Test.scss の内容が Content.scss と Footer.scss で個別に展開されてから CSS Modules に結合されるため

/* Content.scss から @import "Test.scss" した部分 */
._3lCv-t9tW054o8vckddf2y {
  color: red;
}

/* Footer.scss から @import "Test.scss" した部分 */
._15Cb513oAuCYe4NCm3MT69 {
  color: red;
}

のように重複してしまう。CSS Modules は JavaScript からの参照解決だけを担当してモジュール内の参照は考慮しないようだ。

これを避けるために共用したい SCSS は CSS セレクターとして有効な定義をせず、変数や Mix-In に限定すればよい。そもそも CSS セレクターを参照しても利用する方法がないわけで、継承とか合成ならば Sass として @mixin@extend が提供されている。

CSS Modules の結合順とグローバル

CSS Modules の結合は JavaScript 側の参照順となる。CSS Modules を利用しようと考える時点で JS/CSS は疎結合になっているだろうから、普段は順番を意識することはない。しかし例えば

  • <html><body> などのスタイルを CSS 全体で一度だけ定義したい
  • アイコン フォントは個別に参照せずグローバルに一度だけ定義したい

ということもあるだろう。これを実現してみる。

まずは CSS の参照位置。これは JavaScript 側のエントリー ポイント冒頭にする。例えば App.js がエントリー ポイントなら

import './App.scss'

// 以下、エントリー ポイント処理...

のように読み込む。JavaScript 側で直に参照するものはないため from は不要。考え方としては babel-polyfill に近い。これで App.scss に定義されたスタイルは CSS の先頭に展開される。

次にグローバルな CSS クラス名。これは css-modules/css-modules の README で解説されているとおり :global で囲む。例えば .app というクラス名をハッシュ化せず維持したいなら以下のようにすればよい。

:global {
  .app {
    width: 100%;
    height: 100%;
    background-color: $color_white;
  }
}

これはサード パーティ製 CSS 内のハッシュ化を防ぐためにも使える。

:global {
  @import "~library.css";
}

こうすれば対象となる CSS 内に定義されたクラス名が維持される。配布形態が npm なら ~モジュール名 とすることで node_modules 配下のパスを省略も可能。

ただしこの機能にはバグがある。2018/2 時点の css-loader だと :global 内に font-face があると { font-face {} } のように余計なブラケットで囲まれてしまう。そのため font-awesome を対象にすると CSS が壊れてしまう。

アイコン フォントのクラス名

アイコン フォントを利用する場会は前述の問題があるため font-face:global に囲えない。よってアイコンの CSS クラス名を維持するならそこだけを :global に囲み font-face はむき出しにする必要がある。CSS クラス名を維持せずハッシュ化を許容するとしても別問題がある。

例えば

[class^="icon-"], [class*=" icon-"] {
  font-family: "icon";
  speak: none;
  font-style: normal;
  font-weight: normal;
  font-variant: normal;
  text-transform: none;
  line-height: 1;

  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.icon-rew:before {
  content: "\e800";
}

.icon-warning:before {
  content: "\e801";
}

のようにセレクター名で共通部分を定義する場合、そこを維持しなければならない。これは「デバッグ用 CSS クラス名」の項で書いたように css-loader の localIdentName を工夫すれば解決可能ではある。しかしこの方法だと全 CSS クラス名に影響するため設計的に好ましくない。またアイコン フォントは複数箇所から同時に利用したくなるだろうし、その場合は性質としてグローバルだ。

よって font-face 以外は :global でまるごと保護する方が運用しやすい。React コンポーネント側はそのまま直値で CSS クラス名を指定する。

font-faceurl 参照

font-face の参照で src:url() にローカルの相対パスを指定した場合、css-loader の処理時点で参照が解決されなければならない。そのため

.
├── assets
│   ├── index.html
│   └── fonts
│       ├── icon.eot
│       ├── icon.svg
│       ├── icon.ttf
│       └── icon.woff
└── js
    ├── App.js
    └── App.scss

のような構成で以下のように定義しているとエラーになる。

@font-face {
  font-family: "icon";
  src:url("fonts/icon.eot");
}

CSS の出力先が assets/ であっても css-loader は CSS から参照されているものを直に解決しようとするため、App.scss から fonts/icon.eot が見えないといけない。これを防ぐためには css-loader の optionsurl: false を設定する。これで src: url() の参照を無視してくれる。自前でアイコン フォントを用意するなら、これとグローバル指定を組み合わせて CSS のエントリー ポイントへまとめて定義するとよい。

@charset "UTF-8" 問題

アイコン フォントの定義で content: "\e800"; のように非 ASCII 文字を指定すると sass-loader が気を利かせて @charset "UTF-8"; を挿入してくれる。しかし「Sass の @import と CSS Modules の注意点」で書いた問題を考慮しなければならない。

例えば <html><body> などのスタイルを Base.scss、アイコン フォント系を Icon.scss に定義したとしよう。これらをエントリー ポイントになる App.scss で

@import "Base.scss";
@import "Icon.scss";

のように読み込んだ場合、

html, body {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
}

@charset "UTF-8";
@font-face {
}

という感じで出力される。Icon.scss で初めて非 ASCII 文字が登場したため、このファイルを展開する位置に @charset "UTF-8"; が挿入されてしまった。これは @charset で解説されているとおり CSS として不正である。

対策するなら

  • 順番を意識して慎重に @import する
  • グローバルなものはファイル分割せず、エントリー ポイントで直に定義する

ことになる。私は設計的に単純な後者を採用した。直に定義するとファイルが長くなる懸念はある。しかし CSS Modules を採用しているならグローバルは少なくなるはず。そうでないならコンポーネントやモジュールの分割に問題があるため、設計を見直したほうがいい。

npm の font-awesome を利用するなら?

npm の font-awesome を利用するなら、ここまで書いたアイコン フォントに絡む問題をすべて解決しなければならない。

CSS クラス名の維持

CSS クラス名を維持する場合は :globalfont-face が壊れる問題を対策する。そのためには npm で配布されたファイルを加工する必要がある。font-face 以外を :global で囲むことになるだろう。これは npm 管理の観点からおこなうべきではない。配布されたものをそのまま利用できないなら、npm を採用する意味がない。

font-facesrc:url() 問題

font-facesrc:url() 問題について。これは大まかに二つの対策がある。

  1. url-loader で参照解決して出力 CSS に base64 文字列としてフォント ファイルを埋め込む
  2. 参照をスキップさせて CSS 出力先へフォント ファイルを都度コピーする

1 は CSS ファイルが巨大になる問題あり。これを許容できるなら Bundler という観点として正当な方法といえる。2 は出力された CSS から参照できればよい点は好ましいのだけど font-awesome の font-face 定義は

@font-face {
  font-family: 'FontAwesome';
  src: url('../fonts/fontawesome-webfont.eot?v=4.7.0');
  src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');
  font-weight: normal;
  font-style: normal;
}

のように ../fonts/ を参照しているため、出力先でこの構造を再現しなければならない。例えば

.
├── index.html
├── css
│   └── bundle.css
└── fonts
    ├── FontAwesome.otf
    ├── fontawesome-webfont.eot
    ├── fontawesome-webfont.svg
    ├── fontawesome-webfont.ttf
    ├── fontawesome-webfont.woff
    └── fontawesome-webfont.woff2

こんな感じ。Bundler で単一 CSS ファイルを出力していると、それひとつだけ格納するフォルダーを用意するのは抵抗がある。webpack loader/plugin を駆使してパスを書き換える手もあるだろうけど font-awesome の定義変更に影響されるため微妙。

@charset "UTF-8" 問題

font-awesome の @import を全 CSS の先頭にもってくればよい。

これは制約になるためコミット ログだけでなくコメントでソース自体に明記しておいたほうがよい。第三者が @import 周りを整理しようとして事故らないためにも。

そもそも Bundler と相性よくない

font-awesome を Bundler と組み合わせて利用したい需要はそれなりにあるようで、

といった記事で対策が紹介されている。しかし特定 npm の設計による問題を解決するために特別な npm や設定を用意するのは泥縄ではないか。問題が問題を呼んでいる。依存も増えるし労に見合わない。

よって私が Font Awesome を利用するとしたら npm ではなく静的リソースとして直に組み込む。fonts/ を好みの場所に置き、CSS も書き換える。Releases – FortAwesome を見るに更新頻度はさほどでもないし、手動で管理しても大した手間ではないだろう。なお npm 版は CSS/SCSS/LESS ファイルをまとめて提供してくれるため、手動で組み込むための元ネタとしてインストールする価値はある。

Babel や Sass のように Font Awesome が公式に webpack loader/plugin をサポートしてくれることへ期待している。

JS/CSS の配置

CSS Modules を採用することで CSS は特定の React コンポーネント専用になる。よって JS/CSS は併置すると運用しやすい。例えばこんな感じで。

.
├── AlbumListContainer.js
├── AlbumListContainer.scss
├── AlbumListHeader.js
└── AlbumListHeader.scss

併置することで JavaScript 側から参照するときの相対パスが短くなる。ファイル名は一緒にして拡張子だけ変えると関連していることがわかりやすい。ただし同名ゆえに参照する際は拡張子まで記述すること。拡張子を省略すると参照している自身と区別できないので。

CSS クラスのネストについて

CSS Modules を利用しない場合、CSS クラスはすべてグローバルになることを前提に名前衝突を避けるため様々な工夫が必要だった。私も命名規則として BEM (Block Element Modifier) を採用していた。しかしベタに書いてゆくと

.button {}
.button--state-success {}
.button--state-danger {}

のようになってダルいため Stylus のネスト機能を使って

.button {
  &--state {
    &-success{}
    &-danger {}
  }
}

のように定義していた。CSS Modules を利用するなら衝突を気にする必要がないため、ネストさせず

.button {}
.success {}
.danger {}

のように書けばよい。モジュール分割されることでその単位のコンテキストが明示されるから従属しているものを簡素なクラス名にしても通じる。この例であればファイル名が Button.scss なら .button.container.base といった基礎部分をあらわすようにしてもよいだろう。

DOM 要素、擬似要素、擬似クラスに関しては CSS Modules の範疇外だが、これらも

.button {
  img {}
  &:hover {}
}

とするよりは

.button img {}
.button:hover {}

のように CSS 標準で書くほうがよいのかもしれない。これについては私もどうするか迷っている。しかし CSS 標準に寄せておけば CSS Modules や Sass への依存度をなるべく下げることでツールを捨てやすくなる。CSS を第三者と共有する際にも前提知識が CSS 標準の範疇で済むため混乱が起きにくいだろう。というわけで新たに書くならネストなしを採用する予定。

CSS Modules と CSS in JS について

React コンポーネントと CSS を連携させる手段として CSS Modules の他に CSS in JS という勢力がある。これらの違いについては古い記事だけど以下がわかりやすい。

特に後者ではそれぞれの弱点をとりあげていて納得感もある。しかし私は Free-Style の弱点で触れられている

CSS Modules は CSS で書くので、「いつでも引き返せる」みたいな安心感は CSS Modules の方が強いと思う

を重視するため CSS Modules を選ぶことにした。CSS in JS は Free-Style 以外にもいくつかあって、これも古い記事になるけど

が参考になる。で、この「いくつかあって」というのが問題。

CSS の Property/Value 記法は JavaScript オブジェクトの Key/Value と似ているため、ほぼそのまま書けそうに思える。しかし擬似要素や擬似クラスなどは対応しそうな構文がないため、諦めるか特別な記法を持ち込むことになる。そしてこの対策がツール間でまちまちなのだ。依存すると引き返すのが難しくなる。

CSS in JS を利用する動機として JavaScript から動的に変更しやすいというのがある。これについては外観に関わるものなら値そのものを書き換えるより CSS クラスを変更する従来式で十分だと考えてる。

静的でよければ Sass や Stylus にも変数はあるので外観プリセット切り替えのような目的には対応できる。動的なものとしても CSS 標準で calc などが控えてる。ならばわざわざ JavaScript 側でがんばらなくてよいのでは?というのが私の見解。

高機能なのはわかるけど、それなしに CSS 標準へ寄せて運用できるならそうしたい。この辺、CSS in JS について解説してる記事を読んでもピンとこなかったところなので識者の意見をうかがいたい。

Sass を試す

2018年1月17日 3 開発 , ,

年末に PostCSS + cssnext と CSS Modules を試したのだが、コメ欄の指摘とそれを受けた追記を読んでもらえるとわかるとおり次世代 CSS 仕様の先取り = CSS.next のつもりで導入した cssnext が微妙だと感じはじめている。

これらの記事でも言及しているとおり cssnext の採用する PostCSS プラグイン は CSS Working Group の認知していなかったり取り下げられそうなものもある。前者は直せばよいけど後者については直近の仕様だと冗長な記述が避けられないことを意味している。私は少なくとも

  • モジュール管理
    • @import が URL ではなくモジュール参照として解決される
  • 変数 (定数) と Mix-In
    • 色やサイズなどを変数 (定数) として定義して共通化できる
    • Mix-In で拡張性のある共通プロパティーのセットを定義できる
  • ネスト
    • 親子関係にあるクラスで子が複数となるとき、ネストとして定義できる
    • 元が .parent .child1 {} .parent .child2 {} なら
    • .panret { child1 {} child2 {} } とできる

を必要としているのだけどモジュール管理は Bundler の範疇だし Mix-In とネストは採用されないだろうしで、CSS.next としては変数しか恩恵にあやかれない。ならば cssnext に拘る必要はない。また AltCSS としてみた場合、PostCSS のプラガブルな思想はエコ システムが多段になり複雑さを増すだけである。よって Stylus や Sass を採用するほうがよいだろう。

今までは Stylus を採用してきたが、これは PostCSS + cssnext 移行のきっかけとなったとおり開発が停滞している。ならばもう一つの雄である Sass を検討してみたい。というわけで Sass 単体と webpack による CSS Modules を試す。

Sass、SASS、SCSS

SASS (Syntactically Awesome StyleSheets) はツールと言語の側面がある。Web に散見される記事をみるに Sass と表記されたときはプロダクトやツール、SASS なら記法を指すようだ。そして Sass には更に SCSS という記法もある。このあたりの違いは以下の記事がわかりやすい。

ツールとしての Sass には Ruby と Node 版がある。開発環境として依存の少ないほうが好ましいため、Ruby 不要の Node 版を選ぶことにした。記法は SCSS を採用。SCSS は SASS よりも CSS に寄せているため、将来 Sass をやめたくなったときの負担が少なくて済むだろう。この点について Stylus でコロンとセミコロン省略していたことを後悔している。

SCSS の記法は Stylus と似ている。CSS 標準の記法をそのまま取り込めて拡張したスーパー セット的なものだから、拡張部分に注意すれば普通の CSS 感覚で書ける。機能的に大きく異るのは変数ぐらいだろうか。Stylus には透過的変数という概念があって

white = #ff0000;

.page {
  background-color: white;
}

のように CSS 標準定数を上書き可能で、この例だと white なのに赤としている。SCSS は

$white: #ff0000;

.page {
  background-color: $white;
}

のように変数に接頭辞 $ が必須となるため透過的に上書きすることはできない。抽象化としては機能が落ちるけれど、CSS 標準定数を破壊することがないと保証されているともいえる。一方で独自記法を持ち込むことになるから SCSS を捨てる際の枷になるかもしれない。

モジュール機能については拡張子によって @import の扱いが異なる。*.scss なら Stylus と同様に参照かつ結合対象。しかし *.css の場合は CSS 標準の URL 参照として扱われる。そのため npm としてインストールした normalize.cssnode_modules 配下から参照して結合、といった技が使えない。これを解決するためには normalize.scss にするか node-sass-package-importer という node-sass フォークがあるけど、いずれも標準じゃないので採用したくない。

これについて現在は公式資料にて

Notice we’re using @import 'reset'; in the base.scss file. When you import a file you don’t need to include the file extension .scss. Sass is smart and will figure it out for you. When you generate the CSS you’ll get:

と定義されている。ファイルとして取り込みたいなら拡張子を省略すればよい。これについては実際に

@import "../../node_modules/normalize.css/normalize";
@import "../../node_modules/normalize.css/normalize.css";
@import "./Sample";
@import "./Sample.css";

を試した結果、拡張子を省略したものはファイル結合される。.css まで記述すると @import url(path) に変換されることを確認した。なお .scss はどちらでもファイル結合される。つまり外部 CSS のファイル結合はフォークや SCSS 版を持ち出すことなく Sass 標準で対応可能。

面倒なら拡張子を省略して Sass の自動判定に任せるのがよいだろう。@import url(path) はパフォーマンス問題があるため通常は避けるものだから、特に困らないはず。ただし CSS Modules で同名 JavaScript と併置したくなった時などを考慮すると内部ファイルは拡張子まで記述したい。私は ES Modules でも npm など外部由来はモジュール名、内部は拡張子まで記述するようにしている。このあたりは好みがわかれそう。

Sass 単体利用

node-sass による CLI ビルドを試す。

プロジェクト構成。

プロジェクト構成は以下。

.
├── package.json
└── src
    ├── assets
    │   └── index.html
    └── scss
        ├── App.scss
        ├── Base.scss
        ├── Content.scss
        ├── Footer.scss
        ├── Header.scss
        └── Variables.scss

src/scss へ CSS を格納。これを開発版なら src/assets、リリース版は dist/ に変換して bundle.css というファイル名で出力する。開発版では Source Maps にも対応。必要な npm は node-sass のみ。

$ npm i -D node-sass

npm-scripts

Sass は Stylus と同様に CLI オプションのみで設定を完結可能。package.json における定義は以下。

{
  "scripts": {
    "build": "node-sass ./src/scss/App.scss ./src/assets/bundle.css -r --source-map true --output-style expanded",
    "watch": "node-sass ./src/scss/App.scss ./src/assets/bundle.css -r -w --source-map true --output-style expanded",
    "release": "node-sass ./src/scss/App.scss ./dist/bundle.css -r --output-style compressed",
  }
}

オプションについて現時点の公式ページ資料だとわかりにくいため、使用しているものに絞って補足。

オプション 内容
-r ファイルやフォルダーを再帰的に監視する。フォルダーで階層構造を持たせたくなるかもしれないため、指定。
-w ファイルの変更を監視して自動変換する。
--source-map Source Maps を出力する。Boolean 値を指定する必要あり。
--output-style 出力される CSS のスタイル。expanded はネストを展開してくれる。規定値の nest だとインデントでネストっぽくなるけど余計である。なぜこれを規定値にしたのか。compressed は minify。リリース版で指定する。

変換してみる

npm-scripts に定義されたコマンドを実行して CSS.next なファイル群をひとつの CSS に変換してみる。コマンドとしては

コマンド 役割
build 開発用。Source Maps あり。単体で変換結果をチェックする場合に使用する。
watch 開発用。Source Maps あり。ファイルを監視して変更を検知したら CSS を自動変換する。browser-sync による Web ブラウザー自動表示も設定している。開発時は通常、こちらを使用する。
release リリース用。Source Maps なし、minify あり。

となるので、それぞれ

$ npm run build
$ npm run watch
$ npm run release

とすればよい。出力されたファイルを見れば適切な変換がおこなわれていることを確認できるはず。

webpack (CSS Modules)

webpack を使用して React から Sass を CSS Modules として参照できるようにする。

プロジェクト構成

プロジェクト構成は以下。

.
├── package.json
├── webpack.config.babel.js
└── src
    ├── assets
    │   └── index.html
    └── js
        ├── App.js
        └── components
            ├── App.js
            ├── Content.scss
            ├── Content.js
            ├── Footer.scss
            ├── Footer.js
            ├── Header.scss
            ├── Header.js
            └── Variables.scss

ファイル出力の仕様は node-sass 版と同じ。webpack 設定が追加されて SCSS ファイルが React コンポーネントに併置されているのが特徴的。

npm

必要な npm をインストール。webpack と Sass 関連だけ、他は割愛。

$ npm i -D webpack css-loader node-sass sass-loader extract-text-webpack-plugin

それぞれの役割をまとめる。

npm 役割
webpack Bundler。JavaScript や CSS の参照を解決、指定されたファイル単位にまとめて出力する。
css-loader webpack loader。CSS の参照を解決してくれる。
node-sass Sass 変換ツール Node 版。 Sass に基づく CSS 変換を担当。
sass-loader webpack loader。node-sass を webpack で利用するためのもの。
extract-text-webpack-plugin webpack plugin。css-loader と sass-loader のテキスト出力を受けて CSS ファイル化する。

webpack.config.babel.js

webpack の設定は以下。JavaScript 分も含まれているため長いが、すべて掲載する。

import WebPack from 'webpack'
import MinifyPlugin from 'babel-minify-webpack-plugin'
import ExtractTextPlugin from 'extract-text-webpack-plugin'

export default (env) => {
  const PROD = !!(env && env.prod)

  return {
    entry: './src/js/App.js',
    output: {
      path: PROD ? `${__dirname}/dist` : `${__dirname}/src/assets`,
      filename: 'bundle.js',
      publicPath: '/'
    },
    devtool: PROD ? '' : 'source-map',
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader'
          }
        },
        {
          test: /\.scss$/,
          use: ExtractTextPlugin.extract([
            {
              loader: 'css-loader',
              options: {
                modules: true,
                importLoaders: 1,
                sourceMap: !(PROD),
                minimize: PROD ? { autoprefixer: false } : false
              }
            },
            {
              loader: 'sass-loader',
              options: {
                outputStyle: PROD ? 'compressed' : 'expanded',
                sourceMap: !(PROD)
              }
            }
          ])
        }
      ]
    },
    devServer: {
      contentBase: './src/assets'
    },
    plugins: PROD ? [
      new WebPack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('production')
      }),
      new MinifyPlugin({
        replace: {
          'replacements': [
            {
              'identifierName': 'DEBUG',
              'replacement': {
                'type': 'numericLiteral',
                'value': 0
              }
            }
          ]
        }
      }, {}),
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ] : [
      // development
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ]
  }
}

PostCSS + cssnext のサンプルと見比べてもらえるとわかるけど

{
  loader: 'sass-loader',
  options: {
    outputStyle: PROD ? 'compressed' : 'expanded',
    sourceMap: !(PROD)
  }
}

ぐらいしか変更していない。sass-loaderoptions に指定したものは node-sass へそのまま渡される。よって Source Maps や minify 指定はここでおこなう。

実際に変換してみると、React コンポーネントで参照している className がハッシュ値に変換されて CSS 側もそうなっていることを確認できるはず。

まとめ

AltCSS として既に Stylus に馴染んでおり、SCSS の記法は似通っているので違和感はなかった。CSS Modules も PostCSS とほとんど同じ。要素技術としてうまいこと可換にできた感がある。

最後に私がこれまで試した AltCSS 所感をまとめておく。

  • Stylus
    • Stylus を使ってみる が 2015/1/14 なので 3 年ぐらい利用したことになる
    • 当時は Sass の Ruby 依存を忌避して Stylus を採用
    • Pure Node、単体で高機能、開発活発、記述の柔軟性からとても気に入っていた
    • 開発は停滞したものの、現時点の選択肢としては悪くない
    • 個人的には開発が活発化して復活してほしい
  • PostCSS + cssnext
    • 昨年末に試してみた
    • CSS 記法のプラグイン拡張と cssnext の存在から Babel と babel-preset-env 的なものを期待していた
    • cssnext が csswg にないものを採用しており運用に不安がある
    • CSS の将来仕様で Mix-In やネスト (こちらは csswg にない) といった便利機能に相当するものがサポートされないっぽくて微妙
    • PostCSS としては Stylus/Sass から乗り換えたくなるプラグインは今のところない
    • プラグイン拡張に可能性を感じるならあり
    • 他の AltCSS 相当な機能をプラグインかき集めて実現するのはきついので cssnext のようなプリセットを検討したほうがよさそう
  • Sass
    • 記法は Stylus から CSS 標準記法の省略を抜いた感じ
    • Stylus の省略記法に馴染んでると辛いが、それなら SCSS でなく SASS を採用すればよい
    • 私の必要とする機能においては透過的変数をのぞき Stylus と同等
    • Stylus 代替としてはこれかな

これら 3 種について Node 版を npm trends で比較 した結果は 2018/1/16 時点だと PostCSS が Sass にダブル スコア以上の差をつけてトップだった。なんとなく Sass が最も普及していると予想していたので、これは意外。もしかすると gem 版ユーザーが圧倒的に多いとか?gem (Ruby) 版 Sass のダウンロード数がバージョンではなく期間推移も集計していれば npm trends のものと足して比較できたのに。

とりあえず現時点で選ぶとしたら Sass が無難だろう。乗り捨てを考慮しても SCSS 記法にしておけばロック イン度合いも少なくて Stylus/PostCSS への移行しやすいはず。

PostCSS + cssnext と CSS Modules を試す

2017年12月27日 2 開発 , , ,

このあいだ書いた記事の締めで予告したとおり Stylus からの移行先として PostCSS + cssnext を試す。

2017/12/29 追記 : cssnext は CSS.next と呼べるか?

本記事のコメント欄にて @mysticateaさん から指摘され、cssnext の機能すべてを CSS.next と呼ぶのは妥当ではないと判断した。

私が CSS.next だと思っていた CSS Nesting Module は CSS Working Group (以下、csswg) の draft には存在せず上記 issue では cssnext が次世代の規格と宣伝してるけど正式な draft としては提出されてないと書かれてる。

これから提出される可能性はあるものの、現時点の cssnext は csswg draft に完全準拠してるわけではない。CSS.next な機能とそうでないものが混在してる。

とはいえ PostCSS の CSS 構文拡張をプラグイン化するアイディアと cssnext がそこから Babel 的に .next なものを選定する方針は支持したい。よって年明けになるが cssnext 側へ

  • csswg draft とそのステータスを反映するようにしてほしい
  • csswg draft に挙げられているものだけ有効にするモードを追加してほしい
    • csswg draft のステータスがパラメーターになるような感じ
    • 標準化されたものだけ有効とか
    • モード追加としたのは現行 cssnext ユーザー環境を壊さないため

という感じの要望を出す予定。

PostCSS + cssnext

PostCSS は AltCSS の一種となるツール。Stylus や Sass はそれ自体をひとつの言語としているが PostCSS は JavaScript における Babel のようななもので、本体はランタイムに過ぎない。様々なプラグインを組み合わせて好みの変換環境を構築してゆく。

しかし個々のプラグインを調査するのはコストがかかる。また Babel でいう babel-preset-env のように将来の CSS 仕様 (以下、CSS.next) へなるべく準拠して書きたいところ。という要望を叶えるものとして cssnext がある。

cssnext は CSS.next な記法を実現するための PostCSS プラグイン集。以下の記事は対応する仕様と照らし合わせながら解説しており参考になる。

難点として babel-preset-env と異なり、変換対象となる環境を指定できない。たとえば Chrome だけサポートした CSS.next 記法があるとする。そして Web ブラウザーとしても Chrome だけサポートするからそこは変換したくない。このケースだと babel-preset-env では環境の下限を Chrome にすることで無駄な変換を避けられるのだが cssnext にはこの機能がない。

babel-preset-envECMAScript 6 compatibility table に準拠して環境を特定するのだが CSS にはこうした網羅的な環境基準がないため難しいのだろう。現時点では常に CSS3 相当の変換であることを意識する必要あり。

PostCSS CLI による単体利用

PostCSS + cssnext を利用する方法として webpack 前提の記事が散見される。しかし PostCSS は単体で利用可能。いきなり webpack と組み合わせると設定が一気に複雑化して混乱するだけなので、まずは単体で試してみる。

プロジェクト構成

プロジェクト構成は以下。

.
├── package.json
├── postcss.config.js
└── src
    ├── assets
    │   └── index.html
    └── css
        ├── App.css
        ├── Base.css
        ├── Content.css
        ├── Footer.css
        ├── Header.css
        └── Variables.css

src/css へ CSS を格納。これを開発版なら src/assets、リリース版は dist/ に変換して bundle.css というファイル名で出力する。開発版では Source Maps にも対応。

npm

必要な npm をインストール。

$ npm i -D postcss-cli postcss-cssnext postcss-import cssnano

それぞれの役割をまとめる。

npm 役割
postcss-cli PostCSS の CLI 兼、ランタイム。
postcss-cssnext cssnext 用 PostCSS プラグイン集。CSS.next な記法を解釈して変換する。
postcss-import CSS 内の @import を解釈して複数ファイルを結合するための PostCSS プラグイン。CSS.next の範疇から外れるが、便利なので追加。
cssnano CSS を minify するためのツール。プラグインではないが PostCSS の README で minify 用として紹介されているため採用。

postcss.config.js と npm-scripts

PostCSS 用の設定ファイルを定義。ファイル名を postcss.config.js にすると自動認識してくれる。内容は PostCSS よりも postcss-cli の README のほうが詳細に解説してあるので一読を。

module.exports = (ctx) => {
  const PROD = (ctx.env === 'prod')

  return {
    map: PROD ? null : { inline: false },
    plugins: {
      'postcss-import': {},
      'postcss-cssnext': {},
      'cssnano': PROD ? { autoprefixer: false } : false
    }
  }
}

設計思想や記法は webpack っぽい。Object または Functionexport する。Function だと CLI 引数を判定できる。これを利用して package.json の npm-scripts から開発版とリリース版を切り替えている。

{
  "scripts": {
    "build": "postcss ./src/css/App.css -o ./src/assets/bundle.css",
    "watch:css": "postcss ./src/css/App.css -o ./src/assets/bundle.css -w",
    "release:css": "postcss ./src/css/App.css -o ./dist/bundle.css -e prod"
  }
}

CLI の第一引数が CSS のエントリー ポイントになる。postcss-import を追加したので、このファイルから @import をたどって参照が解決される。-o が出力指定。-w でファイル監視 & 変更時の自動変換、-e はその後に指定した文字列を postcss.config.jsFunction に渡してくれる。これは任意だが NODE_ENV で用いられる production に基づき、それを省略した prod とした。

CSS.next + @import で書いてみる。

環境の準備が整ったので CSS.next ならではの機能と @import を使用した CSS を書いてみる。まずはエントリー ポイントになる App.css

@import "./Base.css";
@import "./Header.css";
@import "./Content.css";
@import "./Footer.css";

各 CSS のインポートのみを定義。エントリー ポイント以上のことはしない。こうすると何か問題が起きたとき、読み込み順と CSS 定義のどちらが原因であるかを切り分けやすくなる。個々の CSS が双方にインポートしあわない限り、順番を制御しているのはエントリー ポイントのみとなる。

次に各 CSS で共通使用する変数を定義。名前は Variables.css としておく。今回は色だけだが将来はサイズなども定義することになるだろう。それを踏まえて Colors ではなく Variables とした。

:root {
  --white: #fbfcfa;
  --turquoise: #1abc9c;
  --green_sea: #16a085;
  --emerald: #2ecc71;
  --nephritis: #27ae60;
  --peter_river: #3498db;
  --belize_hole: #2980b9;
  --amethyst: #9b59b6;
  --wisteria: #8e44ad;
  --wet_asphalt: #34495e;
  --midnight_blue: #2c3e50;
  --sun_flower: #f1c40f;
  --orange: #f39c12;
  --carrot: #e67e22;
  --pumpkin: #d35400;
  --alizarin: #e74c3c;
  --pomegranate: #c0392b;
  --clouds: #ecf0f1;
  --silver: #bdc3c7;
  --concrete: #95a5a6;
  --asbestos: #7f8c8d;
}

CSS 変数については MDN の記事がわかりやすい。色の定義と名前は本ブログや自作 Redmine テーマでも使用している Flat UI からもってきた。

これを読み込みつつ、CSS.next の CSS Nesting Module Level 3 を使用した Header.css を定義。

@import "Variables.css";

.header {
  background-color: var(--alizarin);

  & ul {
    margin: 0;
    padding: 0;
  }

  & li {
    text-align: center;
    line-height: 2.5em;
    display: inline-block;

    & a {
      margin: 0;
      width: 10em;
      height: 2.5em;
      color: var(--white);
      border-top: solid 2px var(--alizarin);
      box-sizing: border-box;
      display: inline-block;
      text-decoration: none;

      &:hover {
        border-top: solid 2px var(--sun_flower);
      }
    }
  }
}

Variables.css は他の CSS からも読み込むのだが、結合しても同じものが多重定義されることはないので安心。CSS Nesting Module は Stylus や Sass などでお馴染みの機能。& が直上の Selector を指している。そのため .header 直下で & ul と書けば .header ula に対して &:hover なら a:hover に変換される。これを利用して BEM を簡潔に書くことも可能。

  • 2017/12/29 追記
    • CSS Nesting Module は 2017/12/29 現在、CSS Working Gropu draft には挙げられていません
    • つまり CSS.next の機能とは呼べません
    • cssnext という AltCSS の独自機能となります
    • 元の表現は残しますが、これを CSS.next とする誤解が私以外にも広まるのは好ましくないため、この注記にて補足することにしました

Source Maps と minify

Source Maps を有効にするなら map オプションを指定する。CSS 埋め込みではなく単体ファイルとして出力したいなら

{
  map: PROD ? null : { inline: false }
}

のように inline: false とすればよい。このオプションが無効値だと Source Maps を出力しなくなるのでリリース版ではそうしている。minify は少々、特別で cssnano をインストールして plugins に指定することで実行される。これもリリース版のみとした。

{
  plugins: {
    'postcss-import': {},
    'postcss-cssnext': {},
    'cssnano': PROD ? { autoprefixer: false } : false
  }  
}

cssnano のオプション指定で autoprefixer を無効化している。これは cssnext 側に含まれており、無効化しないと変換時に冗長であると警告される。

Processing src/css/App.cssWarning: postcss-cssnext found a duplicate plugin ('autoprefixer') in your postcss plugins. This might be inefficient. You should remove 'autoprefixer' from your postcss plugin list since it's already included by postcss-cssnext.
Note: If, for a really specific reason, postcss-cssnext warnings are irrelevant for your use case, and you really know what you are doing, you can disable this warnings by setting  'warnForDuplicates' option of postcss-cssnext to 'false'.

この警告は postcss-cssnext のオプションに warnForDuplicates: false を指定することでも無効化できる。しかし本当に警告を表示すべき場合も無効化されてしまう。本来は警告メッセージを読んで個別に対応してゆくべきなのでそうした。

変換してみる

npm-scripts に定義されたコマンドを実行して CSS.next なファイル群をひとつの CSS に変換してみる。コマンドとしては

コマンド 役割
build 開発用。Source Maps あり。単体で変換結果をチェックする場合に使用する。
watch 開発用。Source Maps あり。ファイルを監視して変更を検知したら CSS を自動変換する。browser-sync による Web ブラウザー自動表示も設定している。開発時は通常、こちらを使用する。
release リリース用。Source Maps なし、minify あり。

となるので、それぞれ

$ npm run build
$ npm run watch
$ npm run release

とすればよい。出力されたファイルを見れば適切な変換がおこなわれていることを確認できるはず。

webpack (CSS Modules)

PostCSS + cssnext を webpack で利用することも可能だが、単に CSS を変換するだけなら PostCSS CLI のほうが webpack 依存を避けられて好ましい。どうせ webpack を使うなら領発揮となる CSS Modules を採用してみよう。ということで React + CSS Modules 構成を試す。

React における CSS

Web Components を指向してるライブラリーやフレームワークでは HTML/CSS/JS をどのように組み合わせるかが課題になる。React の場合、HTML/JS は Virtual DOM と JSX (option) で関連付けるのだが CSS については

  • CSS 単体
    • 普通に CSS を定義してそれと関連付ける
    • React コンポーネント側からは className などで CSS クラス名を明示する
    • React コンポーネントの出力も含めた最終的な HTML を想定しながら CSS を定義
    • CSS クラス名の衝突回避は BEM などの命名規則でおこなう
  • CSS in JS
    • React: CSS in JS // Speaker Deck
    • JavaScript の Object として CSS を定義して React コンポーネントと関連付ける
    • CSS は HTML 要素の style 属性に出力される
  • CSS Modules
    • css-modules/css-modules
    • CSSモジュール ― 明るい未来へようこそ | プログラミング | POSTD
    • CSS を JavaScript のモジュールとして扱う
    • CSS は普通に定義、それを JavaScript 側で import する
    • import されたスタイルを React コンポーネントの className に指定することで関連付け
    • CSS はファイルとして出力される
    • CSS ファイル内のクラス名は React コンポーネントとクラス名など (明示的に変更も可能) を元にしたハッシュになる
    • CSS を React コンポーネント単位で分割していれば、この命名規則により自動的に衝突は回避される

がある。私はこれまで CSS 単体を利用してきた。React や webpack などに依存せず、通常の CSS 開発スタイルだけで独立して運用可能なのがその理由。

かつて CSS in JS を検討した際は style 属性に出力されるため CSS ファイルのキャッシュにのらないのと style 属性の制限により疑似要素、擬似クラスが利用できない点を気にして見送った。CSS Modules はこれらの弱点を克服しているのだが webpack など特定のツールに強く依存する点が受け入れられなかった。

しかし Web Components 指向として HTML/CSS/JS をセットであつかうなら、ファイルとしても併置してあるほうが管理しやすい。例えば Header というコンポーネントなら

Header.css
Header.js
Header.test.js

のように構成されていてほしい。

CSS 単体でも Stylus や postcss-import で参照解決すれば、同等の構成を実現可能ではある。しかし CSS エントリー ポイントとの関係を意識しなければならないし、併置しても React コンポーネントとの関連付けは CSS クラス名になるので疎結合で設計的に半端だし、とイマイチだ。

ならばいっそ CSS Modules に舵を切るほうがよいのでは?といことでそうしてみる。

プロジェクト構成

プロジェクト構成は以下。

.
├── package.json
├── postcss.config.js
├── webpack.config.babel.js
└── src
    ├── assets
    │   └── index.html
    └── js
        ├── App.js
        └── components
            ├── App.js
            ├── Content.css
            ├── Content.js
            ├── Footer.css
            ├── Footer.js
            ├── Header.css
            ├── Header.js
            └── Variables.css

ファイル出力の仕様については PostCSS CLI 版と同じ。webpack 設定が追加されて CSS が React コンポーネントに併置しているのが大きな特徴。

npm

必要な npm をインストール。webpack と CSS 関連だけ。PostCSS CLI で説明したものと Babel 系は割愛。

$ npm i -D webpack css-loader postcss-loader extract-text-webpack-plugin

それぞれの役割をまとめる。

npm 役割
webpack Bundler。JavaScript や CSS の参照を解決、指定されたファイル単位にまとめて出力する。
css-loader webpack loader。CSS の参照を解決してくれる。
postcss-loader webpack loader。PostCSS に基づく CSS 変換を担当。
extract-text-webpack-plugin webpack plugin。css-loaderpostcss-loader の出力を受けて CSS ファイル化する。

postcss.config.js

PostCSS と webpack は Source Maps 出力など機能的に被るところがある。そのため主従をしっかり決めて一方へ寄せるのが大切。今回は PostCSS を従として必要最小の設定にとどめる。そのため postcss.config.js の定義は実にシンプル。

module.exports = {
  plugins: {
    'postcss-import': {},
    'postcss-cssnext': {}
  }
}

PostCSS は CSS.next 変換のみに徹し、Source Maps と minify の指定は webpack 側でおこなう。

webpack.config.babel.js

webpack の設定は以下。JavaScript 分も含まれているため長いが、すべて掲載してから CSS 関連を解説する。

import WebPack from 'webpack'
import MinifyPlugin from 'babel-minify-webpack-plugin'
import ExtractTextPlugin from 'extract-text-webpack-plugin'

export default (env) => {
  const PROD = !!(env && env.prod)

  return {
    entry: './src/js/App.js',
    output: {
      path: PROD ? `${__dirname}/dist` : `${__dirname}/src/assets`,
      filename: 'bundle.js',
      publicPath: '/'
    },
    devtool: PROD ? '' : 'source-map',
    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: 'babel-loader'
          }
        },
        {
          test: /\.css$/,
          use: ExtractTextPlugin.extract([
            {
              loader: 'css-loader',
              options: {
                modules: true,
                importLoaders: 1,
                sourceMap: !(PROD),
                minimize: PROD ? { autoprefixer: false } : false
              }
            },
            'postcss-loader'
          ])
        }
      ]
    },
    devServer: {
      contentBase: './src/assets'
    },
    plugins: PROD ? [
      new WebPack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('production')
      }),
      new MinifyPlugin({
        replace: {
          'replacements': [
            {
              'identifierName': 'DEBUG',
              'replacement': {
                'type': 'numericLiteral',
                'value': 0
              }
            }
          ]
        }
      }, {}),
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ] : [
      // development
      new ExtractTextPlugin({ filename: 'bundle.css' })
    ]
  }
}

今回の構成では CSS の参照と出力について JavaScript 側の設定に影響される。そもそも CSS Modules を採用した時点で設計的にも JavaScript と密結合になるわけだから、そのようなものと受け入れよう。これを前提として CSS 関連だけ整形抜粋。

{
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract([
          {
            loader: 'css-loader',
            options: {
              modules: true,
              importLoaders: 1,
              sourceMap: !(PROD),
              minimize: PROD ? { autoprefixer: false } : false
            }
          },
          'postcss-loader'
        ])
      }
    ]
  },
  plugins: [
    new ExtractTextPlugin({ filename: 'bundle.css' })
  ]
}

extract-text-webpack-plugin が重要な働きをする。module.rules では .css ファイルに対して extract メソッドを呼び出している。これは既存の loader を利用して参照解決や変換を実行するためのもの。引数の型が何通りかあるのだけど今回は Array で loader を列挙する方式を採用。順番としては css-loader に参照を解決させてから postcss-loader で CSS 変換という流れになる。

CSS Moduels を利用する場合は css-loader のオプションに modules: true, importLoaders: 1 を指定する。PostCSS CLI でおこなっていた cssnano による minify もこの loader 内で実行される。minimizetrue を指定すると有効、Object の場合は有効かつ cssnano へ渡すオプションになる。

plugins で出力する CSS ファイルを指定。ディレクトリーは JavaScript 側と同じ場所になる。

extract-text-webpack-plugin の README にも解説されているが、複数の CSS ファイルを出力したければ newextract-text-webpack-plugin インスタンスを複数生成し、個別に extract メソッドを呼んだうえで plugins にこれらを列挙指定すればよい。Bootstrap のように巨大な CSS フレームワークとアプリ独自部分をわけるときなどに便利な機能である。

CSS と React コンポーネントの関連付け

まずは CSS を定義。Header.css とする。

.header {
  & ul {
  }

  & li {
    & a {
      &:hover {
      }
    }
  }
}

cssnext によりネストも処理できる。これを同じディレクトリーに併置された React コンポーネント Header.js から読み込んで関連付ける。

import React from 'react'
import Styles from './Header.css'

const Item = ({label, url}) => {
  return (
    <li>
      <a href={url}>{label}</a>
    </li>
  )
}

const Header = (props) => {
  const items = props.items.map((item, index) => {
    return <Item key={index} label={item.label} url={item.url} />
  })

  return (
    <nav className={Styles.header}>
      <ul>
        {items}
      </ul>
    </nav>
  )
}

読み込まれた Object には CSS 側のクラス名をもつプロパティーが定義されているので Styles.header のように参照してコンポーネントの className へ指定する。変換された CSS は以下のようになる。

._1g566qHWQaY0sftP77Euo- {
}

._1g566qHWQaY0sftP77Euo- ul {
}

._1g566qHWQaY0sftP77Euo- li {
}

._1g566qHWQaY0sftP77Euo- li a {
}

._1g566qHWQaY0sftP77Euo- li a:hover {
}

クラス名がハッシュになっている。長いので掲載しないが JavaScript 側の変換結果でもこのハッシュが className と関連付けられていることを確認できた。なおハッシュになるのはクラス名だけで要素名などは維持される。

CSS Modules を利用する時点でモジュール単位にスコープが作られるようなものだから、クラスをネストする意味は薄い。例えば以下のようにネストしたなら

.footer {
  & .copyright {
  }
}

変換結果は

._3Krd9752YE1I4ymIQ2tziV {
}

._3Krd9752YE1I4ymIQ2tziV ._32zVUKNOjqIf8Jr8ZonGQT {
}

となるため React コンポーネント側でも

import React from 'react'
import Styles from './Footer.css'

const Footer = (props) => {
  return (
    <footer className={Styles.footer}>
      <p className={Styles.footer + ' ' + Styles.copyright}>Copyright © {props.copyright}</p>
    </footer>
  )
}

という感じでわざわざスペースを挟んで結合しなければならない。設計としてもひとつのモジュールに同じクラス名が重複するのは好ましくないため、クラスはすべてルートに定義するほうがよいだろう。前述の li ならば可変長であることは自明でわざわざクラスをつける意義が薄いから、ネストして要素名で定義する。という感じで使い分けてゆこうと考えている。

変換してみる

変換用の npm-scripts コマンドは PostCSS CLI 版と同じなので割愛。

テキスト エディター

PostCSS + cssnext で CSS を書く際、テキスト エディターの補助があると生産性が向上する。Atom なら

を入れるとよい。language-postcss が PostCSS の構文強調、pigments は CSS 内の color 系を実際の色として強調表示してくれる。これらの組み合わせなのか pigments のパワーなのか分からないが CSS 変数に定義された色も参照先でちゃんと表示してくれる。なにげにすごい。

vscode の場合は

のいずれかを選ぶ。PostCSS syntax は名前のとおり PostCSS の構文強調。

postcss-sugarss-language は前者をベースにしてインテリセンスなどを追加したもの。ただしこちらは標準だと .css を対象としないため vscode のユーザー設定に

{
  "files.associations": {
    "*.css": "postcss"
  }
}

を追加する必要あり。以降は .css でも反応してくれる。vscode-icons を使用していれば vscode のエクスプローラー上でも .css が PostCSS アイコンに変わるので目安になる。

なお残念なことに vscode だと Atom のように CSS 変数の色は強調されない。Color Highlight が Atom でいう pigments かもと期待して入れてみたが CSS 変数には対応しておらず、たまに描画が崩れることもあったのでやめた。これは諦めるしかないようだ。将来に期待している。

まとめ

AltCSS としての PostCSS + cssnext と、それを CSS Modules に組み合わせる方法を試した。

Stylus なら同等の機能を単体で実現可能だが CSS.next 準拠の点で PostCSS + cssnext のほうが好ましい。標準的な記法を採用しておけばツールを捨てたり乗り換えるものが容易になる。Stylus が CSS.next 準拠してくれればよいのだけど、開発の熱は下がってるようだし難しいだろう。

CSS Modules は導入こそ面倒だが Web Components として思い描いてる構成像に近くて運用しやすそう。私はユニット テストもコードと同じディレクトリに併置しているのだが、関係するものを近くに集約すると実に便利である。密結合だからおのずとそれらを往復することが多くて、ならば近いほうがよい。CLI であれ GUI のツリー ビューであれ、近ければ往復しやすくなる。

CSS Modules を採用した場合、ページ全体とコンポーネントの CSS をどう分割するかが課題となる。今回のサンプルだと body, html のスタイルを最初に読み込まれるコンポーネントの CSS に定義したのだが、これは入出力ともにわけたほうがよいのかもしれない。

グラフィック デザイナーとの協業については一考の余地あり。

JavaScript と CSS が併置されていることに抵抗を感じるかもしれない。これまで CSS だけ注視していればよかったのだが、側に自分がいじらなさそうなファイルが置いてあるというのはストレスになるだろう。これは慣れてもらうしかないか。あと React なら SFC (Stateless Functional Components) にしておくとほぼ HTML 相当に単純化されるからグラフィック デザイナーの忌避感が減りそう。これはグラフィック デザイナーの領分についての話でもある。

グラフィック デザイナーから HTML/CSS の断片を提供してもらい、それを SFC + CSS Modules のセットにするのもよい。Sketch ならそういう書き出しも可能だしパーツ分解しやすそうだから、無理に CSS を定義してもらうより効率的かもしれない。断片として管理することを前提とした作業スタイルについて考えたい。