®️

Bunとesbuild使って、自前のNextJSを作ってみる

2023/09/09に公開

Bunがついにリリースされたので、試しながらNextJSを自前で作ってみました。
https://github.com/qaynam/bun-react-ssr

プロジェクト作成

以下のコマンド叩けば、いくつか質問されながら、プロジェクトが作成されます、ちなみにnode_modulesのインストール時間がとても早かった、

bun init

bun init result

expressをインストール

ここは正直なんでも大丈夫です、自分はexpressに慣れているので、expressをインストールしました。
スピードでいうとBun Serverがいいのかもしれません。
https://bun.sh/docs/api/http

bun add express @types/express

インストール時間はなんと1.78s!!!!
express install

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`);
});

express install

reactのインストール

bun add react react-dom @types/react @types/react-dom

esbuildのインストール

esbuildはreactのコードをbundleするために使います。

bun add esbuild

Reactコンポーネント作成

ちなみに、ディレクトリは以下のようにしています。
localhost

view/App.tsx
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/serverrenderToString関数を使えばjsxをstringとして出力することがでます。

index.ts
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`);
});

サーバー再起動すると以下のように反映されます。

localhost-2

react componentをhydrationする

先程はただのhtml stringを画面に出しただけなので、それだけでは、reactの中に書かれているuseEffectなどの処理がjsとして実行されません、それを可能にするには、react-dom/clienthydrateRootを使う必要があります。

App.tsxにhydration処理を追加します

view/App.tsx
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関数を定義します。

view/ssr.tsx
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してもらい、そのファイルをもう一回読み込んでいます、本当はそのままパイプで貰えるはずですが、うまいやり方見つからなかったので、知っている方がいたら教えてください。🙇‍♂️

view/index.ts
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を編集します。

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`);
});

localhost-3

getServersidePropsっぽいのを作ってみる

まずはApp.tsxにpropsを受け取れるようにします。

view/App.tsx
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を受け取れるようにします。

view/ssr.tsx
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タグに中に置くようにします。

view/index.ts
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が出てしまいます。

localhost-4

それを解決するためにclientサイドでもう一回App.tsxのpropsに渡してあげる必要があります。

view/App.tsx
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} />);
}

これで完成です。

done

Discussion