- 投稿日:2019-04-03T11:24:19+09:00
Suspense for Data Fetch の SSR時のデータローディングを先取りして実装する
この記事は 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 は年内に動いたら、みたいなロードマップです。
https://reactjs.org/blog/2018/11/27/react-16-roadmap.html
というわけでこの react-cache を真似た実装を行います。
(これは react-apollo-hooks からそのまま実装しました)
renderAsync.tsx// 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 まで遡ってリトライする、といった感じになるのは避けられないです。
なので、自力で変更に追従できる人は、自力で実装する価値は大きいのではないでしょうか。
- 投稿日:2019-04-03T08:36:25+09:00
メモアプリ作った。
pwaでwebのメモアプリ作った
https://github.com/nishisuke/memote
機能
- cloud同期
- responsive
- オートセーブ
- オフラインで動く
- 速い
技術
- service worker
- firebase hosting
- firestore
- react hook
- immutablejs
所感
初めてreact hookを使った。
記述がシンプルになって最高だった。
今後はhook一択。
ただ慣れないと最初は厳しいので今回さわれてよかった。
firestoreも今回初めましてだった。
今後api server いらないと本気で思った。
firestoreは設計自体が難しい。
あえて正規化崩して、firestoreを活かせる設計が求められる。
今回は単純なメモアプリだったので簡単だった。
多分複雑なものはまだ設計できないので今後も勉強が必要。
service worker + firestoreでオフラインも完璧に動作するので
簡単なアプリはネイティブの意味なくなってると感じる。
service workerはつまるとこが多い。
特にiphone safariはgoogle 公式の説明のしようと違ってる動作が多々ある。
あとはライフサイクルをきちんと理解しないと、一生クライアントが更新されないことになりうる。
- 投稿日:2019-04-03T00:38:33+09:00
ReactとReduxを入門したときに見たリンク集
React
公式チュートリアル(英語): https://reactjs.org/tutorial/tutorial.html
日本語: https://mae.chab.in/archives/2943個人的にはローカルに落として実行するのがおすすめ。https://reactjs.org/tutorial/tutorial.html#setup-option-2-local-development-environment
Redux
オリジナル: https://redux.js.org/basics/basic-tutorial
日本語: https://0-to-1.github.io/redux/Reactと同じ環境で実行環境できる(https://reactjs.org/tutorial/tutorial.html#setup-option-2-local-development-environment)
BasicとAdvanced両方やる
非同期のあたりが英語が込み入っててわかりずらい。これ見るとわかった。: https://qiita.com/TsutomuNakamura/items/2ded5112ca5ded70e573#redux-promise-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B