読者です 読者をやめる 読者になる 読者になる

React Router v4 の基本的な考え方と使い方

React Routerの使い方を学んでいく。
先月にv4がリリースされたので、それに準拠した内容を学んでいく。

ここで述べていることは正確な仕様とは異なる可能性が十分にあるので、正確なことを知りたい場合は公式のドキュメントを読みましょう。
React Router: Declarative Routing for React.js

何のためにReact Routerを使うのか

具体的な使い方に入る前に、そもそも何のためにReact Routerを使うのかを少し書いてみる。

聞きかじったり自分で考えたりしたものなので、これもまた、間違っている可能性が大いにあります。

SPAとは、ページ遷移を行わず単一ページでコンテンツの切り替えを行うウェブアプリケーションのこと。
ページの読み込みは最初の1回だけで、後はそのページのなかで、JavaScriptによって描画やAPIへのアクセスを行う。

この、ページ遷移を行わない、というのがSPAの大きな特徴で、これを上手く使うことでユーザーの利便性を向上させることが出来る。
デスクトップアプリやモバイルアプリに引けを取らないアプリケーションをブラウザで提供できるようになる。

参考:シングルページアプリケーション(SPA)の導入メリット&デメリット|株式会社オロ

ページ遷移を行わないため、最初にアクセスしたURLのまま、あらゆる操作や表示の切り替えを行うことが出来る。
言い換えれば、同一のURLであらゆる状態を表現できる。

だがこれは同時に、Webの特徴の一つである「URLによってリソース(の状態)を一意に特定できる」という特性を失うことになる。
ユーザーの視点から見ると、コンテンツの共有やブックマークが出来なくなることを意味する。あらゆるコンテンツが、同一のURLで表現されてしまうから。

また、ブラウザによる操作がユーザーの想定通りに行われない、という問題も発生する。
通常のウェブサイト、ウェブアプリケーションでは、「状態の履歴」と「URLの履歴」は一致している。
そのため、ブラウザの「戻る」ボタンを押せば、一つ前の状態に戻ることが出来る。
だがSPAは、「一つのページで状態を変化させていく」という性質上、「戻る」ボタンを押してしまうと、意図した状態よりも前の状態に戻ってしまう可能性がある。
ブラウザはURLの履歴を管理しているが、それがコンテンツの切り替えと一致していないために、このような状態が発生してしまう。

これらの問題を解決し、SPAの利点と、URLによるコンテンツの特定やページの履歴の管理を両立させるのが、React Routerのようなルーティングライブラリの目的である。

そのように自分は理解している。

導入

ここからは具体的な使い方を説明していく。
Reactのビルドやローカルサーバなどの環境構築は既に済んでいるものとして話を進める。

ちなみにこの記事を書くにあたっては、以下の内容で環境構築し、devServerの設定にhistoryApiFallback: true,を追加した。

まず、開発に必要なnpmモジュールをインストールする。

$ npm i -S react react-dom react-router-dom

次に、JavaScriptのファイルを書く。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route, Link } from 'react-router-dom';

const Root = () => (
  <div>
    Root!
  </div>
);

ReactDOM.render(<Root />, document.querySelector('#app'));

最後に、ローカルサーバから返すhtmlファイルを作成する。

<!DOCTYPE html>
<html>
<head>
  <title>webpack</title>
</head>
<body>
  <div id="app"></div>
  <script src="/bundle.js"></script>
</body>
</html>

これでビルドを行い、特にエラーを出すことなくRoot!と画面に表示されていれば準備は成功である。
後はコンポーネントを編集し、インポートしたBrowserRouterRouteなどを使って、ルーティングを行っていく。

ルーティングの基礎

まずは、次のようなサンプルアプリを作成してみる。
//memo/profileの3つのURLが用意されており、それぞれにアクセスするとHomeMemoProfileが表示される。
これにより、「URLによるコンテンツの特定」を実現できる。

