テンプレートエンジンを Webpack と Express で動かす

この記事も、昔のプロジェクトで使われていたテンプレートエンジンを Node で無理やり動かそうとする試みの一つです。
前回の記事に引き続き、今回も Java の Spring の Thymeleaf expression を使って書かれた HTML ファイルをターゲットとして、サンプル程度に処理を実装してあります。

テンプレートを文字列補間を使う関数として出力できるようになったので、次はこれを開発時には Webpack 、運用時には Express で動くように設定を行い、快適に開発できるようにします。

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

文字列補間を使う関数化

前回の記事でやったような処理を、 Webpack の loader として実装します。
今回は文字の置き換えしか行わないことにして、簡単に実装すると次のように作りました。実質的には、好みに合わせてどのようなテンプレートも形作ることができるはずです。

loader.js
1
2
3
4
5
6
7
8
9
const loaderUtils = require('loader-utils');

const IDENTIFIER = '$_$';

module.exports = function loader(source) {
const newSource = source.replace(/\${(.+?)}/g, "$${guard(`$1`," + IDENTIFIER + ".$1)}");
return `const guard = require(${loaderUtils.stringifyRequest(this, require.resolve("./guard.js"))});
module.exports = (${IDENTIFIER}) => \`${newSource}\`;`;
};

guard の実装はこんな感じです。これは、 undefined[Object object] などと間違えて表示させないための処理です。

guard.js
1
2
3
4
5
6
7
8
9
10
11
12
module.exports = function guard(name, obj) {
if (typeof obj === 'undefined') {
throw new Error(`${name} is undefined.`);
}
if (typeof obj === 'object') {
throw new Error(`${name} is object or array.`);
}
if (typeof obj === 'function') {
throw new Error(`${name} is function.`);
}
return obj.toString();
};

この loader により、 Thymeleaf で書かれたテンプレートを関数にできます。開発時、運用時共に、出力される関数を利用するコードを記述します。

開発時の設定

開発時には、 Webpack Serve を使い、 Live Reload を行います。
テンプレート用 HTML ファイルだけでなく、仮の表示確認用データを編集しながら確認できるようになります。

この状況を整えるためには、開発時には以下の項目を見たすようなパイプラインを構成する必要があります。

  • 仮の表示確認用データを取得する
  • 関数化されたテンプレートにデータを渡して実行する
  • HTML が最終的な出力として得られる。
  • ファイル変更時の Live Reload が可能

これは、 Webpack の各種 loader ・ plugin と Webpack Serve の設定により可能になります。

最初の 3 つの項目に対する対策として、データを取得し関数を実行して HTML の文字列を返すような entryPoint となる JavaScript ファイルを作成します。

このための開発時用の Webpack の設定は次のようにしました。( “webpack-serve:1.0.4” では require.resolve("webpack-hot-client/client") がなければこれを対象にしたリロードはできません。)

webpack.config.dev.js
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
module.exports = {
mode: "development",
entry: {
"index": path.resolve(__dirname, "dev/presenter/index.js"),
"detail": [path.resolve(__dirname, "dev/presenter/detail.js"), require.resolve("webpack-hot-client/client")]
// webpack-hot-client/client is temporary necessary
},
output: {
path: path.resolve(__dirname, "public")
},
module: {
rules: [
{
test: /page.\w+\.html/,
use: [
{
loader: "./loader.js"
}
]
},
{
test: /dev.presenter.\w+\.js/,
use: ExtractTextPlugin.extract({
use: "noop-loader"
})
}
]
},
plugins: [
new ExtractTextPlugin("[name].html"),
]
};

entryPoint 用のファイルは、次のような簡単なファイルです。どのエントリーポイントでも同じようなものが必要となる、ボイラープレート的ファイルになります。

dev/presenter/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const page = require("../../src/page/index.html");

const data = require("../data/index.js");

let result;
try {
result = page(data) +
/* This is for reloading. */
`<script src="index.js"></script>`;
}
catch (e) {
result = `<html><body><pre>${e.stack}</pre><script src="index.js"></script></body></html>`;
}

