EY-Office ブログ

Remix3のFrameの動作解説。前回の続き

先週のRemix 3のFrameを使いジャンケンアプリを動かしました、苦労したけど面白い!の続きです。

前回の説明は個々のコードの解説だったので、Remix3やFrame全体の流れは判りにくかったと思い。今回は図を書きRemix3のFrameの動作を解説しました。

remix3-frame Bing Image Creatorが生成した画像を使っています

開発環境

本題に入る前に、前回は開発環境の説明をしてなかったので簡単に説明します。package.jsonのscriptには以下の2つのスクリプトが登録されています。

devは開発用サーバーをtsxコマンドで起動してTypeScript→JavaScript変換を行ってサーバーサイドのコードを実行しています。
dev:browserはクライアントサイドで実行されるJavaScriptの生成(生成されたJavaScriptはpublic/assets/に置かれブラウザーからアクセスされます)を行います。

どちらも起動しておく必要があります。またHMR(ホットリロード)はサポートされてないのでコードを変更した際にはブラウザーのリロード・ボタンを押す必要があります、現在の開発環境は最低限の実装といったレベルです。

  • package.json
  "scripts": {
    "dev": "NODE_ENV=development tsx watch server.ts",
    "dev:browser": "esbuild app/assets/*.tsx --outbase=app/assets
       --outdir=public/assets --bundle --minify --splitting --format=esm
       --entry-names='[dir]/[name]' --chunk-names='chunks/[name]-[hash]'
       --sourcemap --watch"

    ・・・
  }

Remix3のFrame処理の流れ

本題のRemix3のFrame処理の流れを説明します、以下の画像はデベロッパーツールでブラウザーのネットアクセスを表示したものです。 オレンジ色の点線の上は初期表示時のネットアクセスで、点線の下はジャンケンボタンを押した際のネットアクセスです。
browser@4はTailwind CSSの取得なので無視してください。

初期表示(1)

Remix3はサーバーサイドレンダリング(SSR)のフレームワークなので/へのGETリクエストがサーバーに届くと、サーバーサイドのapp/jyanken.tsxが動き(レンダリングされ)、ここから呼び出されるる複数のコンポーネントがレンダリングされてHTMLが生成されます。クライアントサイドで動作するte-button.tsxもいったんサーバーサイドでレンダリングされます、当然イベントハンドラー等のJavaScriptのコードは含まれません。

また、重要な点は/assets/entry.jsがブラウザーにロード&実行される事です。

  • app/jyanken.tsx

サーバーサイドのルーティングに対応したジャンケンアプリ用の特殊なコンポーネントです。

・・・

export default  {
  middleware: [],
  handlers: {
    index() {
      return render(
        <html lang="en">
            <head>
              <meta charSet="UTF-8" />
              <meta name="viewport" content="width=device-width, initial-scale=1.0" />
              <script type="module" async src={routes.assets.href({ path: "entry.js" })} />
              <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
              <title>Jyanken</title>
            </head>
            <body>
              <div class="mx-8 w-1/2">
                <h1 class="my-6 text-center text-xl font-bold">
                  ジャンケンポン
                </h1>
                <Frame src={routes.fragments.jyankenMain.href()} />
              </div>
            </body>
          </html>
      )
    },
   ・・・
  }
} satisfies RouteHandlers<typeof routes.jyanken>
app/jyanken.tsxから呼び出されるコンポーネント
  • app/components/jyanken-main.tsx
  • app/components/jyanken-box.tsx
  • app/components/score-list.tsx
  • app/assets/te-button.tsx
生成されたHTML

ここで生成されたHTMLの一部を載せます。

  • ① クライアントサイドで最初に動くJavaScriptです
  • ② ここがFrameの始まりの目印です
  • ③ クライアントサイドのコンポーネントの始まりの目印?
  • ④ コンポーネントをハイドレーションするための情報です
    • コンポーネントのURLや名前、引数(Props)等が書かれています
  • ⑤ Frameの終わりの目印と、Frameを読み込むためのJSON情報
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script type="module" async src="/assets/entry.js"></script> <!--  ← ①  -->
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    <title>Jyanken</title>
  </head>
  <body>
    <div class="mx-8 w-1/2">
      <h1 class="my-6 text-center text-xl font-bold">ジャンケンポン</h1>
      <!-- frame:start:f1 -->         <!--   ← ②   -->
      <div class="w-[270px] mx-auto flex justify-center mb-10">
        <div class="flex-1">
          <!-- rmx:h -->              <!--   ← ③   -->
          <form method="POST" action="/">
            <button
              type="submit"
              class="text-white text-center text-sm rounded w-16 px-2 py-2 bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50"
              style="opacity: 1"
            >
              グー</button
            ><input type="hidden" name="human" value="0" />
          </form>
          <!-- /rmx:h -->              <!--   ↓ ④   -->
          <script type="application/json" rmx-hydrated>
            {
              "moduleUrl": "/assets/te-button.js",
              "exportName": "TeButton",
              "props": { "value": 0 },
              "id": "h1.1"
            }
          </script>
        </div>

        ・・・

      <!-- frame:end:f1 -->           <!--   ↓ ⑤   -->
      <script type="application/json" rmx-frame="f1">
        {
          "id": "f1",
          "status": "resolved",
          "src": "/fragments/jyanken-main"
        }
      </script>
    </div>
  </body>
