webpack
YARN
lerna
medibaDay 23

yarn workspace, lerna で始めるフロントエンド・ガチ袋 〜 俺のCLI 〜

この記事は mediba advent calendar 2017 23日目の記事です。
フロントエンド開発部の武田 @tkdn が担当します。

プロダクション・趣味の素振りに限らず、自分の鋳型をよっこらせと持ってきて作り始めたり、要件に合わせて設定を足し引きしたりしているのですが、package.json の devDeps も webpack.config.js も見たくなくなります。混雑してくると npm scripts に呪文感が出てきます。

今日は、その辺の構築ストレスを軽減するために、自分のためにポータブルなガチ袋と詰め込むツーリングを作ったという話です。

お送りしたい人

時代は Zero Configuration らしいんですが、今日は webpack の話をします。ターゲットはこんな方向けです。

  • webpack をよく使う・まだまだ使っていきな人
  • Zero Config がプロダクション向きではないと考えている人
  • 素振りする時にいつもの鋳型こねこねするのが面倒な人

ガチ袋?

あまり馴染みがないと思いますが、舞台設営やスタッフの方、大工さん、内装屋さんなどが道具を入れるため腰に巻くベルトです。

シンプルなオンリーワンのツールだと汎用性に欠けるので、各種ツーリングを入れるためのガチ袋をイメージして、必要なものだけを積んで現場に持ち込みたいというのが今回のモチベーションです。

どう作るか

やりたいことの構想としては下記のようになります。

  1. :package: 作るパッケージすべてが相互依存するので yarn workspacelerna でフラットに依存をモノレポ管理してレジストラにパブリッシュ
  2. :construction_worker: ガチ袋 = webpack をベースにした cli
    • ガチ袋に詰め込むツーリングを設定から読み込める
  3. :wrench: Babel = babel-loader で JavaScript をトランスパイルする webpack.config
    • 2 の設定からオプションを受け取れるインタフェースでエクスポート
  4. :electric_plug: Preset = 3 で使用するための babel-preset
    • 環境に合わせて柔軟にトランスパイルしたい = @babel/preset-env ベース
  5. :hammer: Sass = sass-loader で scss ファイルを書き出す webpack.config
    • 2 の設定からオプションを受け取れるインタフェースでエクスポート
    • 環境に合わせて柔軟に autoprefixer したい

参考にすべき先生たち

参考にするにはすべておそれ多いんですが

この辺が参考になりそうです。

で、作った

  1. モノレポ: tkdn/toolbelt
  2. @tkdn/toolbelt-cli - npm
  3. @tkdn/toolbelt-babel - npm
  4. @tkdn/babel-preset-toolbelt - npm
  5. @tkdn/toolbelt-sass - npm

ここから長くなりそうなので、とりあえず動かしたい人はサンプル見てください

1. :package: yarn workspace, lerna 準備

yarn workspace は v1 からの機能で、ワークスペースにより複数のパッケージを設定する際に、 yarn install を一度実行するだけで、それらの全てが単一のパスにインストールされるようになります。下層のパッケージ指定を
package.json に。

package.json
{
  "workspaces": [
    "packages/*"
  ]
}

なお、リポジトリルートには下記記述の .yarnrc が必要です。

.yarnrc
workspaces-experimental true

lerna は適宜入れていただき、ドキュメントに沿って init するなりしてください。lerna で自分がちょっとハマったのは namescoped なパッケージのパブリッシュ時に private パッケージと判別され lerna run publish がコケるので、下記のような記述が下層の package.json に必要な点です。

package.json
{
  "publishConfig": {
    "access": "public"
  }
}

2. :construction_worker: CLI

コマンドとして dev, build だけ受け取れるようにしました。設定ファイルはオプションで受け取れます。

Usage: toolbelt-cli

コマンド:
  toolbelt-cli dev    For develoment, toolbelt watches and builds files.
  toolbelt-cli build  For production, toolbelt builds files.

オプション:
  -v, --version  バージョンを表示                                         [真偽]
  -h, --help     ヘルプを表示                                             [真偽]
  -c, --config   toolbelt setting file(JSON)       [デフォルト: "./.toolbeltrc"]
  • dev => process.env.NODE_ENV === develoment で監視しつつビルド
  • build => process.env.NODE_ENV === production でビルド

:clipboard: 設定ファイル

