🪵

react-router 作り方

2021/12/04に公開

history で自分だけの最強のルーターライブラリを作ろう!

react-router は history というライブラリを内部で使用しています。ブラウザの history API の抽象化を提供するのが目的のライブラリです。
react-router v5 の API に useHistory というカスタムフックがあります(v6 から名前が変更されています)が、それはまさに history ライブラリの実体を取得するための関数になっています。

ルーティングに関する以下の処理は history ライブラリ内部で行われています。

  • 現在の URL から Location オブジェクトを作成する
  • 履歴の操作を行う
  • 履歴の変更を検知してコールバックを実行する

react-router は React 用のインターフェイスを提供しているに過ぎません。

つまり、 history をベースに React コンポーネントやカスタムフックを作成すれば、比較的簡単に React 用ルーターライブラリを作成することができるのです。

この記事では history を使って簡易的な react-router を作成する手順を紹介します。

history ライブラリについて

https://github.com/remix-run/history

history ライブラリのソースコードはかなり少なく、すぐに全体を把握することができます。

History インターフェイス

https://github.com/remix-run/history/blob/8bef6f4d50548f46ab7c97e171b3d8634093e7a7/packages/history/index.ts#L183-L280

history ライブラリはインターフェイスと実装の分離がされています。まずは TypeScript の interface で History 型を定義し、history オブジェクトが プロパティやメソッドを持つことを義務付けます。

後述の createBrowserHistory(), createHashHistory(), createMemoryHistory() はこの History インターフェイスを実装したオブジェクトを生成して返します。どの create 関数で生成しても共通のインターフェイスを持つことが約束されているため、 create 関数を差し替えるだけで振る舞いを変更できます。理想的な設計になっていますね。

プロパティ

History.actionHistory.location が生えています。

History.action は最後に行われた履歴操作がなにかを enum Action で持っており、 Pop, Push, Replace のいずれかになります。 History オブジェクトが生成された直後の値は Action.Pop になるようです。

History.location は履歴スタックの現在位置(ブラウザの URL バーに表示されているもの)のパスから生成された Location オブジェクトが格納されています。

メソッド

履歴スタックを移動したり更新したりするためのメソッド(.push(), .go() 等)と、履歴スタックの変化を検知してコールバック関数を実行する listen(), block() があります。

前者はパスを指定して遷移したりブラウザの戻るボタンをクリックしたときの挙動を再現したりするのに使用します。
後者は、前者のメソッドやユーザーのブラウザ前後ボタンの操作等を検知したときに発火したい関数を登録しておくことができ、その関数は引数で Location オブジェクトを受け取ることができます。

createXXXHistory 関数

History インターフェイスを実装した history オブジェクトを生成する関数が 3 つ用意されています。環境によって使い分けますが、先述の通りすべてが同じインターフェイスを実装しているため、生成する関数を差し替えるだけで振る舞いを変更することが可能です。

createBrowserHistory()

ブラウザの URL から Location オブジェクトを生成して履歴スタックに保存するような history オブジェクトを生成します。通常の Single Page Application の開発を行う場合はこれを使用します。内部実装では当然ブラウザの history API にがっつり依存しています。

createHashHistory()