module.exports = result;

HTML 文字列だけを返すようにすると、 Webpack Serve の効果を受けられなくなるため、( ExtractTextPlugin に抜き取られて空になった) JS ファイルも無理やり追加しておきます。

次に Webpack Serve の設定をします。(この記事にとって重要な部分だけ抜粋)
どうやら今回 HTML として出力するものに対しては watch が初期状態では効かないようなので、これを行う設定を加える必要があります。
参考: webpack-serve watch content

webpack.config.dev.js
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
port: 8080,
hot: {
host: "localhost",
port: 8090,
allEntries: true
},
on: {
// To reload after src or dev data is modified
listening(server) {
const socket = new WebSocket("ws://localhost:8090");
const watchPath = ["src", "dev"];
const options = {};
const watcher = chokidar.watch(watchPath, options);

watcher.on('change', () => {
const data = {
type: 'broadcast',
data: {
type: 'window-reload',
data: {},
},
};

socket.send(JSON.stringify(data));
});

socket.on('close', () => {
watcher.close();
});
},
},

‘src’ (テンプレート用ファイルの場所)と ‘dev’ (開発時用の偽データと entryPoint のある場所)の 2 箇所を watch し、対象先の中でファイルの変更があれば、すべての画面で強制リロードします。
allEntries については “webpack-serve:1.0.4” では対応していません。)

この状態で webpack-serve を実行する(サンプルでは yarn run dev )と、 http://localhost:8080/ で Live Reload が有効なページを閲覧できます。( ‘dev/data/index.js’ を編集してみてください。)

運用時の設定

運用時には Express を使ってページを表示させます。これは一般的なサーバーサイドレンダリングと同じです。
運用時には、以下の項目を満たすようにサーバーや生成物を構成する必要があります。

  • テンプレートはすでに関数化されている
  • データが引数として与えられればそのまま動く
  • 元々のファイル毎にルーティングとデータの取得を行う

こちらも、上述したような Webpack の loader と Express の設定だけで実現できます。

まずは、運用向けにビルドするための Webpack の設定は次のようになります。
出力物が Node ファイルから require されると、それぞれの entryPoint が関数として利用できるようになる、という構成にしてあります。
単に HTML ファイルからの変換だけが必要になるので、開発時用と比べると簡単になります。
(サンプルでは yarn run build

webpack.config.prod.js
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
module.exports = {
mode: "production",
entry: {
"index": path.resolve(__dirname, "src/page/index.html"),
"detail": path.resolve(__dirname, "src/page/detail.html")
},
output: {
path: path.resolve(__dirname, "public"),
libraryTarget: "umd"
},
module: {
rules: [
{
test: /page.\w+\.html/,
use: [
{
loader: "./loader.js"
}
]
}
]
},
target: "node"
};

このビルドの生成物を使うようにするサーバーの設定は、次のように書けます。
(サンプルでは yarn start

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const express = require("express");
const app = express();

const server = app.listen(3000, () => {
console.log("Listening: http://localhost:" + server.address().port);
});

const indexTemplate = require("./public/index.js");
const detailTemplate = require("./public/detail.js");

app.get("/", (req, res) => {
// get data from some where
const data = {home: {welcome: "Welcome to Express!"}};
const response = indexTemplate(data); // should treat exception
res.send(response);
});

app.get("/detail", (req, res) => {
// get data from some where
const data = {detail: "Express 4.16"};
const response = detailTemplate(data); // should treat exception
res.send(response);
});

http://localhost:3000 から、それぞれのページにアクセスできます。
サンプル上では data 取得部分は適当に作っていますが、別の API サーバーから取ってきた値にすれば、それっぽい動きになります。

まとめ

昔ながらのテンプレートエンジンについても、 Webpack を使うことで、開発時にはLive Reload を行いつつ、運用時には( Node 上で)効率良く動作できるような仕組みを作ることができました。