React Router 4.4.0-beta.6 で prefetch 的なことをする

なぜこのような中途半端なバージョンかというと、他のプロダクトにめり込む形で挿入することを想定して書いていたからです。

汎用的なコードにしようとすると今年中に書き終わりそうになかった (まとめるのもしんどそうだった) ので、アドホックなコードとアイデアのメモのみにしています。

prefetch て何

コンポーネントを描画する前に、描画に必要なデータを満たしておく処理です。必要なデータが揃っていることが常に保証されるため、ローディングに関する分岐がコンポーネント内からなくなります。

またページを表示してからパタパタッと部分部分が再描画されることがないので、見た目が静かです。

前提

history の変更その他をどこからでも公聴できるようにしています。

今回は repatchStore<State>Context で知れるようにしています。

Switch コンポーネントを改造する

prefetch はどこかで render を停める必要があります。Vue Router では beforeHook でそれを行えますが、React Router にはそれに相当する機能がありません。

そこで描画するコンポーネントを切り替える Switch コンポーネントを改造して、しかるべきタイミングまで描画を停めます。

素の Switch コンポーネント

RouterContext の変更に併せて、子の Route コンポーネントからマッチするものを選んで描画します。なければ null です。

class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          invariant(context, "You should not use <Switch> outside a <Router>");

          const location = this.props.location || context.location;

          let element, match;

          React.Children.forEach(this.props.children, child => {
            if (match == null && React.isValidElement(child)) {
              element = child;

              const path = child.props.path || child.props.from;

              match = path
                ? matchPath(location.pathname, { ...child.props, path })
                : context.match;
            }
          });

          return match
            ? React.cloneElement(element, { location, computedMatch: match })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

Route コンポーネントを改造する

何を prefetch するかを収めるために、まず Route コンポーネントを拡張します。

export type PrefetchFunctionArguments = { store: Store<State>, location: LocationState, match: match<LocationState> }
export type PrefetchFunction = (args: PrefetchFunctionArguments) => Promise<any>
export type PrefetchProps = RouteProps & { prefetch?: PrefetchFunction }

export class PrefetchRoute extends Route<PrefetchProps> {}

これで、Route コンポーネントを差し替えます。

// PrefetchFunction を満たす感じで
async function prefetchUser({ store, match: { params: { user_id } } }: PrefetchFunctionArguments) {
  await fetchUser(user_id)
}
<PrefetchRoute exact path="/user/:user_id" prefetch={prefetchUser} component={UserDetail} />

改造済み Switch コンポーネント

prefetch を含む Route は用意できましたから、今度はその prefetch を実行しなければなりません。これは Switch で行います。

やっていることは Switch とほぼ同じですが、prefetch がある場合はそれが解決されるまで描画するコンポーネント (el) を差し替えません。

export function PrefetchSwitch ({ children }: { children: any }) {
  // repatch のやつです
  const store = useContext(storeContext);

  // 描画するやつ
  const [el, setEl] = useState<null | JSX.Element>(null);

  const [pathname, setPathname] = useState(store.getState().history.location.pathname);

  // RouterContext.Consumer は露出してないので直接 history を聴く
  // app 開始時に State に history をぶっこんでいる
  useLayoutEffect(() => {
    const { history } = store.getState();
    history.listen(({ pathname }) => setPathname(pathname));
  }, []);

  useLayoutEffect(() => {
    const { history: { location } } = store.getState();
    const { key: oldKey, pathname } = location;
    let element: any;
    let match: match<LocationState> | undefined;
    let requiredPrefetch: PrefetchFunction | undefined;

    Children.forEach(children, (child: PrefetchRoute) => {
      if (match) {
        return;
      }

      const {
        path,
        prefetch,
      } = child.props;

      const candidate = matchPath<LocationState>(pathname, { ...child.props, path });

      if (candidate) {
        match = candidate;
        element = child;
        requiredPrefetch = prefetch;
      }
    });

    if (!match || !element) {
      setEl(null);
      return;
    }

    // 描画が必要なくなるかもなので一応遅延
    const create = () => createRouteElement(location, match as match<LocationState>, element)

    // 実際には進む戻る用のキャッシュに対応するための分岐も入ってるけど割愛
    if (!requiredPrefetch) {
      setEl(create());
      return;
    }

    requiredPrefetch({ store, location, match })
      .then(() => {
        const { history: { location: { key: newKey } } } = store.getState();
        oldKey === newKey && setEl(create());
      })
      .catch(() => {
        const { history: { location: { key: newKey } } } = store.getState();
        oldKey === newKey && setEl(<ErrorPage />);
      });
  }, [pathname]);

  return el;
}

非同期処理中に異なるページ遷移が行われた場合、ロードが済む順番がテレコになると気まずい状況になります。そこで非同期処理終了時点で再度 location を取得してキーを比較し (oldKey === newKey) すでに遷移が生じていれば破棄しています。

その他の前提

prefetch からは何の値を受け取りませんし、Route に何の追加値の中継も行いません。全ての値は Store<State> 経由で通信されます。

バケツリレーが長期的に嬉しかった経験がないのでこうなっていますが、prefetch からの値を props に埋めるのも一つの手でしょう。

注意とか

prefetch は全ての値が揃うまで描画を停めますが、すでに描画されているコンポーネントが監視している Store<State> の値を書き換えると prefetch 中に描画内容が意図せず変わってしまいます。

描画用のデータ保持領域と取得しただけのデータ保持領域を隔離し、必要になった段に表示領域に移動するなどを習慣づける必要があるでしょう。

まとめ

以前は History を改造して prefetch が終わるまで emit しないということをやりましたが、今回はコンポーネントで解決してみました。

重量級のアプリだともっと色々考えることがありそうですが、今扱っている物に関してはこれで大丈夫な予感がしています。

まだわかりませんけど。