URL のハッシュ部分 (http://example.com/#hoge#hoge) のみから Location オブジェクトを生成して履歴スタックに保存するような history オブジェクトを生成します。ブラウザの URL バーに表示される実際の URL は以下のようになります。

https://example.com/#/foo/123/bar/456?hoge=fuga#hash

ドメインのすぐ後に # が置かれることで、ハッシュにパスとクエリパラメーターと論理的(?)なハッシュを詰め込んでいます。
つまりブラウザから見ると URL のパスの変更は起こっていないが、URL と Web アプリの対応付けができているという状態を実現できます。

これの使い所は、例えば GitHub Pages にデプロイするときなどが挙げられます。GitHub Pages の URL は、ユーザーサイトの場合 https://user-name.github.io で、プロジェクトサイトの場合は https://user-name.github.io/repository-name が割り当てられます。ユーザーサイト側でルーティングをする際にパス名がプロジェクトサイトのリポジトリ名とかぶってしまうと、URL の衝突が起こってどちらかが表示されないという事態になります。そんなときに createHashHistory() でルーティングを実装していれば、パスの変更が発生しないため URL の衝突を考慮する必要がなくなります。

createMemoryHistory()

ブラウザの URL や history API には依存せず、文字通りメモリ上だけで履歴スタックを管理する history オブジェクトを生成します。ブラウザ API に依存しない、 React Native やテスト環境で使用されることが想定されています。

react-router の簡易的な作り方

ここから本題に入って、 history ライブラリを使用して react-router を作る手順を説明します。なお、完全な API の再現ではなくむしろ足りない機能があったり TypeScript friendly ではなかったりしますが、そこは大目に見てください。また、 react-router のソースコードをそのまま載せるのではなく、(参考程度に読んではいますが)我流のものを紹介しています。

模倣する react-router のバージョンは v5 です(v6 はまだちゃんと使ったことがない)。

コード例で import 文は省略することが多いですが、適宜モジュール上部に記載してあるとして読んでください。 HistoryLocation の型定義は TypeScript 標準ライブラリに含まれるものと history ライブラリが export しているものがありますが、前者は一切使用していないので、これらの使用が見られる箇所は import {History, Location} from "history"; が前提です。

Context を用意する

React Context を 3 つ用意します。

const HistoryContext = createContext<History | undefined>(undefined);
const LocationContext = createContext<Location | undefined>(undefined);
const PathContext = createContext<string | undefined>(undefined);

それぞれ、 history オブジェクトを配信する用、 location オブジェクトを配信する用、 path 文字列を配信する用です。
この段階で、 useHistoryuseLocation の実装もすぐイメージできるかもしれません。
path 文字列とは、ここでは "/foo/:fooId/bar/:barId" のようなものを想定しています。 react-router で Route に props として渡すアレですね。なぜ Context に乗せるかはあとでわかります。

undefined 許容型にして初期値を undefined としているのは、 Provider で囲われなかったときにエラーにしたいからです(後述します)。

Router コンポーネント

Router コンポーネントを定義します。(今回は createBrowserHistory 用だけ作ります。)

export const Router: VFC<{ children: ReactNode }> = ({ children }) => {
  return (...);
};

Router コンポーネントの役割は以下のとおりです。

  • history オブジェクトの生成と保持
  • location ステートの保持
  • 3 つの Context の Provider を配置

history オブジェクトは一度生成されたら参照が変更されないミュータブルなインスタンスなので useRef で保持しておきます。

const historyRef = useRef<History>();
if (historyRef.current === undefined) {
  historyRef.current = createBrowserHistory();
}

初期値を渡さずに ref オブジェクトを宣言し、 Router コンポーネントの初回評価時のみ createBrowserHistory が実行されるように条件分岐します。

location オブジェクトを保持しておくステートを宣言します。 useState に渡す初期値は history オブジェクトの location プロパティから取り出します。

const [location, setLocation] = useState(historyRef.current.location);

そして history.listensetLocation を仕込んでおくことでロケーションが変化したら最新の location オブジェクトでステート更新できるようにしておきます。

useLayoutEffect(() => {
  if (historyRef.current) {
    return historyRef.current.listen(({ location }) => setLocation(location));
  }
}, []);

history.listen はリスナーの登録を解除する関数を return してくれるので、 useLayoutEffect の中からさらに return することでアンマウント時にリスナーが解除されるようにします(Router コンポーネントはおそらくアプリのトップに配置されるのでアンマウントされることはないと思いますが一応)。

そして Context.Provider に値を渡して JSX を return します。

return (
  <HistoryContext.Provider value={historyRef.current}>
    <LocationContext.Provider value={location}>
      <PathContext.Provider value="/">{children}</PathContext.Provider>
    </LocationContext.Provider>
  </HistoryContext.Provider>
);

location は都度更新されていくステートなので、 useContext(LocationContext) を実行している子コンポーネントは URL の変更によって再レンダリングがされることがわかりますね。

PathContext.Providervalue には固定で "/" を渡しています。 PathContext の値は後述する Route が設定してくれるので、 Router はフォールバックだけセットしておきます。

Router コンポーネント全文
export const Router: VFC<{ children: ReactNode }> = ({ children }) => {
  const historyRef = useRef<History>();
  if (historyRef.current === undefined) {
    historyRef.current = createBrowserHistory();
  }

  const [location, setLocation] = useState(historyRef.current.location);

  useLayoutEffect(() => {
    if (historyRef.current) {
      const unlisten = historyRef.current.listen(({ location }) => setLocation(location));
      return unlisten;
    }
  }, []);

  return (
    <HistoryContext.Provider value={historyRef.current}>
      <LocationContext.Provider value={location}>
        <PathContext.Provider value="/">{children}</PathContext.Provider>
      </LocationContext.Provider>
    </HistoryContext.Provider>
  );
};

useHistory フック

history オブジェクトを使用したいコンポーネントで実行するカスタムフックを作成します。と言っても難しいことはしなくて、 useContext をラップするだけです。

export function useHistory() {
  const history = useContext(HistoryContext);
  if (history === undefined) {
    throw new Error("Component must be wrapped with Router.");
  }

  return history;
}

HistoryContext には Router の配下であれば必ずインスタンスが格納されています。逆に言えば Router で囲っていなければ undefined しか取り出せません。 Router で囲っていない位置で useHistory が実行された場合は例外とみなしてエラーを throw してしまいます。TypeScript にとっては型の絞り込みの意味もあります。 useHistory の戻り値の型が undefined を含まなくなるため、 undefined チェックが不要になり扱いやすくなります。

useLocation フック

location オブジェクトを使用したいコンポーネントで実行するカスタムフックを作成します。こちらも useContext をラップするだけですが。

export function useLocation() {
  const location = useContext(LocationContext);
  if (location === undefined) {
    throw new Error("Component must be wrapped with Router.");
  }

  return location;
}

useHistory 同様に undefined の場合は Router でラップされていないということなので例外とみなしてエラーを throw します。

useRouteMatch フック

path 文字列を渡すと現在の location と比較してマッチしているかどうか判定してくれるカスタムフックを作成します。判定しつつパスパラメーターの抽出もできるようにします。といってもこれを自力で実装するのは普通に難しいので、 本家 react-router v5 も依存していた(v6 から依存から外れた) path-to-regexp を使用します。

npm install path-to-regexp

フックとしてではなくマッチ判定ロジックだけを別の場所で使いたいので、カスタムフックを作成する前に判定ロジックを純粋関数として定義しておきます。

import { match } from "path-to-regexp";

function matchPath<T extends Record<string, string>>(
  path: string,
  currentPath: string,
  exact: boolean
) {
  const matcher = match<T>(path, { end: exact });
  const result = matcher(currentPath);

  return result ? result : null;
}

次のような使い方を想定しています。

const matched = matchPath<{ fooId: string }>("/foo/:fooId", "/foo/123", true);
console.log(matched !== null ? matched.params.fooId : "not matched");

第 1 引数のパステンプレートに第 2 引数のパスがマッチしたら、パスパラメータを内包したオブジェクトを返します。マッチしなかったら null を返します。boolean 型の第 3 引数 exact は、パスの判定時に前方一致か完全一致かを指定します。 true の場合は完全一致です("/foo/:fooId" に対して "/foo/:fooId/bar" がマッチしない)。
パスパラメータの型は template literal types で推論可能ですが、実装をサボっています。まじめに作る場合は推論できるようにしたいところですね。

この matchPath を使って useRouteMatch を実装していきます。

export type Matched<P extends Record<string, string>> = {
  params: P;
};
export function useRouteMatch<P extends Record<string, string> = {}>(option: {
  path: string;
  exact: boolean;
}): Matched<P> | null {
  const location = useLocation();

  const matched = useMemo(
    () => matchPath<P>(option.path, location.pathname, option.exact),
    [option.path, option.exact, location.pathname]
  );

  return matched;
}

確認したいパステンプレートと完全一致かどうかのフラグは引数で受け取り、現在 URL のパス情報は useLocation から取得します。

matchPath はオブジェクトを返却するため、 useMemo で囲っておくのがマナーです。 useRouteMatch を使う側が戻り値をそのまま useEffect 等の依存配列に突っ込む可能性もあるため、不必要な参照の変化は避けます。

useParams フック

現在のパスからパスパラメータを抽出するカスタムフックを定義します。といっても useRouteMatch がパスパラメータの抽出までやってくれるため、その処理はそちらに委譲します。

パステンプレートは PathContext から取り出します。その値は Route がセットしてくれるはずなので、 useParams を使用するコンポーネントの祖先に Route がいないと効果を発揮しないということですね。

export function useParams<T extends Record<string, string>>(): T {
  const path = useContext(PathContext);
  if (path === undefined) {
    throw new Error("Component must be wrapped with Router.");
  }

  const matched = useRouteMatch<T>({ path, exact: false });
  if (matched === null) throw new Error();
  return matched.params;
}

同様に pathundefined チェックを行います。 undefined になるのは Router の外で実行された場合に限られます。

pathuseRouteMatch に渡してマッチしているかを判定します。と言っても pathRoute がセットし、かつ Route はマッチしていない場合は子コンポーネントをレンダリングしません。 Route に囲われていなかったとしても Router"/" をセットしていて、かつ前方一致の判定(exact: false)のため 100% マッチします。つまりここで useRouteMatch が null を返すことはありえません。型の絞り込みのためだけにエラーを throw して、 .params を return します。

Route コンポーネント

Route は指定したパステンプレートにマッチしたときだけ子コンポーネントをレンダリングするコンポーネントです。
まずは Props を考えましょう。必要なものはパステンプレート、完全一致かどうか、子コンポーネントですね。

export type RouteProps = {
  path: string;
  exact?: boolean;
  children: ReactNode;
};

exact は optional にしています。渡されなかった場合は false にしておきます。pathchildren は必須です。 Route の役割を考えたらこれは必然的ですね。

Route 内部では現在のロケーションが path にマッチしているかを判定しますが、これは useRouteMatch で判定可能なのでこれを使います。また props に渡された pathPathContext.Provider に渡すことによって子孫コンポーネントが useParams を実行したときにパスパラメータを取得できるようにしておきます。

export const Route: VFC<RouteProps> = ({ path, exact = false, children }) => {
  const matched = useRouteMatch({ path, exact });

  if (matched === null) return null;

  return <PathContext.Provider value={path}>{children}</PathContext.Provider>;
};

続いては a 要素の代替として使用する Link コンポーネントを作成します。 LinKa 要素をレンダリングしますが、クリックイベントを奪って代わりに history オブジェクトの .push() で遷移できるようにします。

まずは Props を検討します。必要なものは href に相当するものですが、 a に渡す href 文字列は history.createHref で生成したいです。なので、 Link コンポーネントが受け取るものは history.createHref の引数に相当する To 型の値になります。また history.push による遷移時には location state を渡すことができます。それも props で受け取っておきます。そして href を除く a 要素のすべての属性を受け取れるようにしておくことで、 href 以外は a 要素と同じように扱えるようにします。

export type LinkProps = {
  to: To;
  state?: unknown;
} & Omit<ComponentProps<"a">, "href">;

ComponentProps<"a">a 要素が受け取る props の型定義を指しています。 Omit を使って a 要素の props のうち href 以外のものと、 Link 専用の型定義をマージしています。

Link コンポーネントは forwardRef で定義する必要があります。使う側が a 要素の実 DOM を握りたいことがあったり、 Chakra などの UI コンポーネントと組み合わせる場合は ref にアクセスできることが前提になっている場合があります。それらのユースケースに対応できるようにしておくために forwardRef を使います。

export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
  ({ to, state, children, target, onClick, ...props }, forwardedRef) => {

    return (...);
  }
);

