ReactReduxを学ぶために、開発環境というかプロジェクトテンプレートをスクラッチから作っている。 (最終的な成果はGitHubに置いた。)

前回Redux Sagaをセットアップした。

(2018/11/21更新)

フロントエンドのルーティング

Webアプリケーションにおけるルーティングとは、クライアントがリクエストしたURLに対して、返すべきリソースを選択する処理。 昔はバックエンド(i.e. サーバサイド)でやってたけど、バックエンドでリソースを返すということは、ページ遷移が発生するということなので、ネイティブアプリケーションに比べてUXが落ちてしまう。

一方、ページ遷移を発生させないようにAjaxでサーバとやりとりしつつ、ちまちまDOMをいじるのは大変。 DOMをごっそり書き換えて、ページ遷移なしに画面を切り替えることはできるけど、ナイーブにやると以下のような問題がある。

  • URLと画面の紐づけがなく、URLを指定して直接開けない
  • ブラウザの進む、戻るが使えない
  • 宣言的に書けない

こういった問題に対応するため、フロントエンドでのルーティング技術が生まれた。

フロントエンドのルーティングでは、URLが変わってもリクエストはサーバに飛ばない。 代わりに、フロントエンドフレームワークがそのURLを見て、適切な画面を選んでレンダリングする。

ハッシュベースのルーティング

URLが変わってもリクエストがサーバに飛ばないとは何事か。

