30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Deno で 学ぶ React のサーバサイドレンダリング

Deno で React のサーバサイドレンダリング(以下、SSR)を実現する方法をハンズオン形式で書いていく。
自分が調べた範囲では、単に JSX で HTML を構築して終わり、という記事が多かった。それではあまり実用的ではないので、この記事ではハイドレーションまで行う。

また、React で SSR する方法を調べたところ、ほとんどの記事が Next.js を前提としていた。確かに Next.js を使わずに SSR するケースはあまりないだろうし、記事としても需要がないのだと思う。
しかし、Next.js のようなフレームワークが裏側で何をやってくれているのかを知ることで、SSR に対する理解を深めることができる。
事実、私は SSR をほとんど使ったことがなかったが、この記事を書くことでかなり考えを整理することができた。

Deno のバージョンは1.11.2で動作確認している。

前置き

予め断っておくと、TypeScript で書くことは断念した。理由は以下の通り。

まず前提として、React の本体をインポートすることが簡単ではない。
Deno では、import React from "https://jspm.dev/react@17.0.2"のように、CDN からインポートすることで npm パッケージを使うことができる。しかし多くの npm パッケージは、Deno で動かすことを想定していない。そのため、インポートしようとしても、クラッシュしてしまうことが多い。
React も、skypackesm.shといった CDN からインポートしようとしたが、上手くいかなかった。jspm.devからバージョン17.0.2をインポートすることで、ようやく動いた。

しかし TypeScript で書くためにはそれだけでは不十分で、型ファイルも手に入れなければならない。これもライブラリ本体と同じ問題を抱えており、既存の.d.tsファイルを使おうとしても、Deno はそれを理解できずにクラッシュしてしまう。Deno で動く React の型定義が有志によって提供されているのだが、そのバージョンは16.13.1であり、17.xは見つけられなかった。
それならばライブラリの本体を16.13.1にするという手もあるが、これも上手くいかない。jspm.devから16.13.1をインポートした場合、Hooks を使うとクラッシュする。dev.jspm.ioからインポートした16.13.1なら動きそうだったが、dev.jspm.io今月いっぱいで終了する予定なので、選択肢から除外した。

そのため最終的には、jspm.devが提供している17.0.2を TypeScript なしで使う、という結論になった。

TypeScript を利用できない時点で、Deno の持っている魅力が大きく損なわれることになる。
だがそれでも、ビルドツールを弄る必要がなく、Node.js の複雑なモジュールシステムから解放されるメリットは大きい。
個人的な感想だが、Deno は全体的にシンプルで、実験や検証のための環境を簡単に構築できるのがよい。そのため、Node.js よりも取り回しがよい印象を持っている。

最も単純なパターン

前向きも終わったので、早速コードを書いていく。

まずはサーバを立てる必要がある。
JSX を扱うためには拡張子を.tsx.jsxにする必要があるが、前述の通り TypeScript は使えないので、.jsx一択となる。
以下の内容のserver.jsxを書く。

import { listenAndServe } from "https://deno.land/std@0.99.0/http/mod.ts";
import React from "https://jspm.dev/react@17.0.2";
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";

function App() {
  return <div>Hello SSR</div>;
}

listenAndServe({ port: 8080 }, (req) => {
  req.respond({
    status: 200,
    headers: new Headers({
      "Content-Type": "text/html",
    }),
    body: ReactDOMServer.renderToString(
      <html>
        <head></head>
        <body>
          <div id="app">
            <App />
          </div>
        </body>
      </html>
    ),
  });
});

$ deno run --watch --allow-net server.jsxを実行しhttp://localhost:8080/にアクセスすると、Hello SSRと表示される。

これだけでも SSR と呼ぶことはできるが、これではただ単に HTML を書いているのとほとんど変わらない。そのためここから、より「現実的な」内容に書き換えていく。
まずは、React の機能を使ってインタラクティブなページにする。

ページをインタラクティブにする

AppコンポーネントをApp.jsxとして別ファイルに切り出す。そして以下の内容にする。

