Closure Library のプロジェクトで Webpack の HMR を使う

昔のプロジェクトで使われていた Closure Library を Webpack を使って楽に開発できないかと思うきっかけがあり、サンプルプロジェクトを使って Hot Module Replacement までやってみました。

サンプルプロジェクトはこちら

状況

元のプロジェクトとしては https://developers.google.com/closure/library/docs/tutorial を利用しています。
ノートを追加するボタンを追加した main を ‘src/js/main.js’ に切り出し、 HMR イベント発生時のコードを ‘src/js/hmr-append.js’ に追加しています。
さらに、 SCSS で記述したスタイルも ‘src/scss/style.scss’ として追加しています。

Closure Compiler によるコンパイル処理は、 closure-builder で簡単にやってしまいます。
この Node パッケージは、 Closure Library も内包しているため、今回はこの中のファイルを参照するようビルド設定をしています。
(ただし、インストールスクリプトが走るとかなりの時間がかかるため、 google-closure-library の Node パッケージ内にある ‘compiler.jar’ と google-closure-library パッケージに付属している ‘closurebuilder.py’ を使い、自分で処理を組んだほうが楽かもしれません。)

さて、まずは Closure Compiler によるコンパイル処理をとりあえず構成したいと思います。
closure-builder の説明には ‘The Google Closure library will be included automatically if needed.’ とありますので、それについて今回は何も手を加える必要はありません。
ファイル名以外は何も考えることなく設定します。( なぜか sourceMap が上手く動いてくれません。 )

build.js
1
2
3
4
5
6
7
8
9
10
11
12
const closureBuilder = require('closure-builder');
const glob = closureBuilder.globSupport();

closureBuilder.build({
name: 'standard',
srcs: glob([
'src/js/*.js',
]),
out: 'demo/bundle.js',
out_source_map: 'demo/bundle.js.map',
append: '//@ sourceMappingURL=bundle.js.map'
});

node build.js などで実行すると、少しの時間が経過した後に ‘demo/bundle.js’ が生成物として出力されるはずです。
当然、これを html から呼び出せば、依存するスクリプトをすべて明示したのと同じように呼び出せます。

ただし、毎回コンパイルしているのでは開発時は待ち時間が長くなってしまうため、上述したように依存するスクリプトを全て呼び出すようにして開発していきます。

<!-- dev -->
<script src="../node_modules/closure-builder/third_party/closure-library/closure/goog/base.js"></script>
<script src="../src/js/notepad.js"></script>
<script src="../src/js/main.js"></script>
<!-- prod -->
<script src="bundle.js"></script>

ただし、これ開発時とリリース時に切り替える必要があり面倒臭く、新しくファイルを追加するたびにタグを加えなければなりません。
今回は、 Webpack を使ってこの問題を解決し、さらに Hot Module Replacement も実現します。

Webpack 導入

Live Reload まで

さて、ここで Webpack を適応しようとしても、 Closure スクリプトの多くは goog.providegoog.require による依存解決を行っているため、そのままでは上手くまとめられません。
うれしいことに、すでに closure-loader というものが作られているので使ってしまいます。
リリース用では yarn build では Closure Compiler の方で生成するとして、開発用として Webpack Dev Server を使う前提で設定します。

まずは自作スクリプトの依存解決をします。 https://github.com/mxmul/closure-loader/issues/28 が参考になります。
自作スクリプトでの goog.require の参照先は、別の自作スクリプトか Closure Library のため、 path オプションでそれを指定します。

webpack.config.dev.js
26
27
28
29
{
test: /src.*\.js$/,
exclude: /node_modules/,
use: [
0
/* omit */
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
    {
loader: 'closure-loader',
options: {
paths: [
path.resolve(__dirname, 'src/js'),
path.resolve(
__dirname,
'node_modules/closure-builder/third_party/closure-library/closure/goog'
)
],
es6mode: true,
watch: true,
fileExt: '.js',
}
}
]
},

続いて、 Closure Library の依存解決をします。
当然ながら、 goog.require の参照先は、 Closure Library 内で完結しているので、 path オプションでそれを指定します。

webpack.config.dev.js
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
{
test: /node_modules.closure-builder.third_party.closure-library.closure.goog.*\.js$/,
loader: 'closure-loader',
options: {
paths: [
path.resolve(
__dirname,
'node_modules/closure-builder/third_party/closure-library/closure/goog'
)
],
es6mode: true,
watch: true,
fileExt: '.js',
},
exclude: /node_modules.closure-builder.third_party.closure-library.closure.goog.base\.js$/,
},

