Reactはどうやってデータfetchしてきたか 〜SPAからNext.jsとRemixまで〜
これは何
我々の住む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コンポーネントを埋め込む力
を与えられ、「サーバーコンポーネント」と名づけられました。[2]
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)
- サーバーでレンダリングされる
- 初期表示から一切変化しない
- サーバーにできることは何でも書ける
[3]
クライアントコンポーネント(CC)- まずサーバー、次にブラウザでレンダリングされる
- ハイドレーションされ=
<script />
がついていて[4]、状態を持ち操作に反応できる - 従来のReact記法に従い、ブラウザでもできることだけが書ける
Reactはサーバーたちに言われました。
「2種類のコンポーネントを自由に組み合わせてサイトを作れ。全ては最初SCであるから、ハイドレーションが必要なコンポーネントにはuse client
という印を書き加えてCCに変えよ」
この世界は、
- サーバーが最初のHTMLを作る
- ユーザーの操作に反応するところだけ
<script/>
が処理する
という原始からの自然な分業に回帰するものでした。ただ原始と違うのは、Reactの力に満ちていることです。
Next.js
Next.jsは歓喜し、サバコン世界を楽園と考え、すぐに移民しました。
データ取得はサーバーコンポーネントに任せ、独自の方法は捨て去りました。
getInitialProps
は言ってしまえば苦肉の策でした。
Reactの外にあるので、最上位のコンポーネントにしかデータを渡せません。
これでは、
- データが必要なコンポーネントは、特定のルートでしか動かない
- ルートとUIの間の全てがデータのバケツリレーに参加する
ことになり、コンポーネントたちの間に不自由な依存関係が生まれてしまいます。
コンポーネントがサーバーサイドでデータを取得できるなら、UIとそれに必要なデータ取得を両方行って貰えばよいのです。
こうして、コンポーネント(Component)は「組み合わせる(Compose)の自由」を取り戻すことができました。
Remix
Remixは急いで移住することはしませんでした。
Remixの独自のデータ取得関数loader
には、新世界でコンポーネントにfetchを任せるよりも高速に動作できる秘密があったからです。それは並列処理ができることです。
公式サイトTOPの説明が素晴らしいので、ここでは補足することしかできません。
この例では、以下のようなコンポーネントツリーがあり、全てが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はサバコン世界に移る理由がありませんでした。
歴史のまとめはここまでです。色々ありましたね。
これから
最後に、Next.jsとRemixの現在地について、データ取得という1点から筆者の感想をメモしておきます。
Next.jsの進むサバコン世界は王道に見えます。
演算をなるべくサーバーに寄せ不可能な部分だけをブラウザに残す方針は、キャッシュ共有・処理能力の両方の理由で圧倒的に効率的であり、サバコン世界はそれをコンポーネントの独立性を守ったまま行える世界です。
これが主流にならないなら、それはWEB開発者に好かれないからではなく、サバコンの処理が複雑すぎてライブラリが対応できないなどの、より低レイヤーの技術問題のためでしょう。
Remixの提案する並行fetchは強力ですが、Nested Routeの重い縛りを相殺するほどの速度差を生むサイトは少数派です。
そもそもマーケティングサイトなど、皆に同じ内容を見せるサイトでは、ページ全体をCDNにキャッシュさせますから、オリジンのfetch戦術と表示速度は関係がありません。
また、多少ユーザー個別のfetchあったとしてもサーバーでのfetchの多くは100ms以下ですから、数百ミリ秒の短縮効果を生むに過ぎません。本当に生きるのは、銀行口座・SNS・システム管理画面といった多数のユーザー個別データを表示・更新するサイトかと思います。
Remixにはデータ取得以外に良い点がたくさんあるので、そのうちサバコン世界に引っ越してくるかもしれませんね!
-
現時点ではReactの特定のバージョンだけがこれらしい ↩︎
-
この時点で実際はHTML文字列ではなく、DOMや子孫コンポーネントを示すJSON=サーバーコンポーネントペイロードを返すようになったが、意識することはない ↩︎
-
両方で処理されるわけなので、ダブルコンポーネントとかハイドレーションコンポーネントとでも呼ぶべきですが、なぜかこの名前です ↩︎
-
実際は各コンポーネントの処理をまとめた大きなscript=クライアントバンドルがあるようですが、詳しくは知りません ↩︎
Discussion