30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

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'));

webpack-dev-serverで開発用サーバを立てる

webpackでは、開発用サーバを簡単に立てることが出来る。

前回の記事でwebpack2の導入に成功したので、今度はそこにwebpack-dev-serverを導入してみる。

前回の記事の最後で、Reactをビルドできる状態になった。
その時はhtmlファイルを直接開いていたが、ローカルサーバを立ち上げてそこでhtmlを表示できるようにするのが、今回の目的。

なお、この記事で使用しているバージョンは、webpack@2.4.1webpack-dev-server@2.4.5である。

サーバを起動するまでの手順

まずは必要なnpmパッケージをインストール。

$ npm i -D webpack-dev-server

次に、webpack.config.jsを編集して、以下の内容にする。

const path = require('path');

const config = {
  context: path.resolve(__dirname, 'src'),
  entry: './main.js',
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'bundle.js',
  },
  module: {
    rules: [{
      test: /\.js$/,
      include: path.resolve(__dirname, 'src'),
      use: [{
        loader: 'babel-loader',
        options: {
          presets: [
            'es2015', 'react',
          ],
        },
      }],
    }],
  },
  devServer: {
    contentBase: path.resolve(__dirname, 'public'),
    port: 3000,
  },
};

module.exports = config;

devServerの設定を追加している。
contentBaseで指定したディレクトリが、サーバの起点になる。省略した場合は、カレントディレクトリが起点になる。
portはその名の通り、ポート番号を指定する。省略した場合は8080となる。

最後に、package.jsonscriptsを編集。

"scripts": {
  "start": "webpack-dev-server --inline",
  "build": "webpack -p"
},

この状態で$ npm startを実行すると、ローカルサーバが立ち上がる。
http://localhost:3000/にアクセスすると、Hello, React!と表示されているのが確認できる。

そして、src/parts.jsの内容を編集して保存すると、自動的にビルドが行われ、さらにブラウザのリロードも自動的に行われるのが分かる。

ディレクトリの場所に注意

contentBaseで指定するディレクトリと、バンドルファイルをoutputするディレクトリは、同一のものにする必要がある。
上記の例では両方共publicになっているので、問題ない。

例えばこれを変更してみる。

serverというディレクトリを作り、そこにindex.htmlというファイルを置く。

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

このファイルを直接開くと、問題なくbundle.jsを読み込め、正しく表示される。

では、ローカルサーバでアクセスするとどうなるのか。
contentBase: path.resolve(__dirname, 'server'),とした上で、$ npm startしてみる。 すると、bundle.jsを読み込めず、上手くいかない。

ビルドしたファイルは出力されない

webpack-dev-serverを実行していると自動的にビルドされることは既に述べた。
だが、ビルドしたファイルはメモリ上に保存されるらしく、実際にbundle.jsとして出力されるわけではない。

試しに、サーバを起動させた状態でsrc/parts.jsHello, React!となっている部分をHello, Server!に書き換えてみる。
自動的にビルドされ、Hello, Server!と描画される。

では、直接htmlファイルを開くとどうなるか。
Hello, React!のままである。つまり、bundle.jsの内容は、変更前のままである。

つまり、webpack-dev-serverを実行しても、bundle.jsには出力されないというである。
実際にファイルに出力するには、webpackを実行する必要がある。

参考資料