おふくろさまより愛をこめて

mmmpa ふとしです。誠実なプログラミングを心がけたい。

簡単な Webpack plugin を作成して Webpack と仲良くなる (ビルド時情報を console.log に表示する)

JavaScript Advent Calendar 2018 - Qiita 5 日目のエントリーです。未投稿だったので差し込みました。

このエントリーは Webpack 4.26.1 を基準に書かれました。このエントリー用に書いたコードは mmmpa/create-webpack-plugin-practice にあります。

開発者以外の方々に改修後の JavaScript の動作確認を行っていただくことは多々あります。そこでよく問題になるのが、ブラウザキャッシュにより、改修以前の状態を確認されてしまうという事態です。

見栄えに関わる単純な改修だとキャッシュが効いていると察しがつく場合もありますが、ロジック関係の修正だと非常に気づきにくくなり「なおってません」という連絡をいただくことになります。

そこで意図したビルドを確認していただいているかを明確にするために、 JavaScript にビルド日時や Git のコミットハッシュ値を埋めこみ、それを双方からの連絡時に併記するという方法があります。

自動化のために Webpack plugin を作成する

手作業で埋めこむのは現実的ではありませんから、ビルドを行う段で自動的に JavaScript に埋めこまれるようにします。ソースをみていただくようなことはできないので、起動時にコンソールに表示されるようにします。

今回はこの単純な plugin を作成する手順を書いていきます。

Webpack config に plugin を設定する

まだ plugin を用意していませんが、設定は可能です。一般的に plugin はクラスとして実装され、インスタンス化された形で Webpack 設定ファイルの plugins に挿入されます。plugin の名前を BuildingInformationPlugin とし、そのように設定を変更します。

const path = require('path')
const BuildingInformationPlugin = require('./building-information-plugin')

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'index.js',
  },
  module: {},
  plugins: [new BuildingInformationPlugin()],
}

下記のような index.js を読みこみ、index.js を出力します。今回 loader は主題ではないので一つも設定していません。

console.log('Start my main process.')

Start my main process. が出力される前にビルド情報が表示されるように plugin を書いていきます。

plugin のコードを書く

plugin には Webpack から引数として compilation オブジェクトを受けとる apply メソッドが必要です。

class BuildingInformationPlugin {
  apply(compiler) {
  }
}

module.exports = BuildingInformationPlugin

apply は起動時に一度だけ Webpack から呼びだされます。その時に compilation オブジェクトに対して、この plugin がどのタイミングでどのような作用をするか設定します。

表示用のコードを埋めこむタイミングを決める

Webpack plugin はビルドにおける各段階にフックして様々な介入を行えます。

ビルド前のファイルは loader で処理されることを前提としているため、下手に手を加えると処理の内容を変えてしまうかもしれないため、タイミングとしては不適切です (lint 系の回避など無駄な手間がある)。そこで今回は assets として JavaScript ファイルが書き出された段階で、そのファイルの先頭に console.log を挿入したいと思います。

Compiler hooks

各段階のフック一覧 を見ると afterEmit というフックがありますので、これを使います。

After emitting assets to output dir

フックをかける

compiler.hooks[hookName].tap(name, callback) とすると任意の段階にフックすることができます。callback の引数は各メソッドの Parameters に記してあるもので、afterEmit の場合は compilation が渡されます。compilation は compile におけるさまざまな情報が入ったオブジェクトです。

  apply(compiler) {
+   compiler.hooks.afterEmit.tap('BuildInformationPlugin', (compilation) => {
+   })
  }

加工するファイルのありかを知る

前述したとおり compilation は compile におけるさまざまな情報が入っています。なので、Webpack の output 設定を参照すれば最終的に出力されるファイルを知ることができます。

  apply(compiler) {
    compiler.hooks.afterEmit.tap('BuildInformationPlugin', (compilation) => {
+     const { options: { output: { path: dir, filename } } } = compilation
+     const at = path.resolve(dir, filename)
    })
  }

情報を表示する console.log を埋めこむ

ここまでくればあとはファイルを読みこみ、console.log を追加して上書きするだけです。

  apply(compiler) {
+   const hash = childProcess.execSync('git rev-parse HEAD').toString().replace(/[\n\r]/, '')
+
    compiler.hooks.afterEmit.tap('BuildInformationPlugin', (compilation) => {
      const { options: { output: { path: dir, filename } } } = compilation
      const at = path.resolve(dir, filename)
+
+     const log = [
+       '##################################################',
+       `commit:   ${hash}`,
+       `built at: ${new Date().toLocaleString()}`,
+       '##################################################',
+     ].join('\n')
+      
+     fs.writeFileSync(at, [
+       `console.log(${JSON.stringify(log)});`,
+       fs.readFileSync(at),
+     ].join('\n\n'))
    })
  }

ビルドする

console.log("##################################################\ncommit:   93cd2cfa63730e7641988bf169aada2bac8b0382\nbuilt at: 2018-12-3 11:26:33\n##################################################");

!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=0)}([function(e,t){console.log("Start my main process.")}]);

これを HTML に読みこむと、無事、ビルド情報が起動前に表示されました。

##################################################
commit:   93cd2cfa63730e7641988bf169aada2bac8b0382
built at: 2018-12-3 11:26:33
##################################################
Start my main process.

まとめ

このように Webpack plugin は非常に簡単に書くことができます。

パッケージとして配布するような汎用的な plugin ではこのように簡単には行きませんが、開発に合わせた使い捨ての plugin はカジュアルに書いていけるというのがわかっていただけると思います。今回の場合では、本体側のコードを変更することなく開発に有用な処理を埋めこむことができました。

難しい、複雑であると思われがちな Webpack ですが (実際設定で難航することはありますが)、こうやって付き合っていくうちにもっと仲良くなれたらいいなと思っています。