</html>

初期表示(2)

jyanken.jsのコードが実行されハイドレーションが開始されます。ここでRemix3のランタイムライブラリーが読み込まれ、そのランタイムライブラリーがクライアントサイドのコンポーネントte-button.jsを読み込み、HTML上のボタンへのイベントハンドラー等が設定されます。その際には上の④で説明したJSON情報にあるPropsが使われます。

  • app/assets/entry.tsx

このコードはランタイムライブラリーから呼び出されるJavaScriptモジュールのローダーです。特別な処理を組み込めるようにアプリケーション側にあるそうです。

import { createFrame } from '@remix-run/dom'

createFrame(document, {
  async loadModule(moduleUrl, name) {
    let mod = await import(moduleUrl)
    if (!mod) {
      throw new Error(`Unknown module: ${moduleUrl}#${name}`)
    }

    let Component = mod[name]
    if (!Component) {
      throw new Error(`Unknown component: ${moduleUrl}#${name}`)
    }

    return Component
  },

   ・・・
})

ジャンケンボタンを押した(1)

ジャンケンボタンを押すと、下に示したイベントハンドラーが実行されます。 この中でfetch(action, { method, signal, body })が実行され、サーバーに POST / が送られます。パラメーターとして{human: 2}のような人間が押したボタンの情報も送られサーバーサイドでジャンケンが実行され、ジャンケンの結果がサーバーサイドのモデルに格納されます。

  • app/assets/te-button.tsx

イベントハンドラーの部分のみ書きだしました。

<form method={method} action={action}
   on={dom.submit(async (event, signal) => {
     event.preventDefault()
     const body = new FormData(event.currentTarget)

     posting = true
     this.update()

     await fetch(action, { method, signal, body })      // ← (1)
     if (signal.aborted) return

     await this.frame.reload()                          // ← (2)
     if (signal.aborted) return

     posting = false
     this.update()
   })}>

   <button type="submit" class={buttonClass} style={{opacity: posting ? 0.5 : 1.0}}>
     {TeString[value]}
   </button>
   <input type="hidden" name="human" value={value.toString()} />
 </form>
  • app/jyanken.tsx

サーバーサイドではこのリクエストを受け付け、pon関数が実行されます。

・・・

export default  {
  middleware: [],
  handlers: {

    ・・・

    async pon({ formData }) {
      createScore(Number(formData.get('human')?.toString() ?? '0') as Te)
      return new Response(null, { status: 204 })
    }
  }
} satisfies RouteHandlers<typeof routes.jyanken>

ジャンケンボタンを押した(2)

イベントハンドラー内のthis.frame.reload()が実行されるとランタイムライブラリーを経由して、entry.jsresolveFrame関数が呼び出されます。
resolveFrameは、⑤のJSONデータをもとにサーバーサイドの /fragments/jyanken-mainにリクエストが送りapp/fragments.tsxが起動されます。
そこで<JyankenMain />コンポーネントがレンダリングされ生成されたHTML片が戻されます。 戻ったHTML片をランタイムライブラリーが、画面のうちFrameの内部を書きかえます。

  • app/assets/entry.tsx

このコードはランタイムライブラリーから呼び出され、サーバーサイドのFrameのレンダリングを呼出す関数です。特別な処理を組み込めるようにアプリケーション側にあるそうです。

import { createFrame } from '@remix-run/dom'

createFrame(document, {
  ・・・

  async resolveFrame(frameUrl) {
    let res = await fetch(frameUrl)
    if (res.ok) {
      return res.text()
    }

    throw new Error(`Failed to fetch ${frameUrl}`)
  },
})
  • app/fragments.tsx

サーバーサイドのルーティングに対応したFrameの用の特殊なコンポーネントです。

export default {
  middleware: [],
  handlers: {
    jyankenMain() {
      return render(<JyankenMain />)
    },
  },
} satisfies RouteHandlers<typeof routes.fragments>

まとめ

Remix3のFrameの解説をしました、このようにRemix3ではサーバーサイドレンダリング+ハイドレーションやFrameによる部分書き変えの動作が透けて見えています。 ここが高機能ですが全く内部が見えないNext.jsとの違いだと思います。

Remix3ではNext.jsに比べてたくさんのコードを書かないと行けません、また現時点では便利なサーバー・ファンクションもありません。
しかし、そこで得られるメリットは高いパフォーマンスでしょうか。 まず仮想DOMのような大きなランタイムライブラリーは有りませんし、動きが見えるということはパフォーマンス・チューニングが行いやすいということです。

以前取り上げたTanStack StartもRemix3と同様に透明性の高いフレームワークで、最近話題になってきています。
Next.jsが、一強のReact界にも変化があらわれるのでしょうか。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