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 について解説してる記事を読んでもピンとこなかったところなので識者の意見をうかがいたい。

Electron を試す 11 – webpack によるビルド

2017年12月20日 0 開発 ,

これまで Web フロントエンドや Electron の JavaScript ビルドには Browserify と Babel を使用してきた。しかし Browserify の開発は停滞している。現在は個人から org 運営へ移管しており browserify/discuss にて開発者の募集や議論もおこなわれているものの、往時の勢いはない。

私としては CLI のみで完結して設定を package.json に集約しやすい点から Bundler の中では Browserify 推しだった。しかし今後のことを考えると webpack に移行したほうがよいと判断せざるをえない。

というわけで Electron プロジェクトの開発環境に webpack を導入してみた。以下はその覚書。

Browserify + Babel によるビルド

移行前に Browserify + Babel でおこなっていたビルド設定は以下。package.json 内で完結している。

{
  "babel": {
    "presets": [
      ["env", {"targets": {"electron": "1.7"}}],
      "react"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "scripts": {
    "build:js-main": "browserify -t [ babelify ] ./src/js/main/Main.js --exclude electron --im --no-detect-globals --node -d | exorcist --base ./src/assets ./src/assets/main.js.map > ./src/assets/main.js",
    "build:js-renderer": "browserify -t [ babelify ] ./src/js/renderer/App.js --exclude electron -d | exorcist --base ./src/assets ./src/assets/renderer.js.map > ./src/assets/renderer.js",

    "watch:js-main": "watchify -v -t [ babelify ] ./src/js/main/Main.js --exclude electron --im --no-detect-globals --node -o \"exorcist --base ./src/assets ./src/assets/main.js.map > ./src/assets/main.js\" -d",
    "watch:js-renderer": "watchify -v -t [ babelify ] ./src/js/renderer/App.js --exclude electron -o \"exorcist --base ./src/assets ./src/assets/renderer.js.map > ./src/assets/renderer.js\" -d",

    "release:js-main": "cross-env NODE_ENV=production browserify -t [ babelify ] ./src/js/main/Main.js --exclude electron --im --no-detect-globals --node | uglifyjs -c warnings=false -m -d DEBUG=false > ./dist/src/assets/main.js",
    "release:js-renderer": "cross-env NODE_ENV=production browserify -t [ babelify ] ./src/js/renderer/App.js --exclude electron | uglifyjs -c warnings=false -m -d DEBUG=false > ./dist/src/assets/renderer.js"
  }
}

Main/Renderer プロセスで個別に Bundle。目的ごとに接頭辞で分類しており、それぞれ

  • build: 開発用ビルド
  • watch: 開発用ビルド、ファイル監視して更新検知したら差分ビルドする
  • release: リリース用ビルド

となる。かなり長くて複雑なオプションを指定しているが、これは Electron や Node のランタイム モジュールを除外するなどの処理が必要なため。詳細は

を参照のこと。webpack では同等かそれ以上の Bundle 処理を目指す。

webpack

まずは必要な npm のインストール。

$ npm i -D webpack babel-loader babel-minify-webpack-plugin
$ npm i -D babel-core babel-preset-env babel-preset-react

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

npm 役割
webpack Bundler。複数の JavaScript を設定に応じて結合する。
babel-loader webpack から Babel を呼び出して Transpile するためのモジュール。
babel-minify-webpack-plugin minify 用モジュール。webpack 的には uglifyjs-webpack-plugin のほうが一般的で標準プラグインでもあるのだが、Bundle 以外の処理は Babel ファミリーで揃えたかったのでこちらを採用。
babel-core Transpiler となる Babel の本体。
babel-preset-env ES.next で実装されたコードを指定された環境用に変換するためのモジュール。例えば Electron v1.7 向けにすると Electron 上でで有効なもの (ES2015 Classes など) はそのままに、不足しているものだけ代替コードに変換してくれる。
babel-preset-react React 関連 (JSX など) を変換してくれるモジュール。

Babel は Bundler から独立しているため、Browserify 時代の設定をそのまま流用可能。

webpack.config.babel.js

webpack の設定は webpack.config.js というファイルに JavaScript として実装する。この処理を ES2015 以降へ対応させたい場合は Babel をインストールしたうえで webpack.config.babel.js というファイル名にする。今回はこちらでゆく。

import WebPack from 'webpack'
import MinifyPlugin from 'babel-minify-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'
          }
        }
      ]
    },
    plugins: PROD ? [
      new MinifyPlugin({
        replace: {
          'replacements': [
            {
              'identifierName': 'DEBUG',
              'replacement': {
                'type': 'numericLiteral',
                'value': 0
              }
            }
          ]
        }
      }, {}),
      new WebPack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('production')
      })
    ] : [
      // development
    ]
  }
}