まずは useHistory を使って history オブジェクトを取得します。

const history = useHistory();

a 要素に渡すクリックイベントを実装します。 preventDefault で標準の動作を停止して、代わりに history で URL を書き換えます。

const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
  if (onClick) {
    onClick(event);
    if (event.defaultPrevented) return;
  }

  if (event.button === 0 && (!target || target === "_self")) {
    event.preventDefault();
    history.push(to, state);
  }
};

LinkonClick が渡されている場合はこの中で実行してあげます。 onClick の中ですでに preventDefault() が実行された場合は、 Link を使う側が URL の遷移を停止したいものとみなしてそこで関数の実行を終了します。

続いて event.button === 0(左クリックのとき) かつ target が未指定または "_self" のときに標準動作を停止した上で history.push を実行します。 props で受け取っている tostate を渡します。

最後に a 要素を return します。 分割代入しておいた変数も漏れなく props に渡しておきます。

return (
  <a
    {...props}
    ref={forwardedRef}
    href={history.createHref(to)}
    target={target}
    onClick={handleClick}
  >
    {children}
  </a>
);

先述の通り hrefhistory.createHref によって生成します。 to は同じインターフェイスなのでそのまま引数に渡すことが可能です。

Link コンポーネント全文
export type LinkProps = {
  to: To;
  state?: unknown;
} & Omit<ComponentProps<"a">, "href">;