import React, { useState } from "https://jspm.dev/react@17.0.2";

export function App() {
  const [count, setCount] = useState(0);

  const onClick = () => {
    setCount((currentCount) => currentCount + 1);
  };

  return (
    <div>
      {count}
      <br />
      <button type="button" onClick={onClick}>
        count up
      </button>
    </div>
  );
}

これでページにアクセスすると、0やボタンの描画は上手くいっているのだが、ボタンを押下しても何の反応もない。
これは当然で、サーバはただ単に<div>0<br/><button type="button">count up</button></div>という HTML(ただの文字列)を返しているだけであり、JavaScript が存在しないのだからインタラクティブになるわけがない。
そのため、サーバが返す HTML にscript要素を追加して、ブラウザに JavaScript を読み込ませる必要がある。

JavaScript ファイルの作成

ブラウザに読み込ませたい JavaScript ファイルを、client.jsという名前で作成する。

import React from "https://jspm.dev/react@17.0.2";
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";

import { App } from "./App.jsx";

ReactDOM.hydrate(<App />, document.getElementById("app"));

ここでハイドレーションすることで、サーバサイドで予め描画された HTML を、JavaScript と紐付けている。こうすると、ただの文字列だったAppコンポーネントが、インタラクティブなものになる。

client.jsはそのままではブラウザで読み込めないので、$ deno bundle client.jsx bundle.jsを実行してビルドする。生成されたbundle.jsは、ブラウザで読み込むことができる。

あとは、server.jsxを書き換えて、ブラウザにbundle.jsを読み込ませればよい。
以下が、変更後のserver.jsx。ルーティングの実装と、HTML のなかにscript要素を追加したことが、主な変更点。

import { listenAndServe } from "https://deno.land/std@0.99.0/http/mod.ts";
import React from "https://jspm.dev/react@17.0.2";
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";

import { App } from "./App.jsx";

const BUNDLE_JS_FILE_URL = "/bundle.js";

const js = await Deno.readFile(`.${BUNDLE_JS_FILE_URL}`);

listenAndServe({ port: 8080 }, (req) => {
  switch (true) {
    case req.url === "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App />
              </div>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }
    case req.url === BUNDLE_JS_FILE_URL: {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/javascript",
        }),
        body: js,
      });
      break;
    }
    default: {
      req.respond({
        status: 404,
        headers: new Headers({
          "Content-Type": "text/plain",
        }),
        body: "Not found\n",
      });
      break;
    }
  }
});

$ deno run --watch --allow-net --allow-read server.jsxでサーバを起動しページにアクセスすると、ボタンが動作するようになっている。

ただこのやり方だと、App.jsxに変更がある度にその都度client.jsをビルドしないといけない。これでは不便なので、server.jsxのなかでビルドが行われるようにする。具体的には、以下のようにする。

    case req.url === BUNDLE_JS_FILE_URL: {
      const js = await Deno.emit("./client.jsx", { bundle: "module" }).then(
        (res) => {
          return res.files["deno:///bundle.js"];
        }
      );
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/javascript",
        }),
        body: js,
      });
      break;
    }

ブラウザから/bundle.jsにアクセスがある度に、Deno.emitでビルドし、その結果を取り出してレスポンスボディとして使っている。
ページをリロードする度にビルドされるので、開発環境ではこちらのやり方のほうがよいはず。本番環境では事前にビルドさせておくべきなので、環境変数を使って処理を分けるのがよいと思う。
また、Deno.emitは unstable な機能なので、--unstableフラグが必要になる。具体的には、$ deno run --allow-net --allow-read --unstable server.jsxでサーバを起動させる必要がある。

アクセスの度に動的に HTML を生成する

これでページがインタラクティブになった。だがこれでもやはり、SSR にする意味はほとんどない。クライアントからアクセスがあった際に動的に HTML を作りそれを返してこそ、SSR にする意味がある。そのためこれから、そのような状況を擬似的に再現していく。

