React+TypeScript+ExpressのSSR対応アプリ with esbuild

私はとある用事でReactをwebpackでビルドしながら使っていたのですが、ビルドにかかる時間が長すぎてつらかったので、[webpackより187倍速いらしい](https://qiita.com/olt/items/3e3840db050414cde0ed)というesbuildを使ってみたいと思ったのでした。これはその時の備忘録です。

[webpackを使ったSSR対応SPAアプリの作り方](https://qiita.com/Michinosuke/items/a70a349b447f16001f87)を紹介している記事が既にあったので、webpackの代わりにesbuildを使って同じようなものを作っていきます。
ただし、上記の記事では先にJavaScriptできちんと動作するようにした後でTypeScript化していましたが、この記事では最初からTypeScriptで進めていきます。

# 環境
Windows 10上のWSLで作業しました。たぶんMacやUbuntuでも大丈夫なはず。
npmのバージョンは6.14.11、nodeのバージョンは14.15.5です。
また、私の手元でインストールされたTypeScriptのバージョンは4.1.5、esbuildのバージョンは0.8.46でした。

# 下準備
Gitとnpmの最初の設定をします。なお、ここの内容は[TypeScript + Node.js プロジェクトのはじめかた2020](https://qiita.com/notakaos/items/3bbd2293e2ff286d9f49)という記事にあったものと全く同じです。

## Git
まずはGitの初期化をします。

```bash
$ git init
```
.gitignoreも以下のように準備します。

```:.gitignore
# macOS
### https://raw.github.com/github/gitignore/07c730e1fccfe0f92b29e039ba149d20bfb332e7/Global/macOS.gitignore
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

# Linux
### https://raw.github.com/github/gitignore/07c730e1fccfe0f92b29e039ba149d20bfb332e7/Global/Linux.gitignore
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*

# Windows
### https://raw.github.com/github/gitignore/07c730e1fccfe0f92b29e039ba149d20bfb332e7/Global/Windows.gitignore
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
*.lnk

# node.js
### https://raw.github.com/github/gitignore/07c730e1fccfe0f92b29e039ba149d20bfb332e7/Node.gitignore
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pids
*.pid
*.seed
*.pid.lock
lib-cov
coverage
.nyc_output
.grunt
bower_components
.lock-wscript
build/Release
node_modules/
jspm_packages/
typings/
.npm
.eslintcache
.node_repl_history
*.tgz
.yarn-integrity
.env
.next
```

## npm
次にnpm関係の初期設定です。

```bash
$ npm init -y
```
[参考にしている記事](https://qiita.com/notakaos/items/3bbd2293e2ff286d9f49)に倣い、セマンティック バージョニング 2.0.0に従ってバージョンを0.1.0に変えておきます。

```diff:package.json
-  "version": "1.0.0",
+  "version": "0.1.0",
```

# TypeScriptの準備
型のあるTypeScriptの世界へ行くための準備です。まずはtypescriptとesbuildのパッケージを追加します。

```bash
$ npm i -D typescript @types/node esbuild
```
次にtsconfig.jsonを準備します。

```bash
$ npx tsc --init
```
このコマンドで自動生成されたtsconfig.jsonにいくつか変更します。
まず、以下の内容を末尾の方に追加します。

```diff:tsconfig.json
-  }
+  },
+  "include": [
+    "src/**/*"
+  ]
 }
```
次に、`/* Additional Checks */`以下の5つの設定をコメントインします。私はこのくらい厳しくチェックしてもらうのが好きなのでこうしていますが、`"strict": true`になっていればここはお好みでもいいと思います。

```diff:tsconfig.json
     /* Additional Checks */
-    // "noUnusedLocals": true,                /* Report errors on unused locals. */
+    "noUnusedLocals": true,                /* Report errors on unused locals. */
-    // "noUnusedParameters": true,            /* Report errors on unused parameters. */
+    "noUnusedParameters": true,            /* Report errors on unused parameters. */
-    // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
+    "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
-    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
+    "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
-    // "noUncheckedIndexedAccess": true,      /* Include 'undefined' in index signature results */
+    "noUncheckedIndexedAccess": true,      /* Include 'undefined' in index signature results */
```

# サーバー側のビルドと実行コマンドの設定
ここまでできたら、いよいよ本題（の一部）であるesbuildによるビルドの設定をします。なお、ここで行うのはNode.jsで動作するサーバー側のビルド設定だけです。クライアントへ配信するJavaScriptの方は後で設定します。
まずbuild.jsというファイルを作り、その中に以下の内容を書きます。

```javascript:build.js
const { argv } = require("process")
const { build } = require("esbuild")
const path = require("path")
const fs = require("fs")

const IS_DEV = argv[2] === "development"

// サーバー側のビルド
const serverOptions = {
  entryPoints: [path.resolve(__dirname, "src/index.ts")],
  minify: !IS_DEV,
  bundle: true,
  target: "es2020",
  platform: "node",
  outdir: path.resolve(__dirname, "dist"),
  tsconfig: path.resolve(__dirname, "tsconfig.json"),
  external: fs.readdirSync("./node_modules")
}
build(serverOptions).catch(err => {
  process.stderr.write(err.stderr)
  process.exit(1)
})
```
サーバー側のビルドではnode_modulesの中にあるモジュールまでバンドルする必要はないので、ビルドオプションの`external`にnode_modulesの中にあるディレクトリ名の配列（これはインストールしたモジュール名をたぶん全て含む）を設定しています。

次に、package.jsonのscriptsの部分を編集します。

```diff:package.json
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1",
+    "build": "node build.js",
+    "start": "node dist/index.js"
   },
```
ここまで来たら、一度動作確認をします。
srcフォルダを作成したらその中にindex.tsを作成し、以下の内容を書きます。

```typescript:src/index.ts
function hello(name: string): string {
    return `Hello, ${name}!`;
}

console.log(hello("World"));
```
それから`npm run build`と`npm run start`を実行し、無事に`Hello, World!`と表示されればOKです。

しかし、開発時に毎回`npm run build`と`npm run start`を打つわけにはいかないので、下のようにしてビルドと起動を一度に行うコマンドを準備します。

```diff:package.json
   "scripts": {
+    "clean": "rimraf dist/",
+    "dev": "npm-run-all clean \"buildcmd development\" \"startcmd development\"",
+    "build": "npm-run-all clean \"buildcmd production\"",
+    "start": "npm-run-all \"startcmd production\"",
-    "build": "node build.js",
+    "buildcmd": "node build.js",
-    "start": "node dist/index.js"
+    "startcmd": "node dist/index.js"
   },
```
これと一緒に、新しく追加したrimrafとnpm-run-allのモジュールを下のコマンドでインストールしてください。

```bash
$ npm i -D rimraf npm-run-all
```
これで、`npm run dev`を実行するとビルドと起動が一度に行われるようになります。

ここまでできたら、ビルドされた.jsファイルの置き場所であるdistフォルダを.gitignoreに追記しておきます。

```diff:.gitignore
 .env
 .next
+
+#ビルドされた.jsファイルのフォルダ
+dist/
```
最後に、esbuildは型チェックをしないらしいので、tscを使って型チェックだけを行うスクリプトを念のため用意しておきます。

```diff:package.json
   "scripts": {
     "clean": "rimraf dist/",
     "dev": "npm-run-all clean \"buildcmd development\" \"startcmd development\"",
     "build": "npm-run-all clean \"buildcmd production\"",
     "start": "npm-run-all \"startcmd production\"",
     "buildcmd": "node build.js",
     "startcmd": "node dist/index.js",
+    "typecheck": "tsc --noEmit"
   },
```
これで、サーバー側をTypeScriptで記述する準備が完成です。

# Express導入
まずはモジュールをインストールします。

```bash
$ npm i express
$ npm i -D @types/express
```
そして、src/index.tsを以下のように書き換えてExpressサーバーが立てられることを確認します。

```typescript:src/index.ts
import express from "express";
const app = express();

app.get("/", (_req, res)=>{
    res.send("Hello, World!");
});

app.listen(3000);
console.log("Server is listening on port 3000");
```
しかし、これは1行目のimport文のところで以下のようなエラーが出て起動できませんでした。

```
(node:6038) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/mnt/c/Users/Lobesta/workspace_kfc/countup-esbuild/dist/index.js:1
import express from "express";
^^^^^^

SyntaxError: Cannot use import statement outside a module
```
ビルドされた.jsファイルを確認すると確かに`import express from "express";`がそのまま残っていて、これを実行するにはpackage.jsonに`"type": "module"`と書かないといけないみたいです。でもその通りにすると今度は`require`を何度もしているbuild.jsが動かなくなります。なので、[参考元のwebpackを使用している記事](https://qiita.com/Michinosuke/items/a70a349b447f16001f87)と同じようにesmモジュールを入れる必要がありました…

```bash
$ npm i esm
```

```diff:package.json
-    "startcmd": "node dist/index.js",
+    "startcmd": "node -r esm dist/index.js",
```
ちなみに、上記の変更の代わりに`import express from "express";`を`import express = require("express")`と書きかえることでも動作させることができます。[2つの書き方にはあまり違いはないらしい](https://qiita.com/kzee/items/dacbdd552bf8fe49f423)ので、どちらを選ぶかはお好みでよさそうです。

このようにしてサーバーを起動し、[http://localhost:3000](http://localhost:3000)にアクセスしてHello, World!と表示されればOKです。

# React×SSR
まず、tsconfig.jsonに`"jsx": "react"`を書き足してください。これをしておかないと.tsxファイルでhtmlタグのような書き方をすることができません。

```diff:tsconfig.json
-    // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+    "jsx": "react",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
```
その後、srcフォルダの中にviewフォルダを作り、この中にssr.tsxとcountup.tsxを作ります。

```tsx:src/view/ssr.tsx
import React from "react";
import { renderToString } from "react-dom/server";
import CountUp from "./countup";

const Layout:React.FC<{children: React.ReactNode}> = ({children})=>{
    return <html>
        <head>
            <title>Count up with react and esbuild</title>
            <meta charSet="utf-8" />
        </head>
        <body>
            <div id="react-app-target">
                {children}
            </div>
        </body>
    </html>;
};

const ssr = ():string=>{
    return renderToString(<Layout><CountUp/></Layout>);
}

export default ssr;
```

```tsx:src/view/countup.tsx
import React, { useState } from 'react';

const CountUp:React.FC = () => {
    const [count, setCount] = useState(0);

    return <>
        <h1>{count}</h1>
        <button type="button" onClick={() => setCount(count + 1)}>+</button>
        <p>{new Date().toTimeString()}</p>
    </>;
};

export default CountUp;
```
countup.tsxは[参考元の記事](https://qiita.com/Michinosuke/items/a70a349b447f16001f87)と全く同じです。ssr.tsxの方も、CountUpコンポーネントの外側もtsxの書き方をしていることしか違いはありません。

この2つを準備したら、src/index.tsの方でssr関数を使うように書き換えます。

```diff:src/index.ts
 import express from "express";
+import ssr from "./view/ssr";
 const app = express();

 app.get("/", (_req, res)=>{
-    res.send("Hello, World!");
+    res.send(ssr());
 });

app.listen(3000);
console.log("Server is listening on port 3000");
```
これで再度`npm run dev`をしてページを再読み込みすると、src/view/countup.tsxに書いているようなカウンタが見れます。もちろんまだボタンを押しても何も反応はしません。

# クライアント側に配信するJavaScriptの設定
最後に、先ほどのビルド設定で後回しにしたクライアントへ送るJavaScriptを生成する設定をします。
まずは、src/viewの中に新しくclient.tsxを作成します。これも[参考元の記事](https://qiita.com/Michinosuke/items/a70a349b447f16001f87)と全く同じです。

```tsx:src/view/client.tsx
import React from 'react';
import { hydrate } from 'react-dom';
import CountUp from './countup';

hydrate(<CountUp />, document.querySelector('#react-app-target'));
```
次に、build.jsにクライアント側へ送る部分のビルド処理を追記します。

```diff:build.js
 }
 build(serverOptions).catch(err => {
   process.stderr.write(err.stderr)
   process.exit(1)
 })
+
+// クライアント側のビルド
+const clientOptions = {
+    define: {"process.env.NODE_ENV": IS_DEV?"\"production\"": "\"development\""},
+    entryPoints: [path.resolve(__dirname, "src/view/client.tsx")],
+    minify: !IS_DEV,
+    bundle: true,
+    target: "es6",
+    platform: "browser",
+    outdir: path.resolve(__dirname, "dist/static"),
+    tsconfig: path.resolve(__dirname, "tsconfig.json")
+}
+build(clientOptions).catch(err => {
+    process.stderr.write(err.stderr)
+    process.exit(1)
+})
```
クライアント側のビルドがサーバー側のビルドの場合と違うのは、

- `define: `の部分を追加する必要があること
- `target: "es6"`にしていること
- `exclude:`を削除していること
- `entryPoints`と`outdir`の変更

です。

それから、上記の`outdir`に指定したdist/staticの中身をexpressでそのまま配信するようにsrc/index.tsに追記します。

```diff:src/index.ts
 });
+
+app.use("/static", express.static("dist/static"));

 app.listen(3000);
 console.log("Server is listening on port 3000");
```
その後、src/view/ssr.tsxのコンポーネント部分にscriptタグを追加します。

```diff:src/view/ssr.tsx
     </div>
+    <script src="./static/client.js" />
 </body>
```
ここまでできたら準備完了です。`npm run dev`をやり直して、ページを再読み込みするときちんとボタンが動作するようになると思います。

# おしまい
このプロジェクトはビルド速度を評価するには小さすぎると思うので、ビルドにかかる時間は測定していません。
`npm run build`で生成されたclient.jsのファイルサイズは130KBくらいでした。万が一何かの参考になれば幸いです。
GitHub上に今回作成したコードを置いた[リポジトリ](https://github.com/Lobesta/countup-esbuild)を作ったので、もし見たい人がいれば見ていってください。

## たくさん参考にした記事様
何度もリンクを貼りましたがもう一度最後に貼っておきます。

- [SSR対応SPAアプリの作り方(React/TypeScript/Express)](https://qiita.com/Michinosuke/items/a70a349b447f16001f87)
- [TypeScript + Node.js プロジェクトのはじめかた2020](https://qiita.com/notakaos/items/3bbd2293e2ff286d9f49)
- [esbuildがwebpackより187倍早いらしいので環境構築しよう](https://qiita.com/olt/items/3e3840db050414cde0ed)
- [TypeScriptでモジュールのimport/exportやrequire](https://qiita.com/kzee/items/dacbdd552bf8fe49f423)

## おまけ1
クライアント側のビルド設定に`define`を書かないと以下のようなエラーがいっぱい出ます。

```bash
> node_modules/react/index.js: warning: Define "process.env.NODE_ENV" when bundling for the browser
    3 │ if (process.env.NODE_ENV === 'production') {
      ╵     ~~~~~~~~~~~~~~~~~~~~
```
## おまけ2
本題から逸れますが…
このコードを手元で実行すると、`new Date().toTimeString()`がサーバー側で返す文字列とクライアント側で返す文字列が一致せず、以下のようなエラーが出ました。
https://gyazo.com/5e0dff1e378c831ac4882f3a106dc6e4
今回はもう気にしないですが、本格的に使う際はサーバー側とクライアント側でこのような齟齬がないように気を付けたいです。