3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-02-17

私はとある用事でReactをwebpackでビルドしながら使っていたのですが、ビルドにかかる時間が長すぎてつらかったので、webpackより187倍速いらしいというesbuildを使ってみたいと思ったのでした。これはその時の備忘録です。

webpackを使ったSSR対応SPAアプリの作り方を紹介している記事が既にあったので、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という記事にあったものと全く同じです。

Git

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

$ 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関係の初期設定です。

$ npm init -y

参考にしている記事に倣い、セマンティック バージョニング 2.0.0に従ってバージョンを0.1.0に変えておきます。

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

TypeScriptの準備

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

$ npm i -D typescript @types/node esbuild

次にtsconfig.jsonを準備します。

$ npx tsc --init

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

tsconfig.json
-  }
+  },
+  "include": [
+    "src/**/*"
+  ]
 }

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

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というファイルを作り、その中に以下の内容を書きます。

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の部分を編集します。

package.json
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1",
+    "build": "node build.js",
+    "start": "node dist/index.js"
   },

ここまで来たら、一度動作確認をします。
srcフォルダを作成したらその中にindex.tsを作成し、以下の内容を書きます。

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

console.log(hello("World"));

それからnpm run buildnpm run startを実行し、無事にHello, World!と表示されればOKです。

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

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のモジュールを下のコマンドでインストールしてください。

$ npm i -D rimraf npm-run-all

これで、npm run devを実行するとビルドと起動が一度に行われるようになります。

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

.gitignore
 .env
 .next
+
+#ビルドされた.jsファイルのフォルダ
+dist/

最後に、esbuildは型チェックをしないらしいので、tscを使って型チェックだけを行うスクリプトを念のため用意しておきます。

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導入

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

$ npm i express
$ npm i -D @types/express

そして、src/index.tsを以下のように書き換えてExpressサーバーが立てられることを確認します。

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を使用している記事と同じようにesmモジュールを入れる必要がありました…

$ npm i esm
package.json
-    "startcmd": "node dist/index.js",
+    "startcmd": "node -r esm dist/index.js",

ちなみに、上記の変更の代わりにimport express from "express";import express = require("express")と書きかえることでも動作させることができます。2つの書き方にはあまり違いはないらしいので、どちらを選ぶかはお好みでよさそうです。

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

React×SSR

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

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を作ります。

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;
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は参考元の記事と全く同じです。ssr.tsxの方も、CountUpコンポーネントの外側もtsxの書き方をしていることしか違いはありません。

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

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を作成します。これも参考元の記事と全く同じです。

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にクライアント側へ送る部分のビルド処理を追記します。

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:を削除していること
  • entryPointsoutdirの変更

です。

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

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タグを追加します。

src/view/ssr.tsx
     </div>
+    <script src="./static/client.js" />
 </body>

ここまでできたら準備完了です。npm run devをやり直して、ページを再読み込みするときちんとボタンが動作するようになると思います。

おしまい

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

たくさん参考にした記事様

何度もリンクを貼りましたがもう一度最後に貼っておきます。

おまけ1

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

> 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
今回はもう気にしないですが、本格的に使う際はサーバー側とクライアント側でこのような齟齬がないように気を付けたいです。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?