最初に、大元となるコンポーネントBrowserRouterdivで囲む。

const Root = () => (
  <BrowserRouter>
    <div>
      Root!
    </div>
  </BrowserRouter>
);

次に、各コンテンツのコンポーネントを作成する。

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

const Memo = () => (
  <div>
    <h2>Memo</h2>
  </div>
);

const Profile = () => (
  <div>
    <h2>Profile</h2>
  </div>
);

そして、再びRootを編集する。

const Root = () => (
  <BrowserRouter>
    <div>
      <Route exact path="/" component={Home} />
      <Route path="/memo" component={Memo} />
      <Route path="/profile" component={Profile} />
    </div>
  </BrowserRouter>
);

Routeコンポーネントによってルーティングを行っており、現在のURLがpathと一致した場合、そこのcomponentレンダリングする。
アドレスバーにURLを打ち込んで確認すれば、それぞれに正しく表示されているはず。

exact

exactという記述がある場合、pathと一致するURLの場合のみ、コンポーネントレンダリングする。
ない場合は、一致するURLだけでなく、その下層を示すURLの場合も、レンダリングを行う。

例えば上記の場合、/memo/hoge/memo/abc/123にアクセスしても、Memoと表示される。
だが<Route exact path="/memo" component={Memo} />に書き換えると、/memo/memo/の場合にのみ、レンダリングを行うようになる。

render

componentではなくrendearでも、レンダリングを行える。render属性に無名関数を渡し、そのなかでコンポーネントを返すようにして使う。

<Route path="/hoge" render={() => (<h2>Hoge</h2>)} />

Link

現在の状態だと、アドレスバーに手動でURLを入力しないといけない。
そこで、ヘッダーにリンクを表示させることにする。

まずはヘッダーのコンポーネントを作る。

const Header = () => (
  <div>
    <p>Header</p>
    <ul>
      <li><Link to="/">Home</Link></li>
      <li><Link to="/memo">Memo</Link></li>
      <li><Link to="/profile">Profile</Link></li>
    </ul>
  </div>
);

React Routerでは、Linkによってリンクを実装する。to属性でリンク先のURLを指定する。

ヘッダーのように全てのコンテンツに共通して表示させる要素は、Routeを使わずに直接書けばよい。

const Root = () => (
  <BrowserRouter>
    <div>
      <Header />
      <Route exact path="/" component={Home} />
      <Route path="/memo" component={Memo} />
      <Route path="/profile" component={Profile} />
    </div>
  </BrowserRouter>
);

これで、リンクを押してURLを切り替えることが出来るようになった。また、ブラウザバックで戻ることも出来る。

ここまでで、URLによるルーティングをSPAに導入することが出来た。
もちろんこれはあくまでも初歩で、React Routerはもっと様々な機能を用意している。

入れ子

機能、というほどではないが、Routeは入れ子にすることが出来る。

Memoコンポーネントを以下のように書き換えると、/memo/hogeにアクセスした際にMemoHogeが表示される。
/memo/memo/fugaにアクセスした場合は、Memoしか表示されない。

const Memo = () => (
  <div>
    <h2>Memo</h2>
    <Route path="/memo/hoge" render={() => (<div>Hoge</div>)} />
  </div>
);

コンポーネントが受け取る引数

Routeによってレンダリングされたコンポーネントは、引数として様々な情報を受け取る。

const Memo = arg => (
  <div>
    <h2>Memo</h2>
    {console.log(Object.keys(arg))}  {/* ["match", "location", "history", "staticContext"] */}
    {console.log(Object.keys(arg.match))}  {/*  ["path", "url", "isExact", "params"] */}
  </div>
);

例えばmatch.urlには、Routepath属性で指定されたURLが入っている。

const Memo = arg => (
  <div>
    <h2>Memo</h2>
    {console.log(arg.match.url)}  {/* /memo */}
  </div>
);

そのため、先程の入れ子の例は次のように書き換えることが出来る。

