React@next(18.3系統)で実装されたuseを使ってNext.jsでSSRする
Reactのuse
Reactの18.3系統で実装されたuseを使うと、コンポーネント内でPromiseの解決が出来ます。非同期処理が直列で書けるので、コードの内容がかなりすっきりします。
現時点でReact18.3系統を使うには、以下のようにパッケージをインストールする必要があります。
yarn add react@next react-dom@next
このuseを使う場合、
SSRを切ってcacheを使う
ServerComponentsを使う
などの情報が今後増えてくると思われますが、この記事では一切やりません。SSRも切らず、ServerComponentsも使わず、とにかく便利に使えそうな方法で実装します。
1.動作確認 & サンプル
https://next-use-ssr.vercel.app/
https://github.com/SoraKumo001/next-use-ssr
2.作ったものの実行例
3.動作に関して
まず大前提として、今回のプログラムはServerComponentsを使用していません。Next.jsでServerComponentsを使用せずにSSRでSuspenseを行うと、以下のような動作になります。
3.1.Suspenseと非同期をそのまま使った場合
throw promise or use(promise)が発生した時点でSuspense位置までのHTMLを出力
promiseが解決した時点でHTMLを追加出力
クライアント側で追加出力されたHTMLをJavaScriptが所定の位置へ再配置
クライアント側でコンポーネントの動作開始
クライアントはサーバでfetchしたデータを持っていないので、再fetchして初期HTMLを破壊し再レンダリングを始める
ものの見事にサーバ側でfetchした意味がありません。サーバとクライアントの連携が取れていません。
SSRを切ってクライアントのみで動くようにしたり、ServerComponentsでクライアントの再レンダリングを回避したりというのはこれが理由です。
3.2.サーバ側のデータをクライアントに受け渡す構造を作った場合
以下の方法で、クライアント再fetch問題を解決します。
throw promise or use(promise)が発生した時点でSuspense位置までのHTMLを出力
promiseが解決した時点でHTMLを追加出力し、さらにデータ専用ノードを作成してクライアントで受け取れるようにする
クライアント側で追加出力されたHTMLをJavaScriptが所定の位置へ再配置
クライアント側でコンポーネントの動作開始し、データ専用ノードからデータを取得
クライアントはサーバでfetchしたデータを持っているので、再fetchせずに初期HTMLを破壊しない形で再レンダリングされる
HTML内にクライアントで取得可能なデータを混ぜ込むことによって、サーバ側のデータは無駄にされず、クライアントで余計なfetchを防ぐことが出来ました。ただしこの仕組み、Suspenseを使った時点で問題が発生します。それはSSRしたHTMLデータが最終的な完成形に変換される際に、再配置のためのJavaScriptが必要となるからです。検索エンジンにキャッシュしてもらう場合などは、JavaScriptも理解してもらえるので問題ありません。しかしSNS系にリンクを貼り付けてOGPを解釈させるような場合はJavaScriptの再配置が行われないため、それが無かったことにされる可能性が高いのです。
3.3.Suspenseを使いつつ、完成形のHTMLを吐き出す構造を作る
Suspenseは非同期を扱う際に、読み込み中などの途中経過を表示できます。逆に言うと、途中経過を吐き出すための仕組みなです。これを使う限り完成前のHTMLデータがいったん出力されてしまいます。しかしNext.jsでこれを回避する方法があります。Suspenseしないthrow promiseを飛ばすことです。
throw promise or use(promise)が発生した時点でSuspenseしつつ、それとは別にSuspenseしないthrow promiseを作る
throw promiseがSuspenseされていない場合、Promiseが解決されるまでHTMLの出力が待機状態となる
Promiseが解決した時点で完成版のHTMLにデータ専用ノードを作成してクライアントで受け取れるようにする
クライアント側でHTMLを受け取るが、HTMは完成しているので再配置不要
クライアント側でコンポーネントの動作開始し、データ専用ノードからデータを取得
クライアントはサーバでfetchしたデータを持っているので、再fetchせずに初期HTMLを破壊しない形で再レンダリングされる
以上の動作によって、非同期データが完成版のHTMLに入った状態でSSRされます。再配置のためのJavaScriptは不要なため、OGPなどにも問題は発生しません。
余談ですが、クライアント側でSuspenseしないthrow promiseを使うと、ステートを吹き飛ばした状態で最上位から再レンダリングが発生し制御不能になります。
4.サンプルソース
useを使って、気象庁のサイトからデータを取り出すNext.jsのサンプルです。
useによって非同期処理の状態変化の取り回しがSuspense1つで済むので、かなりコードがすっきりします。
そして今回実装したusePromiseStateにPromiseを格納すれば、SSRに必要な動作を自動でやってくれます。
src/pages/index.tsx
1import { Suspense, use } from 'react';2import { usePromiseState } from '../libs/promiseState';34export interface WeatherType {5 publishingOffice: string;6 reportDatetime: Date;7 targetArea: string;8 headlineText: string;9 text: string;10}1112const fetchWeather = (id: number) =>13 fetch(`https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`).then((r) =>14 r.json()15 );1617const Weather = ({ weather: p }: { weather: Promise<WeatherType> }) => {18 //Reactの新機能useでデータを取り出す19 const weather = use(p);20 return (21 <div>22 <h1>{weather.targetArea}</h1>23 <div>24 {new Date(weather.reportDatetime).toLocaleString('ja-JP', {25 year: 'numeric',26 month: 'narrow',27 day: 'numeric',28 hour: 'numeric',29 minute: 'numeric',30 })}31 </div>32 <div>{weather.headlineText}</div>33 <pre>{weather.text}</pre>34 </div>35 );36};3738const Page = () => {39 //初期値はcallbackで渡す40 const [weather, setWeather] = usePromiseState(() => fetchWeather(130000));41 return (42 <div>43 <div>44 <a href="https://github.com/SoraKumo001/next-use-ssr">Source Code</a>45 </div>46 <hr />47 <div>48 {/* 後からstateを変更する場合は、そのままpromiseを格納してOK */}49 <button onClick={() => setWeather(fetchWeather(130000))}>東京</button>50 <button onClick={() => setWeather(fetchWeather(120000))}>千葉</button>51 <button onClick={() => setWeather(fetchWeather(140000))}>神奈川</button>52 </div>53 <Suspense fallback={'Loading'}>54 <Weather weather={weather} />55 </Suspense>56 </div>57 );58};59export default Page;
src/pages/_app.tsx
サーバ側でfetchしたデータをクライアントに受け渡す処理がPromiseProviderに入っています。
1import { AppProps } from 'next/app';2import { PromiseProvider } from '../libs/promiseState';34const App = ({ Component }: AppProps) => {5 return (6 <PromiseProvider>7 <Component />8 </PromiseProvider>9 );10};11export default App;12
src/libs/promiseState.tsx
usePromiseStateの実装です。
サーバで生成されたPromiseを解決して、クライアントに飛ばしています。
SSR時にSuspenseで中断された状態のHTMLが吐き出されないように細工も入れています。
そのため、出力される初期HTMLは非同期データも含まれた完成形のHTMLになり、JavaScriptによる再配置を必要としません。
1import { ReactNode, useContext, useId, useRef, useState, createContext } from 'react';23const DATA_NAME = '__NEXT_DATA_PROMISE__';45type ContextType = {6 values: { [key: string]: unknown };7 promises: Promise<unknown>[];8 finished: boolean;9};10const promiseContext = createContext<ContextType>(undefined as never);1112export const usePromiseState = <T,>(p: Promise<T> | (() => Promise<T>)) => {13 const context = useContext(promiseContext);14 const id = useId();15 const [state, setState] = useState<Promise<T>>(() => {16 if (typeof window === 'undefined') {17 const promise = typeof p === 'function' ? p() : p;18 context.promises.push(promise);19 promise.then((v) => {20 context.values[id] = v;21 });22 return promise;23 }24 return context.values[id]25 ? Promise.resolve(context.values[id] as T)26 : typeof p === 'function'27 ? p()28 : p;29 });30 return [state, setState] as const;31};3233const DataRender = () => {34 const context = useContext(promiseContext);35 if (typeof window === 'undefined' && !context.finished)36 throw Promise.all(context.promises).then((v) => {37 context.finished = true;38 return v;39 });40 return (41 <script42 id={DATA_NAME}43 type="application/json"44 dangerouslySetInnerHTML={{ __html: JSON.stringify(context.values) }}45 />46 );47};4849const useContextValue = () => {50 const refContext = useRef<ContextType>({ values: {}, promises: [], finished: false });51 if (typeof window !== 'undefined' && !refContext.current.finished) {52 const node = document.getElementById(DATA_NAME);53 if (node) refContext.current.values = JSON.parse(node.innerHTML);54 refContext.current.finished = true;55 }56 return refContext.current;57};5859export const PromiseProvider = ({ children }: { children: ReactNode }) => {60 const value = useContextValue();61 return (62 <promiseContext.Provider value={value}>63 {children}64 <DataRender />65 </promiseContext.Provider>66 );67};
生成されたHTMLデータ
JavaScript無しの初期状態でデータが揃っています。
1<!DOCTYPE html>2<html>3 <head>4 <meta charSet="utf-8"/>5 <meta name="viewport" content="width=device-width"/>6 <meta name="next-head-count" content="2"/>7 <style data-next-hide-fouc="true">8 body {9 display: none10 }11 </style>12 <noscript data-next-hide-fouc="true">13 <style>14 body {15 display: block16 }17 </style>18 </noscript>19 <noscript data-n-css=""></noscript>20 <script defer="" nomodule="" src="/_next/static/chunks/polyfills.js?ts=1667089915626"></script>21 <script src="/_next/static/chunks/webpack.js?ts=1667089915626" defer=""></script>22 <script src="/_next/static/chunks/main.js?ts=1667089915626" defer=""></script>23 <script src="/_next/static/chunks/pages/_app.js?ts=1667089915626" defer=""></script>24 <script src="/_next/static/chunks/pages/index.js?ts=1667089915626" defer=""></script>25 <script src="/_next/static/development/_buildManifest.js?ts=1667089915626" defer=""></script>26 <script src="/_next/static/development/_ssgManifest.js?ts=1667089915626" defer=""></script>27 <noscript id="__next_css__DO_NOT_USE__"></noscript>28 </head>29 <body>30 <div id="__next">31 <div>32 <div>33 <a href="https://github.com/SoraKumo001/next-use-ssr">Source Code</a>34 </div>35 <hr/>36 <div>37 <button>東京</button>38 <button>千葉</button>39 <button>神奈川</button>40 </div>41 <!--$-->42 <div>43 <h1>東京都</h1>44 <div>2022年10月30日 4:44</div>45 <div></div>46 <pre>東日本は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。4748 東京地方は、晴れや曇りとなっています。4950 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。5152 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。5354【関東甲信地方】55 関東甲信地方は、晴れや曇りとなっています。5657 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。5859 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。6061 関東地方と伊豆諸島の海上では、31日にかけてうねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。</pre>62 </div>63 <!--/$-->64 </div>65 <script id="__NEXT_DATA_PROMISE__" type="application/json">66 {":R2m:":{"publishingOffice":"気象庁","reportDatetime":"2022-10-30T04:44:00+09:00","targetArea":"東京都","headlineText":"","text":" 東日本は、大陸に中心を持つ高気圧に覆われていますが、気圧の谷や湿った空気の影響を受けています。\n\n 東京地方は、晴れや曇りとなっています。\n\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れ時々曇りとなるでしょう。\n\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れで朝晩は曇りとなるでしょう。\n\n【関東甲信地方】\n 関東甲信地方は、晴れや曇りとなっています。\n\n 30日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\n\n 31日は、高気圧に覆われますが、気圧の谷や湿った空気の影響を受ける見込みです。このため、晴れや曇りでしょう。\n\n 関東地方と伊豆諸島の海上では、31日にかけてうねりを伴い、30日は波が高く、31日は波がやや高い見込みです。船舶は高波に注意してください。"}}67 </script>68 </div>69 <script src="/_next/static/chunks/react-refresh.js?ts=1667089915626"></script>70 <script id="__NEXT_DATA__" type="application/json">71 {"props":{"pageProps":{}},"page":"/","query":{},"buildId":"development","nextExport":true,"autoExport":true,"isFallback":false,"scriptLoader":[]}72 </script>73 </body>74</html>
5.まとめ
Next.jsでSSRを行う場合、getInitialPropsやgetServerSidePropsなどを使うと、コンポーネントとは別の場所にデータ取得のコードを書く必要があります。それが面倒な場合はデータ取得用のプリレンダーを作っていったん内部でデータを完成させて再配布という機能を作れば良いのですが、構造が複雑になります。かといってServerComponentsを使うと、コンポーネントを柔軟に取り扱いたい時に制約が多く、面倒事が増えます。
そんな面倒ごとを考えず、コンポーネント内で非同期データを突っ込んでSSRするという仕組みを今回やってみました。結果、コンポーネント上に直接非同期処理を乗せて、データが出そろった状態でSSRさせることに成功しました。クライアントにHTMLが渡った後も、もちろん動的にコンポーネントを動かせます。難しいことも面倒なことも何もありません。
もしや、かなりヤバい方法を発見したのではないかと思っています。