こんにちは。ナディアのエンジニア 富山です。
ライブラリやフレームワークを使用しないプロジェクトでは、ローカル環境でテンプレートエンジンを使ってあらかじめ静的な HTML を生成することがあります。
その場合、動的なコンテンツを繋ぎ込むためではなく、JavaScript のループや条件分岐などを使用して HTML を効率良く作成するためにテンプレートエンジンを使用することが多いです。
ナディアでは静的な HTML データを納品するプロジェクトにおいて、ローカル環境で使用するテンプレートエンジンとして Pug を採用することがありますが、いくつかの注意点があります。
これは Pug に限らず、すべてのテンプレートエンジンはカスタム言語に近い独自の構文やルールを持っており、HTML や JavaScript に似てはいるものの異なるため、構文の強調表示を行うにはどの IDE でも拡張機能が必要です。
また、テンプレートエンジン自体は型安全性を提供しないため、UI コンポーネントを型安全に構築するには不向きです。
このような注意点を問題だと感じない方もいると思いますが、より優れた型安全性と IDE サポートを求めて、ここでは TSX をテンプレートエンジンとして使う方法を紹介します。
JSX
JSX のほとんどのユースケースは React 内で見られますが、JSX は React に縛られた拡張構文ではありません。誰でも独自のパーサーを作成して使用できる適切な仕様(閲覧日: 2024年11月7日)があります。
JSX の素晴らしい点はその広範なサポートであり、多くの主要 IDE が JSX をサポートしています。TSX も同様です。
開発環境の構築
パッケージのインストール
TSX を使用するために、TypeScript と React をインストールします。
モジュールバンドラーには webpack を使用します。
npm i -D typescript react @types/react react-dom @types/react-dom webpack webpack-cli webpack-dev-server
TSX や TypeScript のトランスパイルには esbuild を使用し、HTML ファイルを出力するために html-webpack-plugin を使用します。HTML として出力する全ての .tsx を効率的に処理するために、globule を使用します。
CSS ファイルも出力するため、mini-css-extract-plugin と css-loader もインストールしておきます。
npm i -D esbuild esbuild-loader html-webpack-plugin globule mini-css-extract-plugin css-loader
TSX はテンプレートエンジンとして使用するため、React に依存もしくは最適化された CSS-in-JS のようなパッケージは使用できません。そのため、CSS の実装には Sass を使用します。
また、UI コンポーネントの作成を容易にするために、相性の良い Tailwind CSS もインストールし、HTML と CSS をカプセル化できるように設計します。
npm i -D sass sass-loader postcss postcss-loader tailwindcss autoprefixer
ディレクトリ構造
project
├── package.json
├── postcss.config.js
├── tailwind.config.js
├── tsconfig.json
├── webpack.config.js
└── src
├── assets
│ ├── scss
│ │ └── app.scss
│ └── ts
│ └── index.ts
└── views
├── components
├── layouts
├── includes
└── pages
└── index.tsxproject
├── package.json
├── postcss.config.js
├── tailwind.config.js
├── tsconfig.json
├── webpack.config.js
└── src
├── assets
│ ├── scss
│ │ └── app.scss
│ └── ts
│ └── index.ts
└── views
├── components
├── layouts
├── includes
└── pages
└── index.tsx
src/views 配下は、pages ディレクトリを除いてディレクトリ構造に指定はありません。
ここではわかりやすくするために components には UI コンポーネントパーツ、layouts には雛形となるテンプレートを配置します。
includes にはインクルード用のコンポーネントを配置しますが、この記事では使用しません。
src/views/pages 配下には、*.html として出力するファイルを配置します。
webpack.config.js
const path = require("path");
const globule = require("globule");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const templates = globule.find("src/views/pages/**/*.tsx");
const globHtmlWebpackPlugins = (files) => {
return files.map((file) => {
const relativePath = path.relative(
path.join(__dirname, "src/views/pages"),
file,
);
const filename = relativePath.replace(/\.tsx$/, ".html");
return new HtmlWebpackPlugin({
filename: filename,
inject: false,
template: file,
});
});
};
const webpackConfig = {
entry: "./src/assets/ts/index.ts",
output: {
path: path.join(__dirname, "dist"),
publicPath: "/",
filename: "assets/js/app.js",
},
mode: "development",
devtool: "cheap-module-source-map",
module: {
rules: [
{
test: /\.ts(|x)$/,
use: "esbuild-loader",
},
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"postcss-loader",
"sass-loader",
],
},
],
},
resolve: {
alias: {
"@": path.join(__dirname, "src"),
},
extensions: [".js", ".ts", ".tsx"],
},
plugins: [
...globHtmlWebpackPlugins(templates),
new MiniCssExtractPlugin({
filename: "assets/css/app.css",
}),
],
devServer: {
static: {
directory: path.join(__dirname, "dist"),
},
devMiddleware: {
writeToDisk: true,
},
port: 9000,
hot: true,
watchFiles: ["src/**/*.*"],
},
};
module.exports = webpackConfig;
const path = require("path");
const globule = require("globule");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const templates = globule.find("src/views/pages/**/*.tsx");
const globHtmlWebpackPlugins = (files) => {
return files.map((file) => {
const relativePath = path.relative(
path.join(__dirname, "src/views/pages"),
file,
);
const filename = relativePath.replace(/\.tsx$/, ".html");
return new HtmlWebpackPlugin({
filename: filename,
inject: false,
template: file,
});
});
};
const webpackConfig = {
entry: "./src/assets/ts/index.ts",
output: {
path: path.join(__dirname, "dist"),
publicPath: "/",
filename: "assets/js/app.js",
},
mode: "development",
devtool: "cheap-module-source-map",
module: {
rules: [
{
test: /\.ts(|x)$/,
use: "esbuild-loader",
},
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"postcss-loader",
"sass-loader",
],
},
],
},
resolve: {
alias: {
"@": path.join(__dirname, "src"),
},
extensions: [".js", ".ts", ".tsx"],
},
plugins: [
...globHtmlWebpackPlugins(templates),
new MiniCssExtractPlugin({
filename: "assets/css/app.css",
}),
],
devServer: {
static: {
directory: path.join(__dirname, "dist"),
},
devMiddleware: {
writeToDisk: true,
},
port: 9000,
hot: true,
watchFiles: ["src/**/*.*"],
},
};
module.exports = webpackConfig;
L6-20 では、src/views/pages 配下のすべてのファイルを *.html として出力するように検知する記述をしています。
このディレクトリ内の .tsx は拡張子が .html に変換され、HTML ファイルとして出力されます。
L48-53 ではエイリアスの設定を記述しています。
これにより、@/ で src/ を参照できるようになります。
その他の設定については割愛します。
Tailwind CSS のセットアップ
公式ドキュメント(閲覧日: 2024年11月7日)に従ってセットアップを行います。
postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
src/assets/scss/app.scss
@tailwind base;
@tailwind components;
@tailwind utilities;@tailwind base;
@tailwind components;
@tailwind utilities;
tsconfig.json
必要な設定を記述したファイルを作成します。https://www.typescriptlang.org/tsconfig/
(閲覧日: 2024年11月7日)
tsconfig.json
{
"compilerOptions": {
"jsx": "preserve",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}{
"compilerOptions": {
"jsx": "preserve",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
エントリーポイントで .scss をインポートする
CSS ファイルを出力するため、エントリーポイントで .scss をインポートします。
src/assets/ts/index.ts
import "../scss/app.scss";
ベースとなるテンプレートを用意する
共通のテンプレートを用意し、src/views/pages/**/*.tsx ではこのテンプレートを使って開発を行います。
インストールした React のバージョンに関係なく、必ず React のインポートが必要である点に注意してください。
src/views/layouts/BaseLayout.tsx
import React from "react";
interface IBaseLayout {
title: string;
children: React.ReactNode;
}
const BaseLayout = ({ title, children }: IBaseLayout) => {
return (
<html lang="ja">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<link rel="stylesheet" href="/assets/css/app.css" />
</head>
<body>
<header className="text-center py-4">Header</header>
<main>
{children}
</main>
<footer className="text-center py-4">Footer</footer>
<script src="/assets/js/app.js" />
</body>
</html>
);
};
export default BaseLayout;import React from "react";
interface IBaseLayout {
title: string;
children: React.ReactNode;
}
const BaseLayout = ({ title, children }: IBaseLayout) => {
return (
<html lang="ja">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<link rel="stylesheet" href="/assets/css/app.css" />
</head>
<body>
<header className="text-center py-4">Header</header>
<main>
{children}
</main>
<footer className="text-center py-4">Footer</footer>
<script src="/assets/js/app.js" />
</body>
</html>
);
};
export default BaseLayout;
この BaseLayout コンポーネントでは、props に title を渡すことでページごとにタイトルを設定できます。
また、コンポーネントの子要素として指定されたソースコードは <main></main> 内に出力されます。meta description や body に付与するクラス名がある場合など、ページごとに異なる要素は props として用意することで、コンポーネントを使用する側から柔軟に値を変更できます。
ベーステンプレートを使用してページを作成する
ページとして出力するファイルを作成し、ベーステンプレートを使用して構築します。
src/views/pages/index.tsx
import React from "react";
import { renderToString } from "react-dom/server";
import BaseLayout from "../layouts/BaseLayout";
const App = () => {
return (
<BaseLayout title="トップページ">
ここにソースコードが入ります。
</BaseLayout>
);
};
export default () => `<!DOCTYPE html>${renderToString(<App />)}`;import React from "react";
import { renderToString } from "react-dom/server";
import BaseLayout from "../layouts/BaseLayout";
const App = () => {
return (
<BaseLayout title="トップページ">
ここにソースコードが入ります。
</BaseLayout>
);
};
export default () => `<!DOCTYPE html>${renderToString(<App />)}`;
npx webpack serve を実行することで、localhost:9000 で確認できるようになります。
生成された HTML ファイル(dist/index.html)
<!DOCTYPE html><html lang="ja"><head><meta charSet="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>トップページ</title><link rel="stylesheet" href="/assets/css/app.css"/></head><body><header class="text-center py-4">Header</header><main>ここにソースコードが入ります。</main><footer class="text-center py-4">Footer</footer><script src="/assets/js/app.js"></script></body></html>
React の renderToString という関数は、サーバーサイドで React コンポーネントを HTML 文字列に変換するために使用します。この関数を使ってあらかじめ静的な HTML をローカル環境で生成しています。
このプロジェクトではローカルで用意した Node.js ベースのサーバーサイドで React の一部の関数を機能させているだけで、ライブラリとしての React が持つ機能をクライアント側で使うことはできません。
そのため、JavaScript は src/assets/ts/**/*.ts で別途実装しトランスパイルします。
React のように JavaScript のロジックを一つのコンポーネントとしてカプセル化することはできず、TSX はあくまでテンプレートエンジンとして使用します。
コンポーネントを作成してみる
React コンポーネントのように作成できるため、JavaScript が書けるなら直感的に実装を進められるかと思います。
こんな感じのUIパーツをコンポーネントとして作成する。年齢の値の受け渡しは任意とする。
src/views/components/GreetingCard.tsx
import React from "react";
interface IGreetingCard {
name: string;
age?: number;
}
const GreetingCard = ({ name, age }: IGreetingCard) => {
const currentYear = new Date().getFullYear();
return (
<div className="flex flex-col gap-3 w-96 bg-gradient-to-b from-teal-500 to-teal-600 shadow-md rounded-lg border p-4 mx-auto [&:not(:last-of-type)]:mb-4">
<div>
<span className="block text-lg text-white font-bold">{name}</span>
{age && (
<span className="block text-sm text-gray-100 mt-1">
🎂 Born in {currentYear - age}
</span>
)}
</div>
<p className="text-white">
こんにちは、{name}さん。
{age && (
<>
<br />
{name}さんは {age} 歳なんですね!
</>
)}
</p>
</div>
);
};
export default GreetingCard;import React from "react";
interface IGreetingCard {
name: string;
age?: number;
}
const GreetingCard = ({ name, age }: IGreetingCard) => {
const currentYear = new Date().getFullYear();
return (
<div className="flex flex-col gap-3 w-96 bg-gradient-to-b from-teal-500 to-teal-600 shadow-md rounded-lg border p-4 mx-auto [&:not(:last-of-type)]:mb-4">
<div>
<span className="block text-lg text-white font-bold">{name}</span>
{age && (
<span className="block text-sm text-gray-100 mt-1">
🎂 Born in {currentYear - age}
</span>
)}
</div>
<p className="text-white">
こんにちは、{name}さん。
{age && (
<>
<br />
{name}さんは {age} 歳なんですね!
</>
)}
</p>
</div>
);
};
export default GreetingCard;
※ サンプルとして画面を見たときにわかりやすいようにコンポーネントの親要素に下マージンを付けていますが、コンポーネントの外側にはできるだけ余白を持たせないようにしましょう。
import React from "react";
import { renderToString } from "react-dom/server";
import BaseLayout from "../layouts/BaseLayout";
import GreetingCard from "../components/GreetingCard";
const App = () => {
return (
<BaseLayout title="トップページ">
<GreetingCard name="ナディア 太郎" age={20} />
<GreetingCard name="芋栗 かぼちゃ" age={30} />
<GreetingCard name="ななしのごんべえ" />
</BaseLayout>
);
};
export default () => `<!DOCTYPE html>${renderToString(<App />)}`;import React from "react";
import { renderToString } from "react-dom/server";
import BaseLayout from "../layouts/BaseLayout";
import GreetingCard from "../components/GreetingCard";
const App = () => {
return (
<BaseLayout title="トップページ">
<GreetingCard name="ナディア 太郎" age={20} />
<GreetingCard name="芋栗 かぼちゃ" age={30} />
<GreetingCard name="ななしのごんべえ" />
</BaseLayout>
);
};
export default () => `<!DOCTYPE html>${renderToString(<App />)}`;
出力結果(表示される画面)は以下のようになり、TSX で作成したコンポーネントが繰り返し使用できていることがわかります。
ベーステンプレート(<BaseLayout />)のヘッダーとフッターも正しく表示されています。
生成された HTML ファイル(dist/index.html)
※ props に渡した値の左右に空のコメントアウトが入っていますが、production モードでビルドしたら消えます。
<!DOCTYPE html><html lang="ja"><head><meta charSet="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>トップページ</title><link rel="stylesheet" href="/assets/css/app.css"/></head><body><header class="text-center my-4">Header</header><main><div class="flex flex-col gap-3 w-96 bg-gradient-to-b from-teal-500 to-teal-600 shadow-md rounded-lg border p-4 mx-auto [&:not(:last-of-type)]:mb-4"><div><span class="block text-lg text-white font-bold">ナディア 太郎</span><span class="block text-sm text-gray-100 mt-1">🎂 Born in <!-- -->2004</span></div><p class="text-white">こんにちは、<!-- -->ナディア 太郎<!-- -->さん。<br/>ナディア 太郎<!-- -->さんは <!-- -->20<!-- --> 歳なんですね!</p></div><div class="flex flex-col gap-3 w-96 bg-gradient-to-b from-teal-500 to-teal-600 shadow-md rounded-lg border p-4 mx-auto [&:not(:last-of-type)]:mb-4"><div><span class="block text-lg text-white font-bold">芋栗 かぼちゃ</span><span class="block text-sm text-gray-100 mt-1">🎂 Born in <!-- -->1994</span></div><p class="text-white">こんにちは、<!-- -->芋栗 かぼちゃ<!-- -->さん。<br/>芋栗 かぼちゃ<!-- -->さんは <!-- -->30<!-- --> 歳なんですね!</p></div><div class="flex flex-col gap-3 w-96 bg-gradient-to-b from-teal-500 to-teal-600 shadow-md rounded-lg border p-4 mx-auto [&:not(:last-of-type)]:mb-4"><div><span class="block text-lg text-white font-bold">ななしのごんべえ</span></div><p class="text-white">こんにちは、<!-- -->ななしのごんべえ<!-- -->さん。</p></div></main><footer class="text-center my-4">Footer</footer><script src="/assets/js/app.js"></script></body></html>
画像の読み込み
ここでは画像に関する設定を webpack の構成ファイルに含めていませんが、Asset Modules を使用する場合は画像をインポートして使用します。
import Image from "@/path/to/image.png";
const MyComponent = () => {
return <img src={Image} alt="" />;
};
export default MyComponent;import Image from "@/path/to/image.png";
const MyComponent = () => {
return <img src={Image} alt="" />;
};
export default MyComponent;
さいごに
TSX をテンプレートエンジンとして使うことで、ピュアな JavaScript を用いて効率的に HTML を記述し、静的な HTML ファイルを出力することができました。
テンプレートエンジンは UI コンポーネント構築のために開発された拡張構文ではありませんが、効率化のためにローカル環境で使用する場合にはその機能が十分とは言えません。
TSX のコンポーネントパーツはインクルードとして扱うこともでき、値を渡す場合は明示的に props を指定することで直感的に扱うことができます。引数(props)の順番にも制限がありません。
また、値のヒントやエラーを事前に表示してくれたり、コンポーネントパーツにワンクリックで移動できる点も開発効率を向上させる要因となります。これらの利点は React での開発においても同様です。
フレームワークを使った開発の方が効率は良いですが、綺麗な HTML を出力する必要がある場合など、プロジェクトの性質によって使い分けてみるのが良いかもしれません。
TSX をテンプレートエンジンとして使用することにより、誰かの開発体験の向上に繋がることを願っています。