Edited at

Express+TypeScript で React をテンプレートエンジンとして使用する

Express+TypeScript で Server Side Rendering (SSR) して、React をテンプレートエンジンとして使用してみます。Babel は使用しません。

TypeScript は JSX に対応しているので、JSX を変換するだけなら Babel は不要です。


ディレクトリ構成

.

├── app.ts
├── createEngine.ts
├── package.json
├── pages/
│ └── Hello.tsx
└── tsconfig.json


設定ファイル


package.json

{

"name": "react-ssr-ts",
"version": "1.0.0",
"license": "CC0-1.0",
"private": true,
"scripts": {
"dev": "ts-node-dev --no-notify app.ts",
"build": "tsc",
"start": "node dist/app.js"
},
"dependencies": {
"express": "^4.17.1",
"react": "^16.11.0",
"react-dom": "^16.11.0"
},
"devDependencies": {
"@types/express": "^4.17.1",
"@types/react": "^16.9.9",
"@types/react-dom": "^16.9.2",
"ts-node-dev": "^1.0.0-pre.43",
"typescript": "^3.6.4"
}
}

ts-node-dev はファイルに変更があると自動で再起動してくれるツールです。ts-node と node-dev を掛け合わせたようなやつです。


tsconfig.json

{

"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"jsx": "react",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
}
}

"jsx": "react" がミソです。tsc が JSX を React.createElement に置き換えてくれるので、普通に JS として実行可能になります。


ページ


pages/Hello.tsx

import React from 'react';

type HelloProps = {
name: string;
};

const Hello: React.FC<HelloProps> = ({ name }) => (
<h1>Hello, {name}</h1>
);

export default Hello;


ページは pages/ に置いていますが、app.ts の設定でディレクトリ名を自由に指定できます。

ページのコンポーネントは export default してください。


テンプレートエンジン


createEngine.ts

import React from 'react';

import ReactDOMServer from 'react-dom/server';

export interface EngineOptions {}

export const createEngine = (engineOptions?: EngineOptions) => {
const renderFile = (path: string, options: object, callback: (e: any, rendered: string) => void): void => {
const component = require(path).default as React.ComponentType<any>;
const markup = ReactDOMServer.renderToStaticMarkup(
React.createElement(component, options)
);
return callback(null, markup);
};
return renderFile;
};


tsx ファイルをテンプレートとして使うためのテンプレートエンジンです。渡された js または tsx ファイルを ReactDOMServer.renderToStaticMarkup で HTML の文字列に変換しています。

EngineOptions は特に使用していないので、createEngine でラップする必要はないのですが、将来の拡張用にとりあえずこうしています。


エントリポイント


app.ts

import express from 'express';

import { createEngine } from './createEngine';

const app = express();

const isTsNodeDev = Object.keys(require.cache).some(path => path.includes('/ts-node-dev/'));
const ext = isTsNodeDev ? 'tsx' : 'js';
app.set('views', __dirname + '/pages');
app.set('view engine', ext);
app.engine(ext, createEngine());

app.get('/:name', (req, res) => {
res.render('Hello', { name: req.params.name });
});

app.listen(8080, () => {
console.log('> Ready on http://localhost:8080/');
});


先ほどのテンプレートエンジンを app.engine に登録します。ページのディレクトリ名を変えたい場合は、app.set('views', __dirname + '/pages') のところを変えてください。

view engine の値を js と tsx で分けている理由ですが、ts-node-dev で実行した場合 tsx が渡されてくる一方、tsc して node dist/app.js した場合 js が渡されてくるので、それぞれの場合でテンプレートの拡張子を変える必要があります。このあたりもう少しスマートにできないものか・・・

追記: (2019-10-24)

@suin さんの『ts-node-devで起動されたかを調べる方法』を参考に、NODE_ENV で判定していたところを、ts-node-dev で起動されたかどうかで判定するよう書き換えました。ありがとうございます!


おわり

以上です。意外と簡単に React をテンプレートエンジンとして使うことができました。

普通は Next.js を使うのがいいと思います(Dynamic Routing で難がありますが)。