なぜアクセスがある度に HTML を生成するのか。それは、リクエストに先立ってコンテンツを用意することが難しいからだ。
例えば、人によってコンテンツの内容が変わるケースがこれに該当する。ログインしているか否かで見せるべきコンテンツが変わったり、マイページのようにユーザー毎に内容が変わったり。また、コンテンツが可変である場合も、事前にコンテンツを用意することが難しくなる。ブログ記事の「コメント」や「いいね」は時間の経過によって変化していくわけだが、アクセスがあったタイミングでの最新の状態を返す必要がある。そのため、事前にビルドしておく、ということが難しい。
リクエストに先立ってコンテンツを用意できるのであれば、わざわざ SSR する必要性は薄い。SSG のように事前にビルドしておけばよいし、内容によっては自分で HTML ファイルを書いたってよい。
アクセスの度にコンテンツを用意する場合、SSR を使わずクライアントサイドレンダリング(以下、CSR)だけで済ませるという選択肢もある。だが CSR の場合、クライアントで API へのアクセスや UI の構築を行うため、ページの初回表示が遅くなりやすい。検索エンジンや SNS のクローラーがコンテンツを正しく読み込んでくれない、という問題もある。これらを許容できない場合に、SSR の導入が有力な選択肢となってくる。

これから作っていくサンプルも、リクエストに先立ってコンテンツを用意することが何らかの要因で出来ず、アクセスの度にコンテンツを生成する状況を想定している。そして同じく何らかの要因で、SSR が採用された。

server.jsxに手を加えて、簡易的な API を作る。/api/で始まる API にアクセスすると、食品のアンケートに関するデータを JSON 形式で取得できる。
DUMMY_DBという名前を付けているように、実際にはデータベースにアクセスしてデータを取得することを想定している。あるいは、外部 API を叩いていると見做してもよい。そのため、実際にはこれらの値は固定値ではなく、随時変化していく。

const DUMMY_DB = new Map([
  ["potato", { name: "potato", like: 10, dislike: 0 }],
  ["carrot", { name: "carrot", like: 6, dislike: 4 }],
  ["tomato", { name: "tomato", like: 3, dislike: 7 }],
]);

// 中略

    case /^\/api\//.test(req.url): {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "application/json",
        }),
        body: JSON.stringify(DUMMY_DB.get(req.url.slice(5))),
      });
      break;
    }

まずは API の動作確認も兼ねて、CSR だけで UI を構築する。
App.jsxを以下のようにすると、ボタンを押下する度に API から情報を取得し、それを元に UI を作り変えるようになる。

import React, { useState, useEffect } from "https://jspm.dev/react@17.0.2";

export function App() {
  const [food, setFood] = useState(null);

  const onClick = (name) => {
    fetch(`/api/${name}`)
      .then((res) => res.json())
      .then((data) => setFood(data));
  };

  useEffect(() => {
    fetch("/api/potato")
      .then((res) => res.json())
      .then((data) => setFood(data));
  }, []);

  return (
    <div>
      {food && (
        <p>
          name: {food.name}
          <br />
          like: {food.like}
          <br />
          dislike: {food.dislike}
        </p>
      )}
      <p>
        <button type="button" onClick={() => onClick("potato")}>
          potato
        </button>{" "}
        <button type="button" onClick={() => onClick("carrot")}>
          carrot
        </button>{" "}
        <button type="button" onClick={() => onClick("tomato")}>
          tomato
        </button>
      </p>
    </div>
  );
}

きちんと動作していることを確認できる。

f:id:numb_86:20210627160616g:plain

ここからいよいよ、動的に HTML を作っていく。
今は、常に同じ HTML が返される。そしてAppコンポーネントのマウント後にuseEffectが実行され、/api/potatoからデータを取ってきている。まずボタンだけが描画されデータは時間差で描画されるのは、このためである。
これを、サーバサイドで予めpotatoのデータを取得して、それを元に HTML を生成して返すようにする。そうすると、データベースの最新の値が反映された HTML がサーバから返されることになり、アクセスのタイミングによって HTML の内容が変化することになる。

