ejs, pugやめてtsxでhtml書かね？


## はじめに
htmlテンプレートエンジン選定のお話です。
『モダンフロントエンド』とは呼ばれないような案件では、まだまだejs, pugなどのhtmlテンプレートエンジンライブラリを用いてhtmlを記述、納品しています。
今回はそんなテンプレートエンジンとしてtsxを採用しようじゃないかというお話です。




## 結論
tsxファイルを`react-dom`の機能である`renderToStaticMarkup`から文字列化し、htmlファイルとして吐き出します。これにより**tsxの記法でhtmlを生成する**ことができます。
webpackでコンパイルを自動化した一例は以下です。

https://github.com/ueda-kio/tsx-html-templateEngine


## ejs, pugの限界
ejs, pugはhtml内にJavaScriptを記述することができ、`if, for, switch, try/catch`などのjsシンタックスや`let, const`による変数宣言、また`include`によるパーシャルファイルの切り出し・読み込みなどが行え、効率よくhtmlを記述することができます。
しかしプロジェクトが大きくなってくると色々と限界を感じてくることかと思います。
- **lintチェックができない**（探せばIDEの拡張機能とかはあるかも）
- **`include`の引数に型がつけられない**
- **コンパイルまでエラーがわからない**&失敗時の**エラー箇所の特定がしづらい**
- その他**皆さんが感じている限界**

上記を解決すべく、htmlのテンプレートエンジンとしてReactの構文tsxを導入してみようと思います。

## tsxの利点
### そもそもtsxって？
JavaScriptの構文を拡張したものであるjsxを、TypeScriptで記述したものです。
jsxの詳しい説明はこちら。

https://ja.reactjs.org/docs/introducing-jsx.html

ejsが「htmlの中にJavaScriptを記述できる」というイメージに対して、jsxは「JavaScriptの中でhtmlを記述できる」というイメージです。

### ejs, pugで感じた限界はtsxで解決できる
前提として上記で登場したejs, pugの機能である`if, for, const, include`などはすべてtsxで実現可能です。
その上で、ejs, pugで感じた問題を解決することができます。

- lintチェックできない問題
	- eslintがそのまま走るので解決。
- `include`の引数に型がつけられない問題
	- tsx(/jsx)の機能である`props`として引数をパーシャルファイルへ渡すので、静的型付けが可能。
- エラー箇所わかりづらい問題
	- eslintが走るのでコーディング時に解決

また、Reactアプリケーションを作るわけではないので学習コストが低いことも利点です。
単にjsxの構文（`if`はワンライナー、`for`は`map`など）を覚えればよく、ejs, pugの構文を覚えるのと大差ないと思います。

## tsxをhtmlに変換する方法
では具体的な実装方法を見ていきましょう。
まずtsxをhtmlに変換する方法として`ReactDOMServer`オブジェクトの`renderToStaticMarkup`メソッドを使用します。

https://ja.reactjs.org/docs/react-dom-server.html#rendertostaticmarkup

tsxはReactによって一旦JavaScriptに変換されますが、上記の機能を用いることで静的なhtml文字列へとNodeサーバー上で変換することができます。

webpackを使う方法、使わない方法の２つの方法をご紹介します。

### 1. webpack使わない方法

まずはwebpackを使わずにシンプルにtsxを`ts-node`でコンパイルし、htmlを吐き出す方法を見ていきましょう。
必要なパッケージをインストールします。

```shell
yarn add -D @types/node @types/react @types/react-dom react react-dom ts-node typescript
```
適当に`tsconfig.json`を用意し（`"jsx": "react-jsx"`を設定する必要があります）以下のようなファイルを用意します。

```tsx:page.tsx
import ReactDOMServer from "react-dom/server";

const App = () => {
  return (
    <div>
      <h1>App Page.</h1>
      <p>description.</p>
    </div>
  );
};

const pageString = ReactDOMServer.renderToString(<App />);

const page = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Title</title>
</head>
<body>
${pageString}
</body>
</html>
`;

console.log(page);
export default page;
```
以下コマンドを実行するとコンソールに文字列化したhtmlが出力されます。
tsxからhtml文字列を生成できることがわかります。

```shell
$ ts-node page.tsx
```

あとはこの文字列を元にhtmlファイルを生成すればよいです。
Node.jsの機能を使えば方法はいくつもあるかと思いますが、以下は一例です。

```tsx:index.ts
import fs from "fs";
import path from "path";
import page from "./page";

const writeFile = async (file: string, data: string): Promise<void> => {
  await fs.promises.mkdir(path.dirname(file), { recursive: true });
  fs.promises.writeFile(file, data);
};

