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
開発者にしてくれるでしょう。
参考 :