App.jsxからuseEffectを削除して、props.initialFoodを受け取るようにする。そしてそれをfoodの初期値にする。

-export function App() {
-  const [food, setFood] = useState(null);
+export function App({ initialFood }) {
+  const [food, setFood] = useState(initialFood);

   const onClick = (name) => {
     fetch(`/api/${name}`)
@@ -9,12 +9,6 @@
       .then((data) => setFood(data));
   };

-  useEffect(() => {
-    fetch("/api/potato")
-      .then((res) => res.json())
-      .then((data) => setFood(data));
-  }, []);
-

そして、HTML を生成する際にデータベース(DUMMY_DB)からデータを取得し、それをAppに渡すようにする。

    case req.url === "/": {
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App initialFood={DUMMY_DB.get("potato")} />
              </div>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }

これで、potatoの最新のデータが埋め込まれた HTML が生成され、それがブラウザに渡されるようになる。
しかし動作確認してみると、初期表示は確かに意図したものになっているが、すぐに消えてしまう。

f:id:numb_86:20210627160721g:plain

これは、ハイドレーションする際にAppコンポーネントにprops.initialFoodを渡していないために発生している。
ページアクセス時に発生している具体的な処理の流れは、以下の通り。

  1. potatoの情報を含んだ HTML がサーバサイドで構築され、それがブラウザに渡される
  2. ブラウザはその HTML を表示するため、potatoのデータが表示される
  3. ブラウザはbundle.jsclient.jsをビルドしたもの)を読み込む
  4. bundle.js<App />をハイドレーションする
  5. initialFoodが渡されていないので、foodundefinedになり、potatoに関する表示が消えてしまう

つまり、ブラウザで読み込まれる JavaScript ファイル(bundle.js、ビルド前はclient.js)においても、AppコンポーネントにinitialFoodを渡す必要がある。
だがinitialFoodはサーバサイドで作られるため、ブラウザで実行される JavaScript は、その内容を知ることができない。
そこで、initialFoodとして使われるデータを HTML に埋め込んでおくことで、この問題に対処する。

server.jsxの HTML 生成部分を、以下のように書き換える。
新しくscript要素を作成し、そのdata-json属性に、potatoのデータを埋め込んでいる。これで、initialFoodとして使われているpotatoのデータをブラウザに渡せるようになる。

    case req.url === "/": {
      const initialFood = DUMMY_DB.get("potato");
      req.respond({
        status: 200,
        headers: new Headers({
          "Content-Type": "text/html",
        }),
        body: ReactDOMServer.renderToString(
          <html>
            <head></head>
            <body>
              <div id="app">
                <App initialFood={initialFood} />
              </div>
              <script
                id="initial-food"
                type="text/plain"
                data-json={JSON.stringify(initialFood)}
              ></script>
              <script type="module" src={BUNDLE_JS_FILE_URL}></script>
            </body>
          </html>
        ),
      });
      break;
    }

そしてclient.jsで、その埋め込まれたデータを HTML から取り出してAppコンポーネントに渡せばよい。

import React from "https://jspm.dev/react@17.0.2";
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";

import { App } from "./App.jsx";

const initialFood = JSON.parse(
  document.getElementById("initial-food").getAttribute("data-json")
);

ReactDOM.hydrate(
  <App initialFood={initialFood} />,
  document.getElementById("app")
);

これで、意図した通りに動くようになる。

f:id:numb_86:20210627160806g:plain

まとめ

整理すると、ハイドレーションも含む SSR を実現するためには、以下の要素を実現する必要がある。

  • 動的なデータをコンポーネントに渡した上で、それ使って HTML を構築する
  • ハイドレーションするためのスクリプトをビルドし、HTML に挿入しておく
  • HTML を構築する際に使ったデータをハイドレーションでも利用するため、そのデータを(JavaScript から利用可能な形で)HTML に埋め込んでおく

これらをどのように実現しているかはフレームワークによって異なるだろうが、基本的な考え方や仕組みは、このようなものだと思われる。

参考資料