export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
  ({ to, state, children, target, onClick, ...props }, forwardedRef) => {
    const history = useHistory();

    const handleClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
      if (onClick) {
        onClick(event);
        if (event.defaultPrevented) return;
      }

      if (event.button === 0 && (!target || target === "_self")) {
        event.preventDefault();
        history.push(to, state);
      }
    };

    return (
      <a
        {...props}
        ref={forwardedRef}
        href={history.createHref(to)}
        target={target}
        onClick={handleClick}
      >
        {children}
      </a>
    );
  }
);

Switch コンポーネント

Switch コンポーネントは、子コンポーネントとして並列に配置された複数の Route コンポーネントから、現在のパスにマッチしている最初のものだけをレンダリングする機能を持ちます。

現在のパスにマッチしているかどうかで分岐を行うため、まずは useLocation を使いましょう。

export const Switch: VFC<{ children: ReactNode }> = ({ children }) => {
  const location = useLocation();

  return ...;
};

children に渡されてくる ReactElement を解析する API が React には公開されています。ここでは ChildrenisValidElement を使って children のうち Route かつマッチしているものだけを選択します。

let matchedRoute: ReactElement | null = null;

Children.forEach(children, (child) => {
  if (matchedRoute !== null) return;

  if (!isValidElement(child) || child.type !== Route) {
    console.error("Switch can have only Route components.");
    return;
  }

  const matched = matchPath(child.props.path, location.pathname, child.props.exact);

  if (matched !== null) {
    matchedRoute = child;
  }
});