.toolbeltrc
{
  "dev": {
    "targets": {
      "browsers": ["last 1 Chrome versions"]
    },
    "browserSync": "./config/bsconfig.js",
    "webpack": [
      "./config/toolbelt.babel.js",
      "./config/toolbelt.sass.js"
    ]
  },
  "build": {
    "targets": {
      "browsers": ["Android >= 4", "safari >= 9"]
    },
    "webpack": [
      "./config/toolbelt.babel.js",
      "./config/toolbelt.sass.js"
    ]
  }
}

トップレベルのフィールドは各コマンドです。

targets フィールド

browserlist の DSL を記述し、babel, autoprefixer に利用します

browserSync フィールド

このフィールドはなくても動きます。ただ dev-server 的なものは起動しないのでソースファイルを監視しながらファイルがディスクに書き出されるだけです。

個人的には、バックエンドのアプリにプロキシして特定のアセットディレクトリのみプロキシサーバに配信したいシーンがどうしても多いので設定に組み込みます。webpack-dev-server だと route でしかマッピングできなかった認識なのですが間違ってたら教えて下さい。

browserSync の middleware として webpack-dev-middleware を差し込む構成です。この場合だと、ファイルはオンメモリ上にあるのでディスクに書き出しません。

あとはレスポンスだけほしい 未開発の API をモックしたい時に middlware として差し込んだりもするので browserSync にしてます。

webpack フィールド

配列にある設定ファイルの数だけ webpack([configA],[configB]...)するイメージです。
config 自体は module.exports = {} な設定オブジェクトそのままエクスポートしたものでも大丈夫ですが、下記のように設定ファイルからオプションオブジェクトを引数にして CLI 側の webpack に渡すことが可能です。

module.exports = options => {
  const {
    targets, // 設定ファイルから読まれるフィールドが入ってきます
    hasBabelrc, // 作業ディレクトリルートに `.babelrc` があれば true ※ 3 で使用
    env // process.env.NODE_ENV が格納されて返ってきます
  } = options

  // options に入ったプロパティ値を元に可変させる
  plugins: env === 'production'
    ? [new webpack.optimize.UglifyJsPlugin()]
    : []
  )
}

コード

CLI は雑に書くと下記のようなことをやってるけだけです。

const compiler = webpack(mergedConfig)
if (command === 'dev') {
  compiler.watch(true, (err, stats) => {})
}
if (command === 'build') {
  compiler.run((err, stats) => {})
}

3. :wrench: toolbelt-babel

上記でも受け取れる options(arg1) と webpack 設定(arg2) を引数にして、このパッケージ自身が提供する webpack の設定と arg2 をマージして設定を返却します。もうこの辺からお前は何を言っているんだ感ありますね。

module.exports = (options, config) => {
  merge({
    // パッケージの webpack 設定
  }, config)
}

内部的には options.hasBabelrc === true で 作業ディレクトリ直下の .babelrc を優先し、false なら 4. に options.targets を渡して preset とします。ますます説明が混乱してます。下手くそか。
options.env で sourcemap/UglifyJS なども切り替えます。

4. :electric_plug: babel-preset-toolbelt

だんだん面倒になってきたのでパッケージのソースそのまま貼ります。babel-preset-env に設定ファイルのtargetsフィールドのオブジェクトそのまま渡すだけのパッケージです。

module.exports = (ctx, options) => {
  const { debug } = options
  return {
    presets: [
      [require.resolve('babel-preset-env'),
        {
          debug: debug || false,
          targets: options.targets || { browsers: ['last 1 Chrome versions'] },
          modules: false,
          loose: true,
          useBuiltIns: true
        }
      ]
    ],
    plugins: [
      [require('babel-plugin-transform-class-properties'), { loose: true }],
      [require('babel-plugin-transform-object-rest-spread'), { useBuiltIns: true }]
    ]
  }
}

です。
※ 記事とは関係ないですが、この構成のまま Babel7 に総じて上げてみましたが、ソース上のパッケージ名と useBuiltins: 'usage' 以外は変更するところがありませんでした。

5. :hammer: toolbelt-sass

3.のインタフェースと同じで、targetsフィールドのオブジェクトを受け取って autoprefixer(targets) 噛ましたり、options.env で sourcemap 吐き出す/outputStyle 変える、option.includePaths があれば指定モジュールまでのパスを通す、くらいです。

:pencil: サンプル再掲

https://github.com/tkdn/toolbelt/tree/master/sample

実行するとこんな感じです。

求めていたガチ袋と俺の CLI、袋に詰めるツーリングが数個出来上がりました。

まとめ

まあまあ満足できるガチ袋ができたのですが、これは私が求めているもので、皆さんが求めているものとは違うかもしれません。

人には人のガチ袋。

もしガチ袋が必要なら自分で作ってみるのもいいんじゃないでしょうか。

FYI