私はとある用事で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も以下のように準備します。
# 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に変えておきます。
- "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にいくつか変更します。
まず、以下の内容を末尾の方に追加します。
- }
+ },
+ "include": [
+ "src/**/*"
+ ]
}
次に、/* Additional Checks */
以下の5つの設定をコメントインします。私はこのくらい厳しくチェックしてもらうのが好きなのでこうしていますが、"strict": true
になっていればここはお好みでもいいと思います。
/* 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というファイルを作り、その中に以下の内容を書きます。
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の部分を編集します。
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1",
+ "build": "node build.js",
+ "start": "node dist/index.js"
},
ここまで来たら、一度動作確認をします。
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
を打つわけにはいかないので、下のようにしてビルドと起動を一度に行うコマンドを準備します。
"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に追記しておきます。
.env
.next
+
+#ビルドされた.jsファイルのフォルダ
+dist/
最後に、esbuildは型チェックをしないらしいので、tscを使って型チェックだけを行うスクリプトを念のため用意しておきます。
"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サーバーが立てられることを確認します。
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
- "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タグのような書き方をすることができません。
- // "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を作ります。
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;
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関数を使うように書き換えます。
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を作成します。これも参考元の記事と全く同じです。
import React from 'react';
import { hydrate } from 'react-dom';
import CountUp from './countup';
hydrate(<CountUp />, document.querySelector('#react-app-target'));
次に、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に追記します。
});
+
+app.use("/static", express.static("dist/static"));
app.listen(3000);
console.log("Server is listening on port 3000");
その後、src/view/ssr.tsxのコンポーネント部分にscriptタグを追加します。
</div>
+ <script src="./static/client.js" />
</body>
ここまでできたら準備完了です。npm run dev
をやり直して、ページを再読み込みするときちんとボタンが動作するようになると思います。
おしまい
このプロジェクトはビルド速度を評価するには小さすぎると思うので、ビルドにかかる時間は測定していません。
npm run build
で生成されたclient.jsのファイルサイズは130KBくらいでした。万が一何かの参考になれば幸いです。
GitHub上に今回作成したコードを置いたリポジトリを作ったので、もし見たい人がいれば見ていってください。
たくさん参考にした記事様
何度もリンクを貼りましたがもう一度最後に貼っておきます。
- SSR対応SPAアプリの作り方(React/TypeScript/Express)
- TypeScript + Node.js プロジェクトのはじめかた2020
- esbuildがwebpackより187倍早いらしいので環境構築しよう
- TypeScriptでモジュールのimport/exportやrequire
おまけ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
今回はもう気にしないですが、本格的に使う際はサーバー側とクライアント側でこのような齟齬がないように気を付けたいです。