それを実現するやりかたは2通りある。 古くはハッシュ(#、フラグメント識別子)をつかったやり方。

例えば、http://example.com/でUIをサーブしているとすると、http://example.com/#fooとか、http://example.com/#barで別々のページの状態を表現する。 ハッシュ以降が変わってもブラウザがサーバにリクエストを投げることはないので、クライアント側でハンドリングできる。 (因みに、ハッシュを含んだURLをブラウザのアドレスバーに入れても、ハッシュを除いたURLでリクエストが送られる。この挙動の根拠となる規格はRFCなどを調べても見つからなかったけど…)

ハッシュの書き換えは、JavaScriptで以下のようにしてできる。

location.hash = newHash;

こういう処理を、例えばWeb UIのボタンをクリックしたときなんかに実行してURLを変えて、その上で画面を更新してやればいい。

そのあと、ブラウザの戻るボタンなんかを押されると書き換える前のURLにもどるわけだけど、これを検知するためにsetInterval()とかで定期的にlocation.hashを監視してたりした。

History APIによるルーティング

ハッシュベースのルーティングは見るからにしょぼい。 URLのハッシュ以降しか使えないのもしょぼいし、内部の処理も泥臭い。

これが、HTML 5でHistory APIがでて変わった。 History APIはJavaScriptのAPIで、ブラウザの履歴を操作できる。

const state = { hoge: "hogeee" };
history.pushState(state, "", "/foo/bar");

こんな感じのを実行すると、URLが/foo/barに変わる。(が、もちろんサーバにはリクエストは飛ばない。) で、ブラウザの戻るボタンを押すと、popstateイベントが発生するので、それにイベントハンドラを登録しておけば、もとのURLに戻った時にも適時画面を書き換えられる。 popstateイベントからは、pushState()に渡したstateオブジェクトを取得できる。


ところで、ブラウザのアドレスバーに/foo/barを直打ちするとどうなるかというと、普通にWebサーバを設定しておくと、/foo/bar/index.htmlを返そうとして、無いので404エラーになっちゃう。 ので、サーバ設定では、どのURLも同じリソース(e.g. /index.html)をしといて、そこからJavaScriptを呼んで、URLを読み取って、画面を描いてやればいい。


HTML 5が普及するにつれ、このようなHistory APIを使ったフロントエンドルーティングをするフレームワークやライブラリが色々出てきた。んだろうと思う。

React Router

Reactのエコシステムとしては、React Routerがフロントエンドルーティングを実現してくれる。

React Routerは、宣言的にフロントエンドルーティングを実現できるReactコンポーネントのライブラリ。

Reduxとともに使う場合は、Connected React Routerを使う。 Connected React Routerにはhistoryが必要。

$ yarn add react-router-dom connected-react-router history

React Routerはv4.3.1、Connected React Routerはv5.0.1が入った。

Connected React Router導入

まずはConnected React RouterのUsageを参考に、ReduxのReducerにrouterを追加し、MiddlewareにrouterMiddlewareを追加して、historyのインスタンスをStoreとつなぐ。

src/reducers/rootReducer.js:

 import { combineReducers } from 'redux';
+import { connectRouter } from 'connected-react-router';
 import * as reducers from './reducers';

-const rootReducer = combineReducers(reducers);
-export default rootReducer;
+const createRootReducer = (history) =>
+  combineReducers({
+    router: connectRouter(history),
+    ...reducers,
+  });
+export default createRootReducer;

src/configureStore.js:

 import { createStore, applyMiddleware } from 'redux';
 import createSagaMiddleware from 'redux-saga';
+import { createBrowserHistory } from 'history';
+import { routerMiddleware } from 'connected-react-router';
 import { logger } from 'redux-logger';
 import rootSaga from './sagas/rootSaga';
-import rootReducer from './reducers/rootReducer';
+import createRootReducer from './reducers/rootReducer';

 const sagaMiddleware = createSagaMiddleware();
+export const history = createBrowserHistory();

 export default function configureStore(initialState = {}) {
   const middlewares = [];
   if (process.env.NODE_ENV === `development`) {
     middlewares.push(logger);
   }
+  middlewares.push(routerMiddleware(history));
   middlewares.push(sagaMiddleware);

   const store = createStore(
-    rootReducer,
+    createRootReducer(history),
     initialState,
     applyMiddleware(...middlewares),
   );
   sagaMiddleware.run(rootSaga);
   return store;
 }


で、Connected React RouterのConnectedRouterコンポーネントをReact ReduxのProviderの下に追加する。

src/index.jsx:

 import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'react-redux';
+import { ConnectedRouter } from 'connected-react-router';
 import App from './components/App';
-import configureStore from './configureStore';
+import configureStore, { history } from './configureStore';

 const store = configureStore();
 const root = document.getElementById('root');

 if (root) {
   ReactDOM.render(
     <Provider store={store}>
-      <App />
+      <ConnectedRouter history={history}>
+        <App />
+      </ConnectedRouter>
     </Provider>,
     root,
   );
 }

これだけ。 これで、Appコンポーネント以下でReact Routerのコンポーネントを使えるようになった。

React Router導入

React RouterのRedirectコンポーネントとRouteコンポーネントを使って、/にアクセスしたら/homeにリダイレクトして、/homeで今までと同じ画面をレンダリングするようにする。

まず、App.jsxをHome.jsxにリネームして、Homeコンポーネントに変える。

src/components/Home.jsx:

 import React from 'react';
 import styled from 'styled-components';
 import HogeButton from '../containers/HogeButton';
-import Fonts from '../fonts';

 const Wrapper = styled.div`
   font-size: 5rem;
 `;

-const App = () => (
+const Home = () => (
   <Wrapper>
     <HogeButton variant="contained">
       HOGE
     </HogeButton>
-    <Fonts />
   </Wrapper>
 );

-export default App;
+export default Home;


で、App.jsxはルーティングを定義するコンポーネントとして作り直す。

src/components/App.jsx:

import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import Home from './Home';
import Fonts from '../fonts';

const App = () => (
  <div>
    <Route exact path="/" render={() => <Redirect to="/home" />} />
    <Route exact path="/home" component={Home} />
    <Fonts />
  </div>
);

export default App;

こんな感じ。

webpack-dev-serverのHistory API Fallback

あとは、上に書いたような404エラーを防ぐために、webpack-dev-serverのHistory API Fallbackを有効にしてやる。

webpack.dev.js:

 const path = require('path');
 const webpackMerge = require('webpack-merge');
 const webpackCommon = require('./webpack.common.js');

 module.exports = webpackMerge(webpackCommon, {
   mode: 'development',
   devServer: {
     contentBase: path.join(__dirname, 'public'),
     compress: true,
     hot: true,
     port: 3000,
     publicPath: 'http://localhost:3000/',
+    historyApiFallback: true,
   },
 });

こうしておくと、/index.html以外にリクエストが来た場合、404エラーを返す代わりに/index.htmlを返してくれるようになる。


次回Code SplittingFlowJestEnzyme