React のシングルページアプリケーションを実装する過程で、必ず習得しなければならないエコシステムの一つが React-Router ルーティングライブラリです。本記事ではゼロから React-Router の重要な部分を実装していきます。
React-Router v4 は画期的なアップデートであり、以前のバージョンの書き方とは完全に互換性がありません。以前のより受け入れやすい設定型ルーティングの書き方に比べ、v4 は個々のルーティングコンポーネント(
Link、Route、Redirect...)を使用して実現されています。これこそがReact自体のコンポーネント化の思想に真に合致していると思います。もしあなたがすでにReactを理解しているのであれば、React-Routerは単にいくつかのコンポーネントを学ぶだけのことです。したがって、私たちが実装する重要な部分は主に v4 に焦点を当てています。
始めに#
まず、create-react-app を使用してプロジェクトを迅速に立ち上げ、index.js に公式の デモ を入力します。最終的にレンダリングされる App コンポーネントを見てみましょう:
const App = () => (
<div>
<ul>
<li><Link to="/">ホーム</Link></li>
<li><Link to="/about">アバウト</Link></li>
<li><Link to="/topics">トピック</Link></li>
</ul>
<hr/>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/topics" component={Topics} />
</div>
)
ここには React-Router の最もコアな 2 つのコンポーネント: Route と Link が含まれています。
次に、これら 2 つのコンポーネントの実装に重点を置いていきましょう~
Route#
上記の例では、Route に 4 つの props: exact、path、component、および render を渡すことができることに注意します。まずは基本的な Route コンポーネントを実装してみましょう:
class Route extends Component {
static propTypes = {
path: PropTypes.string, // マッチするパス
exact: PropTypes.bool, // 正確にマッチするか
component: PropTypes.func, // マッチした場合にレンダリングされるコンポーネント
render: PropTypes.func // カスタムレンダリング内容
}
render () {
const { path, exact, component, render } = this.props
// ルートがマッチするか確認
const match = matchPath(window.location.pathname, { path, exact })
// マッチしない場合は null を返す
if (!match) return null
// (優先)マッチするコンポーネントが渡された場合
if (component) return React.createElement(component, { match })
// カスタムレンダリング内容が指定されている場合
if (render) return render({ match })
return null
}
}
Route コンポーネントのコアは、マッチが成功した場合にレンダリングし、失敗した場合はレンダリングしない(null を返す)ということです。
次に、matchPath マッチ関数の実装を見てみましょう:
const match = (pathname, options) => {
const { path, exact = false } = options
// path が渡されていない場合
if (!path) return { path: null, url: pathname, isExact: true }
// 正規表現で url をマッチ
const match = new RegExp(`^${path}`).exec(pathname)
// マッチしない場合は null を返す
if (!match) return null
// 完全にマッチするか確認
const url = match[0]
const isExact = pathname === url
if (exact && !isExact) return null
return {
path,
url,
isExact
}
}
React-Routerは互換性を保証するために pathToRegex ライブラリを導入して正規表現マッチを行っていますが、ここではjsに備わっているRegExp正規表現オブジェクトを使って簡単に実装しています。
これで Route コンポーネントのマッチレンダリングロジックを実装しましたが、実際のルーティング切り替えの中で、どのように Route を再レンダリングするのでしょうか?
class Route extends Component {
...
componentWillMount () {
// ブラウザの進む/戻るボタンのクリックを監視
window.addEventListener('popstate', this.handlePop)
}
componentWillUnmount () {
window.addEventListener('popstate', this.handlePop)
}
handlePop = () => {
this.forceUpdate()
}
...
}
ルーティングが切り替わるシーンは 2 つあり、そのうちの 1 つは ブラウザの進む / 戻るボタンのクリック です。私たちは 2 つのライフサイクル関数でこのクリックイベントを監視し、一度クリックされると自動的に forceUpdate メソッドを呼び出して UI を強制的に更新します。
React-Router では
history.listenを使用して監視していますが、同様に HTML5 のpopstateイベントを導入することを避けています。
もう 1 つのルーティング切り替えのシーンは a タグのクリックです。これが次に実装する Link コンポーネントです~
Link#
Link コンポーネントのコアは 宣言的 に URL を更新することです。内部的には最終的に a タグであることは簡単に想像できます:
class Link extends Component {
static propTypes = {
to: PropTypes.string.isRequired,
replace: PropTypes.bool
}
// デフォルトの遷移を防ぎ、カスタムのルート更新メソッドを呼び出す
handleClick = e => {
const { replace, to } = this.props
e.preventDefault()
replace ? historyReplace(to) : historyPush(to)
}
render () {
const { to, children } = this.props
return (
<a href={to} onClick={this.handleClick}>
{children}
</a>
)
}
}
カスタムの historyReplace と historyPush メソッドの違いは、履歴スタックに挿入するか、履歴スタックを置き換えるかです:
const historyPush = path => window.history.pushState({}, null, path)
const historyReplace = path => window.history.replaceState({}, null, path)
Link の実装はこのようになります。それでは、Link をクリックした後、どのように対応する Route にマッチさせるのでしょうか? Route コンポーネントのマウントとアンマウントの前に対応するイベントを監視していますが、Link のクリックには効果がありません。
そのため、Route コンポーネントが読み込まれる際に、それをインスタンスとして保存する必要があります(マッチされているかどうかに関わらず)。
componentWillMount () {
window.addEventListener('popstate', this.handlePop)
register(this)
}
componentWillUnmount () {
unregister(this)
window.addEventListener('popstate', this.handlePop)
}
register と unregister の実装は以下の通りです:
let instances = []
const register = comp => instances.push(comp)
const unregister = comp => instances.splice(instances.indexOf(comp), 1)
したがって、historyPush と historyReplace メソッドの中で、各 Route インスタンスをループして呼び出し、それらを一つずつマッチさせる必要があります:
const historyPush = path => {
window.history.pushState({}, null, path)
instances.forEach(instance => instance.forceUpdate())
}
const historyReplace = path => {
window.history.replaceState({}, null, path)
instances.forEach(instance => instance.forceUpdate())
}
これで Route と Link コンポーネントを index.js にインポートして起動すると、正常に動作することが確認できます。
React-Routerはルーティングコンポーネント内でsetState、context、およびhistory.listenを組み合わせてこの問題を解決しています。
最後に#
React Router v4 の原理は非常に学ぶ価値があります。React はあなたをより良い JavaScript 開発者にし、React-Router はあなたをより良い React 開発者にしてくれるでしょう。
参考 :