const Memo = arg => (
  <div>
    <h2>Memo</h2>
    <Route path={`${arg.match.url}/hoge`} render={() => (<div>Hoge</div>)} />
  </div>
);

やっていることは同じである。
だがハードコーディングは避けるべきだし、Memoの子階層としてHogeがあることを明示するためにも、このように書いたほうが望ましいと思う。

パラメータ

match.paramsを使うことで、URLを使ってパラメータをコンポーネントに渡すことも出来る。

<Route path="/memo/:id" component={Memo} />として/memo/hogeにアクセスすると、次のようになる。

const Memo = arg => (
  <div>
    <h2>Memo</h2>
    {console.log(arg.match.params.id)}  {/* hoge */}
  </div>
);

パラメータの使い方

冒頭で述べたように、URLによってリソースを一意に特定できるのが、Webの大きな特徴である。
SPAでもこれを実現するためには、URLにリソースを復元するための情報を含めておく必要がある。そうしないと、リソースやその状態を再現することが出来ない。
この、「リソースを復元するための情報」として、パラメータを使うことが出来る。

例として、/memo/{contentId}というURLで、メモの内容を特定し表現できるようにしてみる。

まず、Memoコンポーネントを書き換えて、パラメータを受け取るようにする。

const Memo = ({ match }) => (
  <div>
    <h2>Memo</h2>
    <Route exact path={`${match.url}/:contentId`} component={Content} />
  </div>
);

これで、ContentというコンポーネントcontentIdがパラメータとして渡るようになった。

const Content = ({ match }) => {
  const id = Number(match.params.contentId);
  if (!id || id > contents.length) {
    return (<p>Not Found.</p>);
  }
  return (
    <p>
      {contents[id - 1]}
    </p>
  );
};

contentIdをキーにして、コンテンツにアクセスしている。
後は適当にコンテンツを用意すればいい。

const contents = [
  '今日のうちにブログを書き終えよう。',
  '明日は夕方から雨なので傘を持って出る。',
  '欲しかった技術書をネットで注文しておいた。',
];

これで、/memo/1にアクセスすると今日のうちにブログを書き終えよう。と表示される。

完成図

最後に、各メモへのリストを表示するContentListを作成して、完成。

以下、全体のコードを貼っておく。

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route, Link } from 'react-router-dom';

const contents = [
  '今日のうちにブログを書き終えよう。',
  '明日は夕方から雨なので傘を持って出る。',
  '欲しかった技術書をネットで注文しておいた。',
];

const Header = () => (
  <div>
    <p>Header</p>
    <ul>
      <li><Link to="/">Home</Link></li>
      <li><Link to="/memo">Memo</Link></li>
      <li><Link to="/profile">Profile</Link></li>
    </ul>
  </div>
);

const Home = () => (
  <div>
    <h2>Home</h2>
  </div>
);

const Content = ({ match }) => {
  const id = Number(match.params.contentId);
  if (!id || id > contents.length) {
    return (<p>Not Found.</p>);
  }
  return (
    <p>
      {contents[id - 1]}
    </p>
  );
};

const ContentList = ({ match }) => {
  const list = contents.map((elem, index) => (
    <li key={index}><Link to={`${match.url}/${index + 1}`}>{index + 1}</Link></li>
  ));
  return (
    <ul>
      {list}
    </ul>
  );
};

const Memo = ({ match }) => (
  <div>
    <h2>Memo</h2>
    <Route exact path={`${match.url}/:contentId`} component={Content} />
    <Route exact path={match.url} component={ContentList} />
  </div>
);

const Profile = () => (
  <div>
    <h2>Profile</h2>
  </div>
);

const Root = () => (
  <BrowserRouter>
    <div>
      <Header />
      <Route exact path="/" component={Home} />
      <Route path="/memo" component={Memo} />
      <Route path="/profile" component={Profile} />
    </div>
  </BrowserRouter>
);

ReactDOM.render(<Root />, document.querySelector('#app'));