Bunとesbuild使って、自前のNextJSを作ってみる
Bunがついにリリースされたので、試しながらNextJSを自前で作ってみました。
プロジェクト作成
以下のコマンド叩けば、いくつか質問されながら、プロジェクトが作成されます、ちなみにnode_modulesのインストール時間がとても早かった、
bun init
expressをインストール
ここは正直なんでも大丈夫です、自分はexpressに慣れているので、expressをインストールしました。
スピードでいうとBun Serverがいいのかもしれません。
bun add express @types/express
インストール時間はなんと1.78s!!!!
express サーバーの構築
import express from 'express';
const app = express();
app.get('/', async (_req, res) => {
res.send("Yayyy!");
});
app.listen(3000, () => {
console.log(`Listening on port 3000`);
});
reactのインストール
bun add react react-dom @types/react @types/react-dom
esbuildのインストール
esbuildはreactのコードをbundleするために使います。
bun add esbuild
Reactコンポーネント作成
ちなみに、ディレクトリは以下のようにしています。
import * as React from 'react';
let i = 0;
export function App() {
React.useEffect(() => {
const timer = setInterval(() => {
console.log(i++);
}, 1000);
return () => {
clearInterval(timer);
};
});
return <div>Component </div>;
}
react componentをhtml stringとして画面に表示する
react-dom/server
のrenderToString
関数を使えばjsxをstringとして出力することがでます。
import express from 'express';
+ import { renderToString } from 'react-dom/server';
+ import { App } from './view/App';
const app = express();
app.get('/', async (_req, res) => {
- res.send("Yayyy!");
+ res.send(renderToString(App()));
});
app.listen(3000, () => {
console.log(`Listening on port 3000`);
});
サーバー再起動すると以下のように反映されます。
react componentをhydrationする
先程はただのhtml stringを画面に出しただけなので、それだけでは、reactの中に書かれているuseEffectなどの処理がjsとして実行されません、それを可能にするには、react-dom/client
のhydrateRoot
を使う必要があります。
App.tsx
にhydration処理を追加します
import * as React from 'react';
+ import { hydrateRoot } from 'react-dom/client';
let i = 0;
export function App() {
React.useEffect(() => {
const timer = setInterval(() => {
console.log(i++);
}, 1000);
return () => {
clearInterval(timer);
};
});
return <div>Component </div>;
}
+ // @ts-ignore
+ if (typeof document !== 'undefined') {
+ // @ts-ignore
+ const dc = document as any;
+
+ hydrateRoot(dc.getElementById('app'), <App />);
+}
それと、ブラウザーにはreactを実行できるruntimeが必要です、そのためにesbuildを使って、App.tsx
をbundleし、ブラウザーに読み込ませる必要があります。
viewディレクトリにindex.tsとssr.tsxファイル作成します。
ssr.tsxではサーバーでの静的stringの生成をするgetSSRString
関数を定義します。
import { renderToString } from 'react-dom/server';
import { App } from './App';
export const getSSRString = () => renderToString(<App />);
view/index.tsファイルにはView
関数を定義し、最終的に画面にHTMLを返すようにします。
esbuildに関しては、一回はview/dist/client.jsにbundleしてもらい、そのファイルをもう一回読み込んでいます、本当はそのままパイプで貰えるはずですが、うまいやり方見つからなかったので、知っている方がいたら教えてください。🙇♂️
import { build } from 'esbuild';
import path from 'node:path';
import { getSSRString } from './ssr';
const getClientBundle = async () => {
const clientTSPath = path.resolve(__dirname, './App.tsx');
const result = await build({
entryPoints: [clientTSPath],
format: 'esm',
target: 'esnext',
minify: true,
sourcemap: false,
tsconfigRaw: {
compilerOptions: {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
}
},
jsx: 'automatic',
loader: {
'.ts': 'ts',
'.tsx': 'tsx',
},
bundle: true,
treeShaking: true,
platform: 'browser',
outfile: path.resolve(__dirname, './dist/client.js'),
})
if (result.errors.length > 0) {
throw new Error(result.errors[0].text)
};
return await Bun.file(path.resolve(__dirname, './dist/client.js'), { type: 'utf8' }).text();
}
export const View = async () => {
const clientJS = await getClientBundle();
const ssr = getSSRString();
const html = `
<div id="app">${ssr}</div>
<script type="module">${clientJS}</script>`
return html
};
最後にトップディレクトリのindex.tsを編集します。
import express from 'express';
import { renderToString } from 'react-dom/server';
- import { App } from './view/App';
+ import { View } from './view';
const app = express();
app.get('/', async (_req, res) => {
- res.send(renderToString(App()));
+ res.send(View());
});
app.listen(3000, () => {
console.log(`Listening on port 3000`);
});
getServersidePropsっぽいのを作ってみる
まずはApp.tsx
にpropsを受け取れるようにします。
import * as React from 'react';
import { hydrateRoot } from 'react-dom/client';
let i = 0;
+type Props = {
+ serverData?: {
+ name: string;
+ };
+};
export function App(
+ props: Props
) {
React.useEffect(() => {
const timer = setInterval(() => {
console.log(i++, props);
}, 1000);
return () => {
clearInterval(timer);
};
});
return <div>Component {JSON.stringify(props)}</div>;
}
次はgetSSRString
にpropsを受け取れるようにします。
import { renderToString } from 'react-dom/server';
import { App } from './App';
export const getSSRString = (
+ params?: {
+ serverData: {
+ name: string;
+ };
+ }
) => renderToString(<App serverData={params?.serverData} />);
次はview/index.tsでgetServersidePropsのようにpropsに渡したいオブジェクトをHTMLタグに中に置くようにします。
import { build } from 'esbuild';
import path from 'node:path';
import { getSSRString } from './ssr';
const getClientBundle = async () => {
const clientTSPath = path.resolve(__dirname, './App.tsx');
const result = await build({
entryPoints: [clientTSPath],
format: 'esm',
target: 'esnext',
minify: true,
sourcemap: false,
tsconfigRaw: {
compilerOptions: {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
}
},
jsx: 'automatic',
loader: {
'.ts': 'ts',
'.tsx': 'tsx',
},
bundle: true,
treeShaking: true,
platform: 'browser',
outfile: path.resolve(__dirname, './dist/client.js'),
})
if (result.errors.length > 0) {
throw new Error(result.errors[0].text)
};
return await Bun.file(path.resolve(__dirname, './dist/client.js'), { type: 'utf8' }).text();
}
export const View = async () => {
const clientJS = await getClientBundle();
- const ssr = getSSRString();
+ const serverSideProps = { serverData: { name: 'Bun is awesome ! 👏' } };
* const ssr = getSSRString(serverSideProps);
const html = `
<div id="app">${ssr}</div>
+ <script id="__SERVER_PROPS__">${JSON.stringify(serverSideProps)}</script>
<script type="module">${clientJS}</script>`
return html
};
これだけではhydrationが完全にできません、props以外の部分は動きますが、差分検知でNextJSでもよく目にする(index):2428 Warning: Text content did not match. Server: xxxxx
のwarningが出てしまいます。
それを解決するためにclientサイドでもう一回App.tsx
のpropsに渡してあげる必要があります。
import * as React from 'react';
import { hydrateRoot } from 'react-dom/client';
let i = 0;
type Props = {
serverData?: {
name: string;
};
};
export function App(props: Props) {
React.useEffect(() => {
const timer = setInterval(() => {
console.log(i++, props);
}, 1000);
return () => {
clearInterval(timer);
};
});
return <div>Component {JSON.stringify(props)}</div>;
}
// @ts-ignore
if (typeof document !== 'undefined') {
// @ts-ignore
const dc = document as any;
+ const data = JSON.parse(dc.getElementById('__SERVER_PROPS__')?.textContent);
- hydrateRoot(dc.getElementById('app'), <App />);
+ hydrateRoot(dc.getElementById('app'), <App serverData={data.serverData} />);
}
これで完成です。
Discussion