Remixが行なっているSSRの処理を超シンプルなアプリで確認してみました。
やりたいことは、下記のページとほぼ同じです。
最新のRemixのバージョンではこのサイトの説明通りでは動作しないところがあったため、自前でやってみました。
- Remix本家のQuick Startのテンプレートをダウンロードする
- テンプレートからSSRの動作確認に不要なファイルとコードを削除し、超シンプルなアプリに書き換える
- サーバサイドとクライアントサイドでRemixが行う処理のエントリポイントにログを仕込む
- root.tsxを使ってSSRの動作を確認していく
- Hookがない静的なコンポーネント
- Reactの標準的なHook(useState)を含むコンポーネント
- RemixのHook(useLoaderData)を含むコンポーネント
Quick Startテンプレートをダウンロードする。
Remix本家のQuick Startページの説明に従ってテンプレートをダウンロードし、ビルド、動作確認をします。
npx create-remix@latest remix-minimal
cd remix-minimal
npm run dev
超シンプルなアプリに書き換える
app/root.tsx
を超シンプルな静的なコンテンツに書き換えます。
import {
Scripts,
} from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
</head>
<body>
<h1>Hello, Remix-Minimal</h1>
<Scripts />
</body>
</html>
);
}
さらにroutes
ディレクトリ、CSSに関するファイル、ロゴ画像などのリソースファイルを削除します。
rm -rf ./app/routes
rm ./app/tail
rm ./app/tailwind.css
rm ./tailwind.config.js
rm ./postcss.config.js
rm ./public/*.png
下記のようなディレクトリ構成になります。
画面表示は下記です。
レンダリング処理のエントリポイントにログを仕込む
Remixで行われるレンダリング処理のエントリポイントは下記です。
- サーバーサイド: app/entry-server.tsx
- クライアントサイド: app/entry-client.tsx
これらの処理が動いていることを確認するためにログを入れておきます。
entry-server.tsxはisbot()の処理の削除も行なっておきます。
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
return handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
console.log(`Remix SSR ${request.url}`);
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
console.log(`Remix Hydration ${document.URL}`);
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
この状態でnpm run dev
を実行してサーバーを再起動し、ブラウザからhttp://localhost:5173
にアクセスします。アクセス時はキャッシュをクリアするために、強制リロードをします。(Safariの場合はShiftボタンを押しながらリロード)
サーバ側に下記のようなログが出力されます。SSRの処理が動いていることがわかります。
Remix SSR http://localhost:5173/
クライアント側であるブラウザの開発ツールのコンソールには下記のようなログが出力されます。ハイドレーションの処理が動いていることが確認できます。
root.tsxを使ってSSRの動作を確認していく
root.tsxを使ってSSRの動作を確認していきます。
Hookがない静的なコンポーネント
まずはHookがない静的なコンポーネントです。
現在のroot.tsxはこの状態になっています。
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
</head>
<body>
<h1>Hello, Remix-Minimal</h1>
<Scripts />
</body>
</html>
);
}
ブラウザからアクセスすると、SSRとHydrationのログがそれぞれ出ていることが確認できます。また、ブラウザにダウンロードされたHTMLのソースコードを見ると下記のようになっています。root.txsのJSXがHTMLにレンダリングされた結果がダウンロードされていることがわかります。
useState Hookを含むコンポーネント
次にuseState Hookを使ってみます。root.tsxを下記のように書き換えます。
import {
Scripts,
} from "@remix-run/react";
import { useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
</head>
<body>
<h1>Hello, Remix-Minimal</h1>
<h2>Counter: {count}</h2>
<button onClick={() => setCount((c) => c + 1)}>Increment Counter</button>
<Scripts />
</body>
</html>
);
}
ブラウザからアクセスすると、SSRとHydrationのログがそれぞれ出ていることが確認できます。
ブラウザにダウンロードされたHTMLのソースコードを見ると下記のようになっています。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charSet="utf-8"/>
</head>
<body>
<h1>Hello, Remix-Minimal</h1>
<h2>
Counter: <!-- -->
0
</h2>
<button>Increment Counter</button>
...
</body>
出力されたHTMLは下記のような静的なものになっています。
- count変数は初期値0でレンダリングされています
- buttonにはonClickハンドラが登録されていません
ブラウザの開発ツールでDOMを確認すると、ハイドレーションによって、buttonにイベントハンドラが登録されていることがわかります。
buttonをクリックすると、通常のReactコンポーネントと同じようにHookが動作して、counter変数がインクリメントされていることが確認できます。
useLoaderData Hookを含むコンポーネント
root.tsxにRemixのHookであるuserLoaderData Hookを追加してみます。
import {
Scripts,
useLoaderData,
} from "@remix-run/react";
import { useState } from "react";
export const loader = () => {
console.log("Remix loader root.tsx");
return ({ items: [
{ id: 1, name: "apple" },
{ id: 2, name: "orange" },
{ id: 3, name: "melon" },
]});
}
export default function App() {
const { items } = useLoaderData<typeof loader>();
console.log(`items: ${JSON.stringify(items)}`);
const [count, setCount] = useState(0);
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
</head>
<body>
<h1>Hello, Remix-Minimal</h1>
<h2>Counter: {count}</h2>
<button onClick={() => setCount((c) => c + 1)}>Increment Counter</button>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
Item[{count}]: {items[count]?.name || "none"}
<Scripts />
</body>
</html>
);
}
ブラウザからアクセスすると、SSRとHydrationのログがそれぞれ出ていることが確認できます。
ブラウザにダウンロードされたHTMLのソースコードを見ると下記のようになっています。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charSet="utf-8"/>
</head>
<body>
<h1>Hello, Remix-Minimal</h1>
<h2>
Counter: <!-- -->
0
</h2>
<button>Increment Counter</button>
<ul>
<li>apple</li>
<li>orange</li>
<li>melon</li>
</ul>
Item[<!-- -->
0<!-- -->
]: <!-- -->
apple
...
</body>
...
<div hidden id="S:0">
<script>
window.__remixContext.streamController.enqueue("
[
{\"_1\":2,\"_18\":-5,\"_19\":-5},
\"loaderData\",
{\"_3\":4},
\"root\",
{\"_5\":6},
\"items\",
[7,12,15],
{\"_8\":9,\"_10\":11},
\"id\",
1,
\"name\",
\"apple\",
{\"_8\":13,\"_10\":14},
2,
\"orange\",
{\"_8\":16,\"_10\":17},
3,
\"melon\",
\"actionData\",
\"errors\"
]\n");
</script>
<!--$?-->
<template id="B:1"></template>
<!--/$-->
</div>
...
出力されたHTMLは下記のようになっています。
- loader関数で取得したitemsの情報が箇条書き(li)要素に展開されている
- loader関数で取得したitemsの情報がloaderDataとしてStreamControllerにエンキューされている
loader関数で取得したデータは、HTMLの中と、JavaScriptの中の2箇所に存在しています。
後者のデータは、クライアントサイドのJavaScriptからもuseLoaderData()の戻り値として利用することができます。root.tsxの下記の部分はクライアント側でIncrement Counterボタンが押されるごとに、データを参照して表示を書き換えています。
Item[{count}]: {items[count]?.name || "none"}
loaderが返すデータには、.dataのsuffixをつけたURLからもアクセスができます。
今回の場合、http://localhost:5173/.data
にアクセスすると下記のデータが返ってきます。
これはuseFetcher Hookを使ってfetcher.load()関数を呼び出した時にアクセスするURLと同じです。
まとめ
RemixではすべてのReactコンポーネントに対してサーバーサイドでのレンダリング処理(SSR)の処理と、クライアントサイドでのハイドレーション処理が行われます。
これらの処理のエントリポイントはapp/entry.server.tsx
とapp/entry.client.tsx
です。
サーバーサイドレンダリングはtsxファイルを評価し、HTMLを出力する処理です。ハイドレーションはダウンロードしたHTMLから構築したDOMと、Reactの仮想DOMを同期する処理です。ハイドレーションを行った後は、通常の(いわゆるSPAの)Reactアプリケーションと同じようにインタラクティブな処理が有効になります。
SSRでloader関数を使ってロードしたデータは、HTML内のJavaScriptコードに埋め込まれます。ハイドレーション後、ブラウザ上のReactコンポーネントはそれらのデータにアクセスすることができます。
これらのデータには、通常のURLに.data
suffixをつけたURLから直接アクセスすることもできます。useFetcher HookではこのURLにアクセスしてloadされたデータを取得します。返ってくるデータはloader関数でreturnされているJSONオブジェクトではなく、turbo-stream形式(?)のデータです。
考察(Remixとマイクロサービス)
Remixではサーバサイドの処理とクライアントサイドの処理が一つのファイルに実装されます。
そのため、Remixにおけるサーバサイドの処理はあくまでそのアプリ専用の処理と考えるのが良いと思います。
そのアプリだけで使うデータベースにアクセスする処理や、そのアプリのデータを引数にして外部のAPIにアクセスするような処理が適しています。
いわゆるマイクロサービスに基づく設計では、APIサービスと、それらを利用してユーザにアプリケーションを提供するサービスが複数存在します。Remixは後者のアプリケーションサービスを実現するために適したフルスタックフレームワークであると言えます。RemixでもAPIサービスを実装することができますが、Remixを使う必然性はありません。RemixはUIを持つアプリケーションの実装でその効果を発揮するためです。
したがって、マイクロサービス設計のシステムにおいては、下記が適切な設計であると考えます。
- APIサービスはRemixとは別の、よりAPIの実装に適したランタイムとフレームワークを用いて実現する
- UIを伴うアプリケーションサービスの実装にはRemixを利用し、Remixのloader関数やaction関数からAPIサービスを利用する形で連携する
リソース
この記事で作成したコードは下記からダウンロードできます。