この記事は react-apollo-hooks から apollo(graphql) 非依存の部分を理解して抜き出した記事です。
追記
動いてる実装は https://github.com/mizchi-sandbox/ssr-data-fetch にあります。
さらに追記
https://github.com/mizchi/ssr-helpers ライブラリに切り出したので @mizchi/ssr-heplers
でインストールできます。
import { renderAsync, createResource } from "@mizchi/ssr-helpers";
// data fetcher
const resource = createResource(async () => {
await new Promise(r => setTimeout(r, 1000));
return { message: "hello" };
});
function App() {
const data = resource.read();
return <div>{data.message}</div>;
}
const html = await renderAsync({
tree: <App />
});
何がしたいか
next.js のgetInitialProps
のように、SSR時にデータを解決する仕組みを自分で実装します。
React for Data Fetch
React.lazy と React.Suspense による Component の非同期ロードは React v16.6 でサポートされましたが、将来的に 非同期データロードも Suspense でサポートされる予定です。
現在の実装中のAPI案はこんな感じになります。
import { createResource } from 'react-cache';
const resource = createResource(...);
function Greeting() {
const data = resource.read();
return <div>{data.message}</div>
}
<Suspense fallback="loading...">
<Greeting />
</Suspense>
内部的には、 キャッシュを持たない時の resource.read()
は Promise を throw し、その間 Suspense fallback 側のものが表示されます。
ただし、前提として、Facebook の SSR サポートの優先度がものすごく低いです。現在の React は SSR で ErrorBoundary をサポートしておらず、 ErrorBoundary で実装されている Suspense が ReactDOMServer で実行できません。
非同期なデータロードは mid 2019, その SSR は年内に動いたら、みたいなロードマップです。
というわけでこの react-cache を真似た実装を行います。
(これは react-apollo-hooks からそのまま実装しました)
// Extract from https://github.com/trojanowski/react-apollo-hooks
import React from "react";
const SSRContext = React.createContext<null | SSRManager>(null);
interface SSRManager {
hasPromises(): boolean;
register(promise: PromiseLike<any>): void;
consumeAndAwaitPromises(): Promise<any>;
}
function createSSRManager(): SSRManager {
const promiseSet = new Set<PromiseLike<any>>();
return {
hasPromises: () => promiseSet.size > 0,
register: promise => {
promiseSet.add(promise);
},
consumeAndAwaitPromises: () => {
const promises = Array.from(promiseSet);
promiseSet.clear();
return Promise.all(promises);
}
};
}
interface GetMarkupFromTreeOptions {
tree: React.ReactNode;
onBeforeRender?: () => any;
renderFunction: (tree: React.ReactElement<object>) => string;
}
export function renderAsync({
tree,
onBeforeRender,
renderFunction
}: GetMarkupFromTreeOptions): Promise<string> {
const ssrManager = createSSRManager();
function process(): string | Promise<string> {
try {
if (onBeforeRender) {
onBeforeRender();
}
const html = renderFunction(
<SSRContext.Provider value={ssrManager}>{tree}</SSRContext.Provider>
);
if (!ssrManager.hasPromises()) {
return html;
}
} catch (e) {
if (!(e instanceof Promise)) {
throw e;
}
ssrManager.register(e);
}
return ssrManager.consumeAndAwaitPromises().then(process);
}
return Promise.resolve().then(process);
}
(これはあとでライブラリに切り出すかも)
仕組みとしては 内部的に throw new Promise(...)
されており、この throw された Promise が解決されるまで延々と render し続けます。
パフォーマンスを事前に意識しないと複数回レンダリングのパフォーマンスはかなり悪化する可能性があります。とくに末端に近い Footer のような領域で throw するとほとんどのCPU処理は無駄になります。
これは許容し難いパフォーマンス問題を引き落とすかもしれません。
createResource の実装
react-cache を参考に実装します。
export function createResource<T>(loader: (key: string) => T) {
const cache = new Map();
const load = (key: string) =>
new Promise(async (resolve, _reject) => {
const data = await loader(key);
cache.set(key, data);
resolve(data);
});
return {
async preload(key: string) {
if (cache.has(key)) {
return cache.get(key);
} else {
return load(key);
}
},
read(key: string) {
if (cache.has(key)) {
return cache.get(key);
} else {
throw load(key);
}
}
};
}
read 時は Suspense 規約に従って、Promise を throw します。
これはほぼ現在の提案通りの実装ですが、本家的にはキャッシュパージの仕様とかどうしようか?みたいな議論があるので、おそらく変わると思われます。
サーバーサイドでのSSR
(後の最適化のために react-router-config を使って実装しています)
import React, { Suspense } from "react";
import ReactDOMServer from "react-dom/server";
import { renderRoutes, matchRoutes, MatchedRoute } from "react-router-config";
import { StaticRouter } from "react-router-dom";
import { renderAsync, createResource } from "./renderAsync";
// data fetcher
const resource = createResource(async (_key: string) => {
await new Promise(r => setTimeout(r, 1000));
return { message: "hello" };
});
function Home() {
return <div>Home</div>;
}
function Foo() {
const data = resource.read("/foo");
return (
<div>
Foo
<pre>
<code>{JSON.stringify(data)}</code>
</pre>
</div>
);
}
const routes = [
{
component: Home,
exact: true,
path: "/"
},
{
component: Foo,
exact: true,
path: "/foo"
}
];
function App() {
return <>{renderRoutes(routes)}</>;
}
async function renderHtml(location: "/foo") {
const renderedHtml = await renderAsync({
renderFunction: ReactDOMServer.renderToString,
tree: (
<StaticRouter location={location}>
<App />
</StaticRouter>
)
});
return renderedHtml;
}
この renderHtml 関数を実装すると、HTML の文字列を生成します。 具体的な SSR 引き継ぎは略。
クライアントでの hydrate
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
const root = document.querySelector(".root") as HTMLDivElement;
async function main() {
ReactDOM.hydrate(
<BrowserRouter>
<Suspense fallback="loading">
<App />
</Suspense>
</BrowserRouter>,
root
);
}
main();
BrowserRouter と Suspense で囲って実行します。これで初回SSR以外のCSRに対応します。
先読みでのパフォーマンスを解決する
renderAsync で実装していた onBeforeRender と、 react-router-config を組み合わせて、その画面のルート要素だけ shallow な render を試みてから実際の render に処理を回すことにします。
import { renderRoutes, matchRoutes, MatchedRoute } from "react-router-config";
import ShallowRenderer from "react-test-renderer/shallow";
const shallowRenderer = ShallowRenderer.createRenderer();
// ...
function preloadRouteAction(matches: MatchedRoute<any>[]): Promise<any> | null {
let promises: Promise<any>[] = [];
matches.forEach(m => {
const C = m.route.component as React.ComponentType;
try {
shallowRenderer.render(<C {...m as any} />);
} catch (err) {
if (err instanceof Promise) {
promises.push(err);
}
}
});
if (promises.length > 0) {
return Promise.all(promises);
}
return null;
}
async function renderHtml(location: "/foo") {
let once = false;
const renderedHtml = await renderAsync({
renderFunction: ReactDOMServer.renderToString,
onBeforeRender() {
// load once
if (once) {
return;
}
once = true;
const matches = matchRoutes(routes, "/foo");
const promise = preloadRouteAction(matches);
if (promise) {
throw promise;
}
},
tree: (
<StaticRouter location={location}>
<App />
</StaticRouter>
)
});
return renderedHtml;
}
preloadRouteAction
を実行することで、 ReactRouter の Route 要素は少なくとも render 前に shallowRenderer を行って、非同期処理をかき集めます。
Footer や Header で専用の処理がある場合、この無限に再帰して、巻き戻る処理が多少緩和されるでしょう。できれば0回にしたいところです。
つまりは自分でアプリケーション側に制約を化して自明な箇所をロードしてしまう、という感じです。
おわり
将来的にいらなくなる予定ですが、来年まで待たないといけないのと、今のセマンティクスのままでも少なくとも2回 render しないといけない、もしくは ErrorBoundary まで遡ってリトライする、といった感じになるのは避けられないです。
なので、自力で変更に追従できる人は、自力で実装する価値は大きいのではないでしょうか。