React + History API で Router を実装する覚書
React 製のアプリを作るときに react-router が正規表現の対応をやめて非常に使えなくなったので(一般的なアプリケーションでは問題ないと思うけど、今回作っているアプリでは大いに問題があった)、History API を使って自前で実装した。一般的にはそうする意味はあまりなく、react-router でない類似のパッケージを入れたら良いと思う。(個人的には https://github.com/molefrog/wouter がおすすめ、結局使ってないけど)
事前知識
History API
今回改めて History API について調べた。先に知っておきたかったことは以下。
-
history.pushState
/history.replaceState
はイベントを発火しない。popstate
イベントはあくまでブラウザによる戻る・進むの動作時に発火する。履歴の操作処理が副作用を持つのは微妙ってことだろう。 -
history.pushState
/history.replaceState
の第一引数state
はなくても良いし、第二引数は使われてない。 -
PopStateEvent
クラスが定義されている。PopStateEvent
とEvent
の違いはオプションとしてstate
(history.pushState
/history.replaceState
の第一引数)を保持するかどうか。
React
React でルートの変更時に ReactNode を再レンダリングする必要があるが、どうやるのが良いか。多分 Router Component がルートの変更を検出して、自身の key をルートによってユニークな値に変更するのが良さそう。
key が変更されるとコンポーネント全体が別物として再レンダリングされる。
実装の概要
細かい実装はまだこなれていないし適当なので、あくまでコンセプトだけ。今手で書いてるので動かないかもしれない。
import { createContext, useContext } from "react";
export const navigate = (to: string, options = { replace: false }) => {
const event = options.replace ? "replaceState" : "pushState";
history[event].call(history, null, "", to);
dispatchEvent(new Event(event));
};
export const useNavigate = () => ({ navigate });
export type Route = {
path: string;
search: URLSearchParams;
hash: string | undefined;
};
export const RouteContext = createContext<Route>({
path: "/",
search: new URLSearchParams(),
hash: undefined,
});
export const useRoute = () => useContext(RouteContext);
import { FC, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { Route, RouteContext } from './useRoute';
const events: Array<string> = [
"popstate",
"hashchange",
"pushState",
"replaceState",
];
export const Router: FC<PropsWithChildren> = ({ children }) => {
const [route, setRoute] = useState<Route>({
path: "/",
search: new URLSearchParams(),
hash: undefined,
});
const refresh = useCallback(() => {
const { pathname, search, hash } = window.location;
setRoute({
path: pathname,
search: new URLSearchParams(search),
hash: hash == "#" ? undefined : hash,
});
}, [setRoute]);
useEffect(() => refresh(), [refresh]);
useEffect(() => {
events.forEach(e => window.addEventListener(e, refresh))
return () => events.forEach(e => window.removeEventListener(e, refresh));
}, [refresh]);
const key = route.path + "?" + route.search.toString() + (route.hash || "#")
return (
<RouteContext.Provider key={key} value={route}>
{children}
</RouteContext.Provider>
)
};
あとは適当に Body 内部でルーティング処理を書けば良い。ルーティング処理そのものはお好みでコンポーネントを切り替えるだけなので適当にする。
useRouter().navigate
を使って画面遷移すると "pushState"
/ "replaceState"
イベントが発火する。それを Router が購読していて refresh
が起動し、window.location
を拾ってきて route が更新される。route の更新によって Router の key が変更され、子DOMが再レンダリングされる。
Link コンポーネントの参考はこんな感じ。Props は適当にやる。
import { FC, PropsWithChildren } from 'react';
import { useNavigate } from './useRoute';
export const Link: FC<PropsWithChildren<{ to: string }>> = ({ to, children }) => {
const { navigate } = useNavigate();
return (
<a
href={to}
onClick={click => {
click.preventDefault();
navigate(to);
}}
>
{children}
</a>
);
};
navigate
の先は Event によって処理されているので、すべての Link
が Router
以下に入っている必要はない。Router
コンポーネントで囲むのはあくまで useRoute
の内容によってDOM ツリーを差し替えたい領域だけ。
いわゆるルーティングの DSL としての Route
コンポーネントの実装はやってないけど、それを使いたいなら wouter など使ったら良いと思う。
Discussion