const filename = path.basename(__filename, ".ts");
writeFile(path.resolve(__dirname, `build/${filename}.html`), page);
```
実行すると`build/index.html`が吐き出されます。
```shell
$ ts-node index.ts
```
htmlテンプレートエンジンとしてtsxを使うことができました！

### 2. webpackを使う方法
さて、上記方法でtsxからhtmlを生成できることがわかりました。が、必要なページが増えた時少し面倒そうです。
ということでwebpackを用いて自動コンパイル環境を構築しましょう。
（webpack不要です、という方は以降不要です。）

まずは必要なパッケージをインストールします。
```shell
$ yarn add -D @types/react @types/react-dom esbuild-loader globule html-webpack-plugin html-webpack-skip-assets-plugin prettier react react-dom@17.0.2 typescript webpack webpack-cli webpack-dev-server
```

:::note warn
**※注意※**
`react-dom`ライブラリの最新版は22/10/05現在ver.18ですが、なぜだかver.18だと`Error: TextEncoder is not defined`というエラーでコンパイルに失敗します。（理由をご存知の方はぜひ...）
ということで、ver.17をインストールしてます。
:::




先程の`page.tsx`のようなhtml文字列を`export`しているtsxファイルをgrepし、`HtmlWebpackPlugin`でhtmlファイルへと自動コンパイルします。
MPAらしく画面が増えてもgrepできるようにスクリプトを書いていきます。grepには`globule`ライブラリを使用しました。
```js:webpack.config.js
const path = require('path');
const globule = require('globule');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {HtmlWebpackSkipAssetsPlugin} = require('html-webpack-skip-assets-plugin');

const assignPlugins = (env) => {
  const globuleFiles = ['**/*.tsx', '!**/_*.tsx', '!**/_partials/**/*.tsx'];

  /** 指定されたディレクトリからgrepしたtsxファイル */
  const templateFiles = globule.find([...globuleFiles], {cwd: `${__dirname}/src/pages`});

  /** entryファイルを格納したオブジェクトを作成 */
  const entriesList = templateFiles.reduce((temp, current) => {
    temp[`${current.replace(new RegExp(`.tsx`, 'i'), `.html`)}`] = `${__dirname}/src/pages/${current}`;
    return temp;
  }, {});

  const assignObject = { plugins: [] };
  for (const [htmlFileName, tempFileName] of Object.entries(entriesList)) {
    assignObject.plugins.push(new HtmlWebpackPlugin({
      filename: htmlFileName,
      template: tempFileName
    }));
    env.WEBPACK_BUILD && assignObject.plugins.push(new HtmlWebpackSkipAssetsPlugin({ excludeAssets: [/entry.js/] }));
  }

  return assignObject;
};

module.exports = (env) => (
  Object.assign({
    entry: './src/entry',
    output: {
      path: path.join(__dirname, 'dist'),
      filename: 'entry.js'
    },
    devtool: false,
    watchOptions: {
      ignored: /node_modules/
    },
    resolve: {
      extensions: ['.js', '.ts', '.tsx'],
    },
    module: {
      rules: [
        {
          test: /\.tsx$/,
          use: [
            {
              loader: 'esbuild-loader',
              options: {
                loader: 'tsx',
              }
            }
          ]
        },
      ],
    },
    devServer: {
      ...
      watchFiles: [
        'src/**/*.tsx'
      ],
    },
  }, assignPlugins(env));
);
```

エントリーファイルを用意する必要があるので、適当にsrcディレクトリ直下に`entry.js`を用意します。

:::note info
**Point**
- エントリーファイルである`entry.js`はビルド時に自動でhtml内に`script`要素から読み込まれますが不要ファイルなので`HtmlWebpackSkipAssetsPlugin`で吐き出されないようにします。<br>ただ開発中はこのエントリーファイルを読み込まないとコンパイル対象となってくれないため`env.WEBPACK_BUILD`が`true`の時（ビルド時）のみこのプラグインを読み込むようにします。
- tsxファイルの変更に応じてwebpack-dev-serverをホットリロードさせるために、`devServer.watchFiles`オプションに`'src/**/*.tsx'`を指定します。
:::



▼ htmlを吐き出すだけの最小環境はこちらです

https://github.com/ueda-kio/tsx-html-templateEngine


## おまけ
せっかくwebpack環境を構築したので、どうせならsass, typescriptも一緒にコンパイルできる環境を作ってみました。

https://github.com/ueda-kio/dev-env

`yarn start`でローカルサーバーが立ち上がり`yarn build`で`dist` ディレクトリ配下にビルドします。
画像圧縮は諸々案件毎に設定必要そうですので一旦入れてません。

#### 【さらにおまけ】html → tsxへの変換

エンハンスなどですでに存在するhtmlファイルをtsxへ変換し開発を行う場合、諸々書き換えやバッククォートのエスケープなど必要な処理が発生し手動で行うのは面倒/大変だと感じたので、スクリプトで自動化させました。
`pages` ディレクトリ配下に変換前のhtmlファイルを新規で配置し、以下コマンドを実行すると同名のtsxファイルを生成します。（htmlファイルは削除されます）
```shell
$ node convertHtmlToTsx.js
```
gitで新規ファイル差分のファイルを一括で変換するので、複数ファイル同時に変換可能です。
ソースコードは[こちら](https://github.com/ueda-kio/dev-env/blob/main/convertHtmlToTsx.js)です。

## まとめ
htmlテンプレートエンジンとしてtsxを使って環境構築してみました。
昨今はSPA, SSR, SSGが主流となりwebpack自体も枯れてきてますが、まだまだ静的なhtmlを納品しているプロジェクトは残っているかと思います。
どなたかの開発体験向上に貢献できたなら嬉しいです。


## 参考記事

本記事、リポジトリ作成にあたり参考にさせていただきました。ありがとうございます。

https://zenn.dev/otsukayuhi/articles/e52651b4e2c5ae7c4a17

https://atmarkit.itmedia.co.jp/ait/articles/2003/13/news031.html