内容について解説する。

エントリー ポイントを export default (env) => {...} としているが、これは Environment Variables を使用するためのもの。

Electron は少なくとも Main/Renderer の 2 種類をビルドする必要がある。それらを更に開発用とリリース用にわけるので単純に設定ファイルを定義すると 4 種類もの分岐が発生するうえ、設定や処理の大半は共通。

そのため設定ファイルは共通で外部引数によって処理を分岐する方式を採用した。Environment Variables はこの用途にうってつけの機能である。webpack を CLI から実行する際に

$ webpack --env.prod --env.main

のように --env.XXXX 形式の引数を与えると webpack のエントリー ポイントにした関数の第一引数に展開される。値名だけだと Boolean で true となり --env.XXXX=1 のように明示的に内容を指定することも可能。これを利用して npm-scripts 側は

{
  "scripts": {
    "build:js-main": "webpack --env.main",
    "build:js-renderer": "webpack",
    "watch:js-main": "webpack --env.main --watch",
    "watch:js-renderer": "webpack --watch",
    "release:js-main": "webpack --env.prod --env.main",
    "release:js-renderer": "webpack --env.prod"
  }
}

となる。--env.main により Main/Renderer、--env.prod でリリースと開発版を分岐している。これを受けて webpack 側は

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

とする。env を直に使わず別の変数に格納している理由は --env.XXXX が未指定だと undefined になるため env.XXXX を判定できないから。参照部分ごとに env の存在をチェックするのは冗長なので事前チェックして変数化しておく。

__dirname__filename

コード中に __dirname__filename が含まれていた場合、標準では webpack がコンテキストにあわせて解決しようとする。これは Node として実行される Main プロセスにおいて問題になる。例えば BrowserWindow.loadURL のパスに指定すると正しくファイルを読み込めない。そのため無効化して Node として処理されるようにする。

{
  node: {
    __dirname: false,
    __filename: false
  }
}

Electron だと __filename を使用することはなさそうだが、いざ参照したくなったときに Node と異なる動作をすると厄介なのでついでに設定しておく。これで開発版だけでなくリソースを asar にパッケージ化したリリース版イメージでも想定どおりパスが処理される。Renderer ではそもそも __dirname などを参照することはないが、使用しないものについて webpack 設定を分岐する意味はないため Main/Renderer 共通の設定としている。

target

webpack の Target 設定はビルド対象の仕向けを指定することで import/require を適切に処理してくれる。Electron 向けとしては

Option Description
electron-main Compile for Electron for main process.
electron-renderer Compile for Electron for renderer process, providing a target using JsonpTemplatePlugin, FunctionModulePlugin for browser environments and NodeTargetPlugin and ExternalsPlugin for CommonJS and Electron built-in modules.

の 2 種類をサポートしている。Main プロセスをビルドする場合、そのまま Node/Electron のモジュールを Bundle すると参照エラーになる。よって electron-main を指定して実行時に参照解決されるようにする。

Renderer プロセスについては electron-rendererweb を指定する。前者だと webpack プラグインと組み合わせることで Node/Electron モジュールの参照を適切に処理してくれる。しかし私は Renderer 側で Node/Electron モジュールを使用しない主義であり、Main プロセスとの通信に必須の ipcRenderer に限定している。

Node/Electron の機能が必要ならば Main プロセスにリクエストすればよい。処理結果も Main プロセスから Renderer プロセス側へ返せる。よって Renderer の targetweb とした。

{
  target: MAIN ? 'electron-main' : 'web`
}

Renderer の require について。ipcRenderer を使用するために require が必要となる。しかしこれは直に使用せず

const ipc = window.require('electron').ipcRenderer
ipc.send('message', 'Hello world!!')

というように window に属するものとして明示的に呼び出す。こうすると Web フロントエンドとしての require or import と Node/Electron 由来の参照を明確に区別できる。そのため targetweb でも問題ない。これは Borwserify 時代からの対応だが、設計面でも Bundler 処理的にも好ましいと考えており、実際に webpack でもそのまま維持できた。

minify と環境変数 production

使用してる npm の項でも触れたが、今回のビルド設定では minify に babel-minify-webpack-plugin を使用している。これは babel-minify の wrapper である。これはかつて babili と呼ばれており、本ブログでも

で紹介したことがある。Babel ファミリーのツールだけあって ES.next を考慮した minify を実行してくれる。

Browserify と組み合わせると Babel (babelify) から先に実行されるため、Bundler 部分のコードが minify されない。単独 CLI だと Babel 処理に組み込めない。…といった問題があってイマイチだったのだが webpack だと Bundler 処理の一部として実行されるため、問題がすべて解決されている。

