SPAアプリを開発する場合、どのpathにアクセスしてもサーバーからは同じHTML、jsファイルが返却されるので、クライアント側でルーティング機能を実装する必要があります。そしてReactなら、react-router-domといったライブラリを使って実装するのが一般的だと思います。
<BrowserRouter>
<Switch>
<Route exact path="">
<Component />
</Route>
</Switch>
</BrowserRouter>
こんな感じで記述しておけば、書いたpathにアクセスすることで指定したコンポーネントが表示され、実質的にルーティングが可能になります。
では、ルーティングライブラリはどんな実装でこの機能を実現しているのか、というのが気になったので、ライブラリの中身をざっと読んでみました。
react-router-domの公式ドキュメントを読むと、上記の実装が最小限っぽかったので、この3つのコンポーネントの実装を追っていきます。
BrowserRouter.js
BrowserRouterの実装は非常にシンプルで、以下の数行だけです。
import { createBrowserHistory as createHistory } from "history";
/*
* The public API for a <Router> that uses HTML5 history.
*/
class BrowserRouter extends React.Component {
history = createHistory(this.props);
render() {
return <Router history={this.history} children={this.props.children} />;
historyを生成し、コンポーネントのpropsに乗せてreturnしているだけですね。createBrowserHistoryは何をしているかというと、ブラウザが提供するhistoryオブジェクトをラップし、APIとしてpushメソッドやreplaceメソッドを公開しています。Switchコンポーネント以下のコンポーネントで、propsをconsole.logなどしてみると、historyの中身(=createBrowserHistory)を確認できます。
Switch.js
次にSwitch.jsを見ていきます。ここで少しややこしいのは、react-routerはいくつかのパッケージに分かれていることです。react-router-config、react-router-dom、react-router-native、react-routerの4つがあり、Switch.jsの本体はreact-routerにあります。
react-router-domにもSwitch.jsファイルはありますが、react-router側のSwitch.jsを参照する内容が記述されているだけです。おそらく、コアの機能はreact-router側に集約されているのでしょう。BrowserRouter.jsはその名前の通り、Webブラウザ特有の実装だったため、react-router-domパッケージの中で実装されていました。
Switch.jsの処理も非常に簡潔で、コア部分は30行ほどからの実装になっています。(一部、コメントを削除して、解説コメントを追記しています)
/**
* The public API for rendering the first <Route> that matches.
*/
class Switch extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Switch> outside a <Router>");
// 現在のpathを取得
const location = this.props.location || context.location;
let element, match;
// Switchコンポーネント以下に置かれているコンポーネントをループで処理
React.Children.forEach(this.props.children, child => {
if (match == null && React.isValidElement(child)) {
element = child;
// Routeコンポーネントからpathを取得
const path = child.props.path || child.props.from;
// 現在のpathと、Routeコンポーネントのpathが一致している場合、matchにそのpathを代入
match = path
? matchPath(location.pathname, { ...child.props, path })
: context.match;
}
});
// 現在のpathとmatchするRouteコンポーネントがあった場合は、propsを追加してreturn
return match
? React.cloneElement(element, { location, computedMatch: match })
: null;
}}
</RouterContext.Consumer>
);
}
}
Switchコンポーネントの役割は、自身の配下にあるRouteコンポーネント(=children)をループで処理し、現在のpathとマッチするRouteコンポーネントがあるかを判定することです。
Switchコンポーネントの配下にいくつRouteコンポーネントが配置されていても、ループ処理の中でふるいにかけられ、レンダリング時点ではpathにマッチするコンポーネントのみが残ることになります。
Route.js
Route.jsもSwitch.jsと同じくreact-routerパッケージにあります。上2つに比べて少し長いので、前半、後半に分けて見ていきます。
/**
* The public API for matching a single path and rendering.
*/
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Route> outside a <Router>");
// 現在のpathを取得
const location = this.props.location || context.location;
// computedMatchはSwitch.jsで追加されたprops
const match = this.props.computedMatch
? this.props.computedMatch
: this.props.path
? matchPath(location.pathname, this.props)
: context.match;
const props = { ...context, location, match };
let { children, component, render } = this.props;
// Preact uses an empty array as children by
// default, so use null if that's the case.
if (Array.isArray(children) && children.length === 0) {
children = null;
}
{/* 後半部分は省略 */}
}}
</RouterContext.Consumer>
);
}
}
前半部分は特別なことはしていないように見えます。Switch.jsで行なっていた、現在のpathとpropsのpathが一致するかどうかの判定をもう一度行なっているのがよくわかりませんが。。。
後半部分は以下です。
/**
* The public API for matching a single path and rendering.
*/
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{/* 前半部分は省略 */}
return (
<RouterContext.Provider value={props}>
{props.match
? children
? typeof children === "function"
? __DEV__
? evalChildrenDev(children, props, this.props.path)
: children(props)
: children
: component
? React.createElement(component, props)
: render
? render(props)
: null
: typeof children === "function"
? __DEV__
? evalChildrenDev(children, props, this.props.path)
: children(props)
: null}
</RouterContext.Provider>
</RouterContext.Consumer>
);
}
}
三項演算子の連打でちょっとイヤになりますが、上から順に見ていきます。
まず、props.matchを評価します。matchしている(=現在のpathとRouteコンポーネントで指定しているpathが同じ)場合、Routeコンポーネントが子コンポーネントを持っているかを見ます。持っている場合、そのchildrenは関数かどうかをチェックします。関数ならば実行する必要があるためです。
Routeコンポーネントが子コンポーネントを持っていなかった場合、propsにcomponentがあるかどうかを見ていきます。あるならば、React.createElementでそのcomponentを描画します。component propsが記述されていなかった場合、renderの行に進むのですが、このrenderがどういった評価になるのかがちょっとわかりません。。。ただ、trueの場合はpropsをrenderし、falseの場合はnull、つまり何も描画しないという挙動になるようです。
ここで三項演算子の頭に戻って(props.match)、pathにmatchしていなかった場合は、childrenの存在を見て、かつそれが関数であるかどうかを確認します。関数であれば実行し、そうでなければnullに到達して一連の三項演算子の評価はおしまいです。
Switch.jsで現在のpathを見て描画すべきRouteコンポーネントを絞り込み、Route.jsはどのようにレンダリングするかを担当する、みたいな感じかと思われます。
Link.js
ここまでの3つのコンポーネントを配置すればルーティングのロジックは稼働しますが、このLink.jsも大切なコンポーネントです。Linkコンポーネントはページ間遷移をサーバーへのリクエストなしで実現するコンポーネントです。
HTMLのaタグをクリックするとイベントが発行され、サーバーへのリクエストが発生します。すると、リクエスト → レスポンス → レンダリングのような流れが再度実行され、これらは基本的に同期的に実行されるので、ユーザーはその時間分、待たされることになります(いわゆるSPAではないWebサイト)。
なので、SPAの内部ページ遷移にはaタグはそのままでは使えません。Linkコンポーネントは、aタグをラップし、サーバーへのリクエストイベントをキャンセルするコンポーネントになっています。その実装は以下です。
/**
* The public API for rendering a history-aware <a>.
*/
const Link = forwardRef(
(
{
component = LinkAnchor,
replace,
to,
innerRef, // TODO: deprecate
...rest
},
forwardedRef
) => {
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Link> outside a <Router>");
const { history } = context;
// 遷移先のpathを生成(/example)
const location = normalizeToLocation(
resolveToLocation(to, context.location),
context.location
);
// 完全なURLを生成(https://example.com/example)
const href = location ? history.createHref(location) : "";
const props = {
...rest,
href,
navigate() {
const location = resolveToLocation(to, context.location);
const method = replace ? history.replace : history.push;
// path遷移を実行
method(location);
}
};
// React 15 compat
if (forwardRefShim !== forwardRef) {
props.ref = forwardedRef || innerRef;
} else {
props.innerRef = innerRef;
}
return React.createElement(component, props);
}}
</RouterContext.Consumer>
);
}
);
これもざっくり見ていきます。
まずforwardRefというのが出てきます。これは子コンポーネント内のDOM要素にアクセスするための機能で、useRefに近しい感じです。useRefの場合、子コンポーネント内の特定のDOM要素にアクセスすることはできませんが(親から見ると内部実装は隠蔽されているので)、forwardRefの場合はそれが可能になるようです。
LinkコンポーネントでforwardRefが使われているのは、この機能を実現するためだと思いますが、自身でこの機能を使ったことはないので、ちょっと具体的なイメージは湧きづらいです。
navigate()の中身を見ると、history.replace、history.pushの記述があります。これらは少し上で受け取ったhistoryオブジェクト内に実装されているメソッドで、その途中にwindow.locationにページ履歴を追加する処理があります。これにより、SPAにおいてもブラウザの「戻る」や「進む」の機能が正しく動作するようになります。
ただ、最後まで読み進めてもaタグのイベントをキャンセルする処理はおろか、aタグすら出てきません。それらはどこで実装されているかというと、引数で渡されているLinkAnchorです。
LinkAnchorの記述はLinkの直上にあり、以下のようになっています。
const LinkAnchor = forwardRef(
(
{
innerRef, // TODO: deprecate
navigate,
onClick,
...rest
},
forwardedRef
) => {
const { target } = rest;
let props = {
...rest,
onClick: event => {
try {
// LinkコンポーネントにonClick propsが記述されていた場合はそれを実行
if (onClick) onClick(event);
} catch (ex) {
event.preventDefault();
throw ex;
}
// aタグのイベント発行をキャンセルし、navigate()を実行
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
(!target || target === "_self") && // let browser handle "target=_blank" etc.
!isModifiedEvent(event) // ignore clicks with modifier keys
) {
event.preventDefault();
navigate();
}
}
};
// React 15 compat
if (forwardRefShim !== forwardRef) {
props.ref = forwardedRef || innerRef;
} else {
props.ref = innerRef;
}
// aタグに上記のpropsを付与してreturn
/* eslint-disable-next-line jsx-a11y/anchor-has-content */
return <a {...props} />;
}
);
LinkAnchorには本命のaタグのイベント発行をキャンセルする処理が記述されています。
onClickで発行されるイベントをキャンセルすればOKなのですが、開発者が自身でLinkコンポーネントのonClickハンドラに何かしらの処理を追加しているかもしれません。それも全てキャンセルしてしまうのは使い勝手が悪いので、ユーザーが追加したonClickについては実行されるようになっています。
次のif文の中で4つの条件が評価されており、trueだった場合はイベント発行をキャンセル、つまりサーバーへのリクエストを送らないようにしています。代わりにnavigate()を実行していますが、この具体的な処理はLinkコンポーネント側に記述されていました。(const method = replace ? history.replace : history.push;
の箇所)
これらの実装により、Linkコンポーネントは「サーバーへのリクエストが起こらないaタグ」のような振る舞いになっています。
まとめ
react-routerのリポジトリには他にもたくさんのファイルがあり、非常に多機能なライブラリなので、全部を見ていくのはさすがに難しかったのですが、上記の4コンポーネントについて知るだけでもSPAにおけるルーティングのロジックが掴め、非常によかったです。
おそらくですが、他のルーティングライブラリも似たような実装になっていると思うので、自身が使っているライブラリの中身を見てみると面白いかもしれません。