最後に、 Closure Library の中で使われている ‘base.js’ に対する設定をします。(goog 以下の、 Closure Library のシステムを支える関数が含まれる。)
73 行目で 1つだけ “exclude” されている理由です。

webpack.config.dev.js
75
76
77
78
79
80
81
{
test: /node_modules.closure-builder.third_party.closure-library.closure.goog.base\.js/,
use: [
'imports-loader?this=>{goog:{}},goog=>this.goog',
'exports-loader?goog',
],
},
webpack.config.dev.js
105
106
107
new webpack.ProvidePlugin({
goog: 'closure-builder/third_party/closure-library/closure/goog/base'
}),

goog.require からの呼び出しとグローバルでも呼び出しに対応した goog 変数を用意する」と解釈すれば多分大丈夫そうです。
imports-loader, exports-loader, ProvidePlugin

あとは modeentrydevServer を指定して Webpack Dev Server を起動すれば、 Live Reload まではできるようになっているはずです。( HMR は未設定)
ここまでで、 <script> タグ群の切り替えをしなければならない事態は解消され、スクリプト修正後も検知してリロードしてくれます。

HMR まで

今まではツールを使ってばかりいたので、勝手に HMR までやってくれると思っていましたが、実際には明示的に処理を書き込まなければなりません。
Closure Compiler の方で生成されるリリース版に処理が書き加えられるのが嫌だったので、 ‘webpack.config.dev.js’ L30-L41 のように外部ファイル化して読み込むようにしてみました。もしリリース版でも Webpack を使うのであれば、何らかの loader か plugin で消去してしまうのもいいでしょう。

この部分は実装する機能とデータの持ち方でかなり変わってくるので参考程度にしかなりませんが、載せておきます。
私見ですが、 Closure Library で作られたアプリケーションでは状態の保持場所がカプセル化して散らばっていると思うので、割り切ってスクリプトは Live Reload にとどめておくのも手だと思います。
モジュールをHMRに対応するための実装について - Qiita がとても参考になります。

hmr-append.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// I wish that were pure or idempotent...
if (module.hot) {
module.hot.accept();
if (module.hot.data) {
noteData = module.hot.data.noteData;
var elem = document.getElementById('notes');
while (elem.firstChild) {
elem.removeChild(elem.firstChild);
}
tutorial.notepad.makeNotes(noteData, elem);
}
module.hot.dispose(function (data) {
var elem = document.getElementById('notes');
while (elem.firstChild) {
elem.removeChild(elem.firstChild);
}

elem = document.getElementById('add-button');
while (elem.firstChild) {
elem.removeChild(elem.firstChild);
}
data.noteData = noteData;
});
}

無理矢理にではありますが、 HMR 可能であることの明示とデータの受け渡し、要素の初期化を行っています。
‘webpack.config.dev.js’ L108, L112, L117 のように設定して Webpack Dev Server を起動すると、追加したノートもそのまま表示してくれるようになります。

スタイルも HMR 可能に

最後に SCSS も設定してしまいます。まずはページに適用する SCSS ファイルの大元を entry に加えます。

webpack.config.dev.js
13
14
15
16
17
18
entry: {
bundle: [
path.resolve(__dirname, "src/js/main.js"),
path.resolve(__dirname, "src/scss/style.scss")
]
},

(※ js ファイル用の設定も含む。)

SCSS ファイルの取り込みにはお馴染みの sass-loadercss-loader を使います。
加えて、 <link> タグで外部 CSS ファイルを読み込んでいるであろうことから、スタイルをまとめて生成してくれる mini-css-extract-plugin と、その状況で HMR を使うために css-hot-loader も使います。

webpack.config.dev.js
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
{
test: /\.scss$/,
use: [
'css-hot-loader',
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
sourceMap: true,
importLoaders: 1
}
},
{
loader: 'sass-loader',
options: {
sourceMap: true
}
}
]
}
webpack.config.dev.js
109
new MiniCssExtractPlugin('bundle.css')

(※ entry に指定したキー値になる。この行で ‘hoge.css’ と指定しても ‘bundle.css’ となって出力される。)

これでスタイルの HMR も完成しました。