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 も、skypack
やesm.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> ); }
きちんと動作していることを確認できる。
ここからいよいよ、動的に 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 が生成され、それがブラウザに渡されるようになる。
しかし動作確認してみると、初期表示は確かに意図したものになっているが、すぐに消えてしまう。
これは、ハイドレーションする際にApp
コンポーネントにprops.initialFood
を渡していないために発生している。
ページアクセス時に発生している具体的な処理の流れは、以下の通り。
potato
の情報を含んだ HTML がサーバサイドで構築され、それがブラウザに渡される- ブラウザはその HTML を表示するため、
potato
のデータが表示される - ブラウザは
bundle.js
(client.js
をビルドしたもの)を読み込む bundle.js
は<App />
をハイドレーションするinitialFood
が渡されていないので、food
がundefined
になり、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") );
これで、意図した通りに動くようになる。
まとめ
整理すると、ハイドレーションも含む SSR を実現するためには、以下の要素を実現する必要がある。
- 動的なデータをコンポーネントに渡した上で、それ使って HTML を構築する
- ハイドレーションするためのスクリプトをビルドし、HTML に挿入しておく
- HTML を構築する際に使ったデータをハイドレーションでも利用するため、そのデータを(JavaScript から利用可能な形で)HTML に埋め込んでおく
これらをどのように実現しているかはフレームワークによって異なるだろうが、基本的な考え方や仕組みは、このようなものだと思われる。