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