React Router 4.4.0-beta.6 で prefetch 的なことをする
なぜこのような中途半端なバージョンかというと、他のプロダクトにめり込む形で挿入することを想定して書いていたからです。
汎用的なコードにしようとすると今年中に書き終わりそうになかった (まとめるのもしんどそうだった) ので、アドホックなコードとアイデアのメモのみにしています。
prefetch て何
コンポーネントを描画する前に、描画に必要なデータを満たしておく処理です。必要なデータが揃っていることが常に保証されるため、ローディングに関する分岐がコンポーネント内からなくなります。
またページを表示してからパタパタッと部分部分が再描画されることがないので、見た目が静かです。
前提
history
の変更その他をどこからでも公聴できるようにしています。
今回は repatch
の Store<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
しないということをやりましたが、今回はコンポーネントで解決してみました。
重量級のアプリだともっと色々考えることがありそうですが、今扱っている物に関してはこれで大丈夫な予感がしています。
まだわかりませんけど。