webpack の設定としては

{
  plugins: PROD ? [
    new MinifyPlugin({
      replace: {
        'replacements': [
          {
            'identifierName': 'DEBUG',
            'replacement': {
              'type': 'numericLiteral',
              'value': 0
            }
          }
        ]
      }
    }, {}),
    new WebPack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    })
  ] : [
    // development
  ]
}

となる。第一引数に babel-minify のパラメーター、第二引数でプラグイン独自のパラメーターを指定する。今回はデバッグ時の if-def 変数 DEBUG を無効化するようにした。この変数については

で言及しているので詳細はそちらを参照のこと。簡単に要約すると

if (DEBUG) {
  console.log('DEBUG MODE!!!')
}

みたいにデバッグ専用の処理を定義しておき、リリース用ビルドで処理をまるごと除去するためのもの。古式ゆかしいコンパイル分岐である。

リリース用ビルドにおける production 環境変数について。

npm によっては process.env.NODE_ENVdevelopmentproduction であることを判定してコンパイル分岐してる。本来ならさきほど紹介した例もこの方法で実装すべきだったが、それは将来の課題ということで。

process.env.NODE_ENV を変更する場合、CLI 実行であれば cross-env か webpack 処理で代入することになるのだが、標準プラグイン DefinePlugin で対応するほうがよいだろう。

DefinePlugin にしておけば他に環境変数を設定したいときも、ここへ局所化できる。webpack 公式でも Production の Specify the Environment でこの方法を紹介している。

これらの設定で minify すると適切に圧縮され、mangle による副産物としての難読化もおこなわれていることが確認できるはず。

original-fs

音楽プレーヤーのサンプルで Electron の original-fs を参照していたのだが、webpack の targetelectron-main にしていてもこれがエラーになる。

ERROR in ./src/js/main/model/MusicMetadataReader.js
Module not found: Error: Can't resolve 'original-fs' in '.../examples-electron/audio-player/src/js/main/model'

Electron は 2 種類の fs を提供しており original-fsasar をサポートしない Node 本来のもの。その処理を実装していた当時は Electron 版 fs だとなにか問題が起きると勘違いして明示的に original-fs を使用したほうがよいと考えていた。

しかし asar に関する処理をしないのであれば fs でもよいため、そのように修正した。

original-fs が本当に必要となったときは困りそうだから、後で webpack の issues を探してこの問題が報告されていなければレポートを挙げるかもしれない。

まとめ

webpack を使用しはじめたのは最近であること、Electron アプリで Bundler 処理する場合は Node と Web フロントエンドの両方を考慮しなければならないことから、かなり不安があった。

しかし Browserify 関連のツール チェインで要件を洗い出せていたのは大きかった。この種のツールに戸惑うのは要件が定まっていないからである。先に目的を明確にしていれば利用や代替はさほど難しくない。必要最小でよいのだし。今回もそんな感じでわりとスムーズに移行できた。

あと webpack の Environment Variables 機能のおかげで想像よりもずっとシンプルな設定にできて嬉しい。webpack を npm-scripts から実行する派ならファイル分岐を減らすのに効果抜群なのでかなりオススメ。

そういえば Electron サンプルとして examples-electron を公開してるけど、ずっと 3 プロジェクトなのもさみしいので簡易ファイラーを追加したいと考えてる。前から自分用に画像ビューアーがほしいと思ってて、その習作としてもよいのでは?というのがその理由。

ただ examples-electron は他にも

  • Flux を material-flux から reduxalmin に移行
    • 機能的に不満はないが Browserify から webpack へ移行したように一般的で勢いのあるものにしたい
    • redux は既に一般教養となってるから好みとは別に触れておくべきだろう
    • redux を class base にしたような almin も興味ある、なにしろ material-flux と同じ作者だ
  • AltCSS を Stylus 以外へ移行
    • AltCSS 系では Stylus が最も気に入っているけど開発側の熱は失われかけているため移行を検討したい
    • 2017/12 時点だと postcss が有力候補だろうか?
    • ただし Stylus 単体で実現していた機能を postcss のプラグインをかき集めて実現するのは面倒そうだ
    • 大きな問題が起きるまでは Stylus でよい、という考えもある
  • Test Runner を ava へ移行
    • 現在は mocha だが、これと組み合わせてる power-assert を ava が内部利用してるので出力機能としては同等
    • ava の機能としては並列実行に惹かれる
    • mocha も mocha.parallel があるけど ava だと単体で多機能なのでツール チェインを削減できる

といった課題がある。足回りを先にしたほうが生産性あがるのでサンプル追加より環境面を優先するかもしれない。その場合 redux/alumin 移行が作業と学習量として重く時間かかりそう。