25
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Suspense for Data Fetch の SSR時のデータローディングを先取りして実装する

Last updated at Posted at 2019-04-03

この記事は 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 からそのまま実装しました)

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 まで遡ってリトライする、といった感じになるのは避けられないです。

なので、自力で変更に追従できる人は、自力で実装する価値は大きいのではないでしょうか。

25
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?