return matchedRoute;

isValidElement によって child が ReactElement かどうかをチェックすることができます。 TypeScript 的には isValidElement が Type Guard として型定義してあるので child の型が絞り込まれて .props.type プロパティにアクセスできるようになります。

child.type は例えば div 要素の場合は "div" 文字列が、 Route コンポーネントの場合は Route 関数のインスタンスが格納されています。

もし渡された children の一つが Route ではなくても無視するだけで Error を throw したりはしませんが、開発者に伝わるように console.error を残しておきます(本当は development ビルドのときだけ console.error するみたいな処理が理想です)。

child.type === Route を満たし、かつ useRouteMatch のために作った matchPath を流用して判定した結果マッチしたものを return します。これで Switch コンポーネントの要件を満たすことが可能です。

Switch コンポーネント全文
export const Switch: VFC<{ children: ReactNode }> = ({ children }) => {
  const location = useLocation();

  let matchedRoute: ReactElement | null = null;

  Children.forEach(children, (child) => {
    if (!isValidElement(child) || child.type !== Route) {
      console.error("Switch can have only Route components.");
      return;
    }

    const matched = matchPath(child.props.path, location.pathname, child.props.exact);

    if (matched !== null) {
      matchedRoute = child;
    }
  });

  return matchedRoute;
};

全体のソースコード

この記事で作成するものは以上です。マッチしているときだけ適用されるクラス名 activeClassName を渡せる NavLink や、マウントと同時に画面遷移を行う Redirectなど作成していない API がいくつかありますが、応用すれば簡単に作成することができます。

動作確認

実際に作ったものが react-router のように使えるか確認しましょう。

まずは Router コンポーネントをアプリのトップに配置します。

render(
  <Router>
    <App />
  </Router>,
  rootElement
);

App コンポーネントで Link を配置していきます。 location state も渡っていくか確認するために props に適当なオブジェクトを渡しておきます。
また、 Switch の中に Route を配置していきます。パスはなんかそれっぽく(適当)。

const App: VFC = () => {
  return (
    <div>
      <div style={{ display: "flex", gap: 16 }}>
        <Link to="/">Home</Link>
        <Link to="/stin">Profile</Link>
        <Link to="/stin/settings" state={{ foo: "bar" }}>
          Settings
        </Link>
      </div>
      <Switch>
        <Route path="/:userId/settings">
          <UserSettings />
        </Route>
        <Route path="/:userId">
          <UserProfile />
        </Route>
        <Route path="/">
          <Home />
        </Route>
      </Switch>
    </div>
  );
};

UserSettingsUserProfile ではパスパラメータの userId が取れるはずなので useParams を内部で使いましょう。 location state は useLocation から取得できます。

const UserSettings: VFC = () => {
  const { userId } = useParams<{ userId: string }>();
  const { state } = useLocation();

  return (
    <div>
      <div>{userId}</div>
      <div>location state: {JSON.stringify(state)}</div>
    </div>
  );
};

では動かしてみましょう。

リンクをクリックすると RouteSwitch で出し分けている部分が切り替わります。 Settings のリンクでは、 location state もちゃんと表示されますね。
最低限の機能を持ったルーティングライブラリが完成しました。

まとめ

この記事では history ライブラリを使用して react-router を自作する方法を紹介しました。 history ライブラリは react-router だけでなく、新興で react-query と同じ開発者が公開している react-location や、 TypeScript の型安全性を第一に設計されたルーターライブラリの Rocon が依存しており、非常に使い勝手がよいのがわかります。

react-router の API を模倣をしてきましたが、自由にインターフェイスを組めば自分好みの使いやすさを持ったルーターライブラリを実装可能ということです。ぜひ最強のルーターライブラリを自作してみてください(自己責任で)。

僕自身もルーターライブラリを作ってみたいと思い、APIを考えている途中です。完成したらまた記事にしたいと思います。

それではよい React ライフを!

GitHubで編集を提案

Discussion