これは何
我々の住むReact世界も、始まって10年になりました。
この記事は、この世界が「データの取得」という課題にどう向き合ってきたか・今どうしようとしているか、細かい実装ではなく、大きな設計の歴史を語ってみる試みです。
最後に現在の到達点を代表してNext.js(App Router)とRemixを比べます。
SPA
はじめに神はブラウザへReactを与えられました。
ブラウザはReactの力でデータを取得し、HTMLを作れるようになったので、サーバーは空のHTMLと<script/>
タグを返すだけになりました。
SSR
仕事がないことを寂しがったサーバーは、自分もReactを使って、初期表示用のHTMLだけでも作ろうとしました。
しかしReactはブラウザに与えられたものですから、サーバーには真似できないこともあります。その1つがデータ取得でした。
Reactコンポーネントは特殊な関数です。コンポーネント自体が状態の更新で何十度でも実行されますし、ブラウザから叩くAPIを叩くしかありません。
その結果、サーバーにはどうにも解釈できないコードがあちこちにあります。
const Component = () => {
const [data, setData] = useState();//これが状態を表す
useEffect(() => { //1回しか実行しないよの印
fetch('https://api.com')
.then((res)=>res.json())
.then((data)=>{setData(data)})
}, []);
return <>{data}</>;
};
しかし、データ取得を全て諦めてしまえば大したHTMLは作れません。
困ったサーバーは、全く独自の方法でデータを取ることにしました。
Remixのloader
,Next.jsのgetServerSideProps
です。
取ったデータは、Reactの最上位のコンポーネントにpropsとして渡しました。
//ここはフレームワークの世界
export async function getServerSideProps() {
const data = await fetch('https://api.com');
return {
props: { data },
};
}
//ここから下がReactの世界
export default function Page({ data }) {
return <>{data}</>;
}
こうしてSSRフレームワークができました。
サーバーは独自にデータを取得し、ブラウザ用のReactコンポーネントを苦労して動かし、HTMLを送信します。
ブラウザは改めて全てのコンポーネントを実行して、サーバーには作れなかった部分を反映させます。(この反映はハイドレーションと呼ばれました。)
サーバーたちがすっかり完成のHTMLまで作成し、ブラウザは僅かなイベント登録をするだけ、というサイトもそこかしこに生まれてきました。
Reactはこれらサーバーの努力に驚き、また祝福して言われました。
「ちょっとこっち来て」
サバコン世界
Reactは、サーバーたちが輝くための新しい世界を作ったのでした1。サバコン世界と呼びましょう。
そもそも、太古よりサーバーたちは関数でHTMLを作ってきました。
import createHeading from './createHeading';
const HTMLRenderer = () => {
return (
`<div>
${createHeading('見出し')}
<p>本文だよ</p>
</div>`
);
};
ここにはサーバーにできることなら何でも書けました。
非同期でも良いですし、サーバーからしかアクセスできないデータを取るのも自由です。
その代わりに、当然静的なHTMLしか出力できません。
const HTMLRenderer = async () => {
const res = await fetch('https://serverOnly.com');
const data = await res.json();
return (
`<p>{data}</p>`
);
};
Reactはこれらの関数に
- JSXの力
- 他のReactコンポーネントを埋め込む力
を与えられ、「サーバーコンポーネント」と名づけられました。
import NormalComponent from './NormalComponent';
const ServerComponent = () => {
return (
<div>
<h1>このボタン押せるよ</h1>
<NormalComponent />
</div>
);
};
'use client';//通常コンポーネントの印
const NormalComponent = () => {
return (
<button
onClick={() => {
console.log('こいつはブラウザで動く');
}}
>
ボタン
</button>
);
};
そして、旧世界から連れてきたReactコンポーネントを振り返り、これからは「クライアントコンポーネント」を名乗るよう言われました。
こうして、新世界にはReactコンポーネントは2種類になりました。
サーバーコンポーネント(SC)
- サーバーでレンダリングされる
- 初期表示から一切変化しない
- サーバーにできることは何でも書ける
クライアントコンポーネント(CC)2
- まずサーバー、次にブラウザでレンダリングされる
- ハイドレーションされ=
<script />
がついていて3、状態を持ち操作に反応できる - 従来のReact記法に従い、ブラウザでもできることだけが書ける
Reactはサーバーたちに言われました。
「2種類のコンポーネントを自由に組み合わせてサイトを作れ。全ては最初SCであるから、ハイドレーションが必要なコンポーネントにはuse client
という印を書き加えてCCに変えよ」
この世界は、
- サーバーが最初のHTMLを作る
- ユーザーの操作に反応するところだけ
<script/>
が処理する
という原始からの自然な分業に回帰するものでした。ただ原始と違うのは、Reactの力に満ちていることです。
Next.js
Next.jsは歓喜し、サバコン世界を楽園と考え、すぐに移民しました。
データ取得はサーバーコンポーネントに任せ、独自の方法は捨て去りました。
getInitialProps
は言ってしまえば苦肉の策でした。
Reactの外にあるので、最上位のコンポーネントにしかデータを渡せません。
これでは、
- データが必要なコンポーネントは、特定のルートでしか動かない
- ルートとUIの間の全てがデータのバケツリレーに参加する
ことになり、コンポーネントたちの間に不自由な依存関係が生まれてしまいます。
コンポーネントがサーバーサイドでデータを取得できるなら、UIとそれに必要なデータ取得を両方行って貰えばよいのです。
こうして、コンポーネント(Component)は「組み合わせる(Compose)の自由」を取り戻すことができました。
Next.jsが新しく独自開発したのはキャッシュです。
データのキャッシュは、これまでgetInitialPropsなどの独自関数に内包されていましたが、新世界ではNodeのfetch
を魔改造版に差し替えることとしました。
このfetch関数は、
- 1レンダリングの間に同じリクエストがあった時(Request Memoization)、
- 指定された時間内に別のレンダリングで同じリクエストがあった時(Data Cache)
の両方でキャッシュを使える、高機能なものです。
Remix
Remixは急いで移住することはしませんでした。
Remixの独自のデータ取得関数loader
には、新世界でコンポーネントにfetchを任せるよりも高速に動作できる秘密があったからです。それは並列処理ができることです。
公式サイトTOPの説明が素晴らしいので、ここでは補足することしかできません。
https://remix.run/
この例では、以下のようなコンポーネントツリーがあり、全てがfetchデータを必要としています。
画面全体 <Root/>
↓
セールス <Sales/>
↓
請求書一覧 <Invoices/>
↓
請求書 <Invoice id={id}/>
Reactのレンダリングは、サバコン世界で各コンポーネントにfetchを任せるとこのような処理順序になります。
- Rootのfetch・レンダリング
- Salesのfetch・レンダリング
- Invoicesのfetch・レンダリング
- Invoiceのfetch・レンダリング
この様を「Request Waterfall」と呼びます。
「これでは下層コンポーネントの表示を待つユーザーが不憫であります」とRemixは言いました。
これを速めるには、fetchを並列処理するしかありません。
しかし、Reactは親コンポーネントを実行し終わるまで子コンポーネントが何になるかを知りませんし、ましてや最終的に実行されるfetchの一覧など持ってはいません。
そこでRemixはReactを飛び越え、開発者と密約を結びました。
そもそもNext.jsやRemixと開発者は、URLと最上位のコンポーネントを対応させる約束をすでにしています。File Based Routingというものです。
/dashboard
↓
<Dashboard />
これを拡張して、URLの各階層をコンポーネントツリーの階層と対応させるのはどうでしょう。
/最上位コンポーネント/次のコンポーネント/孫コンポーネント
/sales/invoices/10200
↓
<Sales>
<Invoices>
<Invoice id="10200"/>
</Invoices>
</Sales>
もちろん、ある程度の階層までで構いませんが、このルールに従う上位数層のコンポーネントには特別な力が宿るので、Route Moduleと名付けます。
Route Moduleはみなデータ取得関数loader
で自身に必要なデータを定義できることにします。
export const loader = async () => {
const data = await fetch('https://api/sales')
return json(data);
};
export default function SalesUI() {
const data = useLoaderData<typeof loader>();
return <div>{data}<Invoices/></div>
}
この密約とloader
の記述により、/sales/invoices/10200
へのアクセスがあった瞬間、Remixは必要なfetchの全てを把握できるようになりました。
そうなれば、処理順序はこうできます。
-
loader
処理(該当する全てのRoute Moduleのloader
≒fetchを並列処理) - Rootのレンダリング
- Salesのレンダリング
- Invoicesの(ry
- Invoiceの(ry
この仕組みを、Nested Routeと呼びます。URLとコンポーネントツリー(結果的にUIの構造)を一致させる縛りを受け入れる範囲でfetchを並列処理するという、Remixと開発者の契約です。この仕組みを持っているために、Remixはサバコン世界に移る理由がありませんでした。
2021年の記事で、Remixはこの並列fetchがNext.jsやサバコンより速くなる例を示しています。
https://remix.run/blog/react-server-components
歴史のまとめはここまでです。色々ありましたね。
これから
最後に、Next.jsとRemixの現在地について、データ取得という1点から筆者の感想をメモしておきます。
Next.jsの進むサバコン世界は王道に見えます。
演算をなるべくサーバーに寄せ不可能な部分だけをブラウザに残す方針は、キャッシュ共有・処理能力の両方の理由で圧倒的に効率的であり、サバコン世界はそれをコンポーネントの独立性を守ったまま行える世界です。
これが主流にならないなら、それはWEB開発者に好かれないからではなく、サバコンの処理が複雑すぎてライブラリが対応できないなどの、より低レイヤーの技術問題のためでしょう。
Remixの提案する並行fetchは強力ですが、Nested Routeの重い縛りを相殺するほどの速度差を生むサイトは少数派です。
そもそもマーケティングサイトなど、皆に同じ内容を見せるサイトでは、ページ全体をCDNにキャッシュさせますから、オリジンのfetch戦術と表示速度は関係がありません。
また、多少ユーザー個別のfetchあったとしてもサーバーでのfetchの多くは100ms以下ですから、数百ミリ秒の短縮効果を生むに過ぎません。本当に生きるのは、銀行口座・SNS・システム管理画面といった多数のユーザー個別データを表示・更新するサイトかと思います。
Remixにはデータ取得以外に良い点がたくさんあるので、そのうちサバコン世界に引っ越してくるかもしれませんね!
-
現時点ではReactの特定のバージョンだけがこれらしい ↩
-
両方で処理されるわけなので、ダブルコンポーネントとかハイドレーションコンポーネントとでも呼ぶべきですが、なぜかこの名前です ↩
-
実際は各コンポーネントの処理をまとめた大きなscript=クライアントバンドルがあるようですが、詳しくは知りません ↩