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

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

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

[express-react-views](https://github.com/reactjs/express-react-views) をかなり参考にしています。

# ディレクトリ構成

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

# 設定ファイル

```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](https://github.com/whitecolor/ts-node-dev) はファイルに変更があると自動で再起動してくれるツールです。ts-node と node-dev を掛け合わせたようなやつです。

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

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

# ページ

```tsx: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` してください。

# テンプレートエンジン

```ts:createEngine.ts
import React from 'react';
import ReactDOMServer from 'react-dom/server';

export type EngineOptions = {
  doctype?: string;
};

export const createEngine = ({ doctype = '<!doctype html>' }: EngineOptions = {}) => {
  return (path: string, options: object, callback: (e: any, rendered: string) => void): void => {
    try {
      const component = require(path).default as React.ComponentType<any>;
      const markup = ReactDOMServer.renderToStaticMarkup(
        React.createElement(component, options)
      );
      return callback(null, doctype + markup);
    } catch (e) {
      return callback(e, '');
    }
  };
};
```

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

# エントリポイント

```ts: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 が渡されてくるので、それぞれの場合でテンプレートの拡張子を変える必要があります。<del>このあたりもう少しスマートにできないものか・・・</del>

追記: (2019-10-24)
@suin さんの『[ts-node-devで起動されたかを調べる方法](https://qiita.com/suin/items/b9ca0f6b7331c6e742d0)』を参考に、NODE_ENV で判定していたところを、ts-node-dev で起動されたかどうかで判定するよう書き換えました。ありがとうございます！


# おわり

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

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