3行で言うと
- react-router はただのコンポーネント切り替え
- react-router-redux はロケーション情報をストアに同期するだけで、コンポーネントを表示する前にデータ取得する方法を提供しない
- redux-saga を使えば最高のルーティングが実現できるのでは? → redux-tower を開発中
リポジトリ: https://github.com/kuy/redux-tower
デモ:http://kuy.github.io/redux-saga-tower/blog/
はじめに
「redux-sagaでReduxのルーティングを制する(キリッ」とか言っちゃいましたが、まだライブラリをリリースできていませんし、実務で使っているわけでもありません。本当に制しているかどうかはこれから使っていって改良も加えつつって感じです。redux-saga を使う時点でそっと閉じる人も多いかと思いますが、自分が不満に感じていた部分はおおむね解消できそうです。共感できる方はぜひお試しください(リリース後に!)。
ただ、私はこれまで業務で本物のSPAを書いたことがありません。業務でSPAを作ることになるかもしてなくて、これまで自分が趣味で作ったサンプルに毛が生えた程度の小さなSPAでの経験と、ネット上に公開されている記事や発表資料にもとづいて考えを巡らせています。実際の問題にぶち当たらないとわからないことは少なくないため、他にもこんな懸念があるよ!とかありましたら優しく教えてください。
何が欲しいのか
最初に自分が求めるものが何なのか整理しておきます。
- [A] 指定した URL パターンにマッチする React コンポーネントが表示される
- [B] History API やハッシュベースなどの実装を選択可能
- [C] ロケーション情報を Redux のストアに同期して、下層の React コンポーネントで利用したい
- [D] データ取得やユーザ認証などが完了してから React コンポーネントを表示したい
- [E] ロジックの記述は非同期処理を同期的に書けて、途中で action を dispatch したり状態を取得したい(訳:redux-saga 使いたい)
- [F] react-router みたいな JSX を使ったルーティング定義は勘弁してくれ
[E] はなんかグチャグチャ書いちゃいましたけど、ようは Redux との親和性の高さが必要、ということです。
[F] は個人的な趣味なので気にしない人は何も問題ありません。
何が問題なのか
React は react-router の一人勝ち、Redux は雨後の筍のように大量にあります。大量すぎてほとんど試せてないのでとても限られた比較になってしまいますが、前のセクションで列挙した項目について1つずつ考えていきます。
react-router を使った場合 [A] [B] [D] 🤔
React でルーティングといったらまず思い浮かぶのは react-router でしょう。私を含めて文句を言う人はたくさんいますが、なんだかんだで結局これを使っています。大半の人が必要としているユースケースは満たしていて(いるはず)、多くの人に使われているという実績があります。こればっかりは一朝一夕でどうにかなるもんではないので重要な点です。
[C] とか [E] とか [F] について文句を言っても仕方がないので [D] の非同期処理について考えてみます。react-router には標準で onEnter
というフックが用意されていて、これを使うとそのルートで指定されているコンポーネントの表示タイミングを制御できます。さらに通信処理が失敗した場合などは遷移自体をキャンセルして別のルートにリダイレクト可能です。以下、公式のサンプルを引用します。
const userIsInATeam = (nextState, replace, callback) => {
fetch(...)
.then(response = response.json())
.then(userTeams => {
if (userTeams.length === 0) {
replace(`/users/${nextState.params.userId}/teams/new`)
}
callback();
})
.catch(error => {
// do some error handling here
callback(error);
})
}
<Route path="/users/:userId/teams" onEnter={userIsInATeam} />
引用元:react-router > API Reference > onEnter > サンプルコード
データ取得だけでなく、ユーザ認証などもこれを使えばいけそうですね。
一方、リクルートテクノロジーズさんは redux-async-loader というデータの非同期読み込みのためのライブラリを開発しています。これは大雑把に言うと、ルート設定で指定した React コンポーネントがマウントされても、データの読み込みが完了するまで render
が走らないように shouldComponentUpdate()
が常に false
を返すコンポーネントで対象コンポーネントをラップし、それによって表示中のコンテンツを維持しつつ、完了したら render
される仕組みを react-router のミドルウェアを使って実現しています(長い)。
ただ、公開されている資料を読んでみても各ライブラリが厳密にどのように役割分担しているのかはわかりませんでした。react-router の onEnter
フックと <ReduxAsyncLoaderContext>
による shouldComponentUpdate()
のブロックは役割的には被っていますが、東京 Node 学園祭 2016 での発表資料 によると onEnter
では分割されたコンポーネントの遅延読み込みのみ行っていて、それ以外は redux-async-loader による render
ブロックにまかせているように読み取れます。Redux を使っているアプリでプログレスバーの表示・非表示などのタイミングを合わせるには onEnter
ですべてを行うわけにはいかなかったのかな、と推測します。
引用元:React with Reduxによる大規模商用サービスの開発 by Naohiro Yoshida
react-router はつらい・・・と言われながらも方法は用意されていて、大規模開発での利用実績もあり [D] は満たしていると言えそうです。
参考にした資料は以下です。
react-router-redux を使った場合 [A] [B] [C] 🤔
react-router-redux は前述の react-router に渡す history オブジェクトをラップすることで、react-router と history の両方を Redux の制御下に置きます。それによってブラウザの戻るボタンを押したときでも、react-router が提供する <Link>
を利用したページ遷移でも、すべて Redux の action が dispatch されることでストアの状態が変化してから制御下の構成要素に反映されます。ちゃんと Redux してる感じがしていいですね。公式 README から引用すると次のような構造です。
history + store (redux) → react-router-redux → enhanced history → react-router
少し気になる点を言うと、react-router-redux の action はあくまで内部的に使用しているもので、ホスト側のアプリケーションが直接投げていいものではないのかな、という印象を受けました。
react-router に react-router-redux を導入することで [C] を満たせます。react-router-redux 単体で使うことはないので両方使って [A] [B] [C] [D] になりそうですが、onEnter
と Redux の相性はあまり良くない気がしています。 onEnter
で thunk を dispatch して完了後に callback を呼んで・・・みたいな感じで可能だとは思うんですけど、とりあえずテストやりにくそうです。
react-router-redux がストアに同期してくれるロケーション情報は routing.locationBeforeTransitions
から取得可能です。例えば
store.getState().routing.locationBeforeTransitions
には以下の情報が含まれています。
-
pathname
: クエリ文字列を除いたパス文字列 -
search
: クエリ文字列 -
hash
: ハッシュ -
state
: ロケーション変更時に指定した状態オブジェクト -
action
: history の action -
key
: ユニークID -
query
: クエリ文字列をパースしたもの
universal-router を使った場合 [A] [B] [D] [F] 🙂
universal-router はかなり欲しいものに近くて、Redux とか redux-saga を使っていないならこれで事足りそうです。async/await
で非同期処理を同期的に書けますし、ドキュメントには書いていませんが React コンポーネントを返せばそれがレンダリングされるようです。
難点を挙げるとすると react-router のときと同じなのですが、universal-router は Redux のことを意識して開発されているわけではないので、せっかく async/await
があっても Redux と一緒に使うと action を扱えなくて物足りなさを感じます。universal-redux-router を組み合わせて使うと決められた枠組みの中で action を dispatch できるみたいです。ただ、ソースコードを読む限りストアには react-router-redux のようにあらゆるロケーション情報を同期してくれないみたいでちょっと残念です。そんなわけで [C] は落としました。でも何気にスクロール問題に対応していたりして、どこまで使えるのか謎なところ。
Universal という名前の通り SSR に対応していますが、ダウンロード数や利用実績を考えると react-router と比べて不安は残ります。正直、onEnter
だけでやっていける気がしないよって人とか、何があっても泣かず、自分で改善しつつ使う覚悟があれば最高のルーターになりそうです。
redux-saga-router を使った場合 [B] [E] 😐
さて、redux-saga-router です。この「redux-saga-router」というパッケージ名を目にした瞬間、ついに最高のルーティングライブラリがっ・・・!と叫びそうになりました。ちょうどルーティングについてモヤモヤ考えているときだったのでいいタイミングだったわけです。次に示す README のサンプルコードを見てテンション上がりまくりだったんです。
const routes = {
// Method syntax
*'/users'() {
const users = yield call(fetchUsers);
yield put(setUsers(users));
},
// Or long form with function expression
'/users/:id': function* userSaga({ id }) {
const user = yield call(fetchUser, id);
yield put(setCurrentUser(user));
},
};
引用元:redux-saga-router > README.md > Usage > サンプルコードから抜粋
ただ、README を読み進めるにつれて徐々にテンションが下がっていきます。最後のサンプルコードを見てやっぱり react-router かーと思いました。
import { Router, Route, browserHistory as history } from 'react-router';
// {snip}
render((
<Router history={history}>
<Route path="/" component={App}>
<Route path="/users" component={Users} />
<Route path="/users/:id" component={User} />
</Route>
</Router>
), document.getElementById('main'));
引用元:redux-saga-router > README.md > React Router > サンプルコードから抜粋
つまり、どういうことかというと redux-saga-router というライブラリはロケーションに応じたデータ取得や認証処理を Saga で書けるようにしてくれますが、ページを構成する React コンポーネントの表示切り替えについては関知しません。というわけで [A] が落ちます。それらを react-router に丸投げした結果、ルート設定を二重に持つ羽目になり(JSX で書かないといけないので [F] が脱落)、Saga の処理を待ってから React コンポーネントを表示するというもっとも重要な [D] を落としています。
さらに redux-saga-router はロケーション情報をストアに同期しません。ということは react-router-redux を導入するしかない・・・? というわけで [C] もダメです。ちなみに Saga の引数として受け取れる情報にクエリ文字列は含まれていませんのでご注意を。/search?q=keyword
の ?q=keyword
は内部的には history.location
で取得できているのに握りつぶしています。 Why???
このような感じで redux-saga-router はロケーションに応じた非同期処理を実行するライブラリです。それ以外はいさぎよく、徹底的に別ライブラリに任せているのはメリットとも言えます。が、そういう方針なので PR を出すのはやめました。すごく惜しいところまで行ってるだけに悔しさがあります。
以上、めちゃくちゃ前置きが長くなりましたが、これらの悔しさをバネに仮想敵と戦いつつ作ったライブラリが redux-tower になります。
寄り道: monorouter
歴史を紐解くと的な感じで古いライブラリを見つけました。monorouter は好きなタイミングで render を呼び出せるので実現したいものは近い感じです。ただ、まだコールバックで書くのが当たり前だった時のものなので、今となっては少し書き方が古いように感じます。
redux-tower [A] [B] [C] [D] [E] [F] 😆 🎉
redux-tower は Redux アプリケーションにおいてロケーション変更が発生してから React コンポーネントが表示されるまでのすべてを Saga でコントロール可能にするルーティングライブラリです。
パッケージ名の由来は、うず高く積み重なった Saga の山のイメージ・・・ではなく、航空管制塔(Air Traffic Control Tower)です。ルーティングはするんですが、単なるコンポーネント切り替えではなく、その前後のルーティングに関わるすべてを一元管理するのでルーターという名前を使うのはやめました。redux-saga-router という名前が使えなくて悔しいなんて全然思ってません。
サンプルコード
React + Redux + redux-saga を想定しているため、どうしても構成要素が多くなってしまい、個々の要素の説明をしても全体の把握に繋がりにくいのが難点です。そこで構成要素の説明の前に1枚のファイルにまとめたサンプルコードを見てもらおうと思います。このサンプルは最小構成の redux-tower の利用例で、Index
ページと Tower
ページの2ページからなり、Tower
ページはクリックしたあと1秒間待ってから表示されます。
// ページコンポーネント(React コンポーネント)
function Navigation() {
return <ul>
<li><a href='/#/'>Index</a></li>
<li><a href='/#/tower'>Tower</a></li>
</ul>;
}
class Index extends Component {
render() {
return <div>
<h1>Index</h1>
<Navigation />
<p>Hi, here is index page.</p>
</div>;
}
}
class Tower extends Component {
render() {
return <div>
<h1>Tower</h1>
<Navigation />
<p>Here is tower page. You waited a while for loading this page.</p>
</div>;
}
}
// ルート設定(Saga)
const routes = {
'/': Index,
*'/tower'() {
yield call(delay, 1000);
yield put(actions.changePage(Tower));
}
};
// History
const history = createHashHistory();
// Saga(アプリ初期化時に呼び出される Saga)
function* rootSaga() {
// ルーター Saga の起動
yield fork(routerSaga, history, routes);
}
// Reducer
const reducer = combineReducers(
{ router: routerReducer }
);
const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, {}, applyMiddleware(
sagaMiddleware, logger()
));
sagaMiddleware.run(rootSaga);
ReactDOM.render(
<Provider store={store}>
<Router />
</Provider>,
document.getElementById('container'));
ページコンポーネント
ここは特に説明は不要と思いますが一応。見たまんまの素直な React コンポーネントで、<Navigation>
をくくりだして <Index>
と <Tower>
から使っています。今回は簡単のためハッシュベースの history インスタンスを使用しています。History APIを使用する場合は <Link>
コンポーネントを使ってください。
ルート設定
ロケーションのパターン文字列とそれに対応するアクションを Saga で書きます。redux-saga-router とは異なり、明示的に表示したい React コンポーネントを CHANGE_PAGE
action として put
で dispatch しないとコンポーネントの切り替えは起こりません。Index
ページの表示は特に何も非同期処理を挟まないため、Saga を書かずにそのまま React コンポーネントを指定しています。一方で Tower
ページの方は何らかの非同期処理をシミュレートするために delay
を使ってロケーション変更が発生してからページが表示されるまで1秒間遅らせています。実際にはここで fetch
などを使って通信処理を実行して、取得したデータを action に詰めて dispatch し、 Redux のストアに反映してからコンポーネントを表示します。通信処理でエラーが発生した場合はそのまま何もせずに終わることもできますし、通信処理の失敗をリカバーする処理を行ってから別のコンポーネントを表示させることもできます。
さらに文字列を渡すと別のルートへのリダイレクトになります。例えば次のコードのように特定のパスのときにパラメータを埋めたURLにリダイレクトできます。
const routes = {
'/posts/:id': function* postsShowPage({ params: { id } }) {
yield call(loadPost, id);
yield put(actions.changePage(PostsShow));
},
'/about': '/posts/2',
};
history オブジェクト
History APIかハッシュベースか選べます。このあたりは react-router と同じなので割愛。
ルーター Saga の起動
redux-tower が提供する Saga の起動に必要な history オブジェクトとルート設定が揃いました。特にルート Sagaで起動しないとならないわけではありませんが、わかりやすいのでおすすめです。fork
せずに call
すると戻ってこないので注意。
Reducer
redux-tower は Redux 管理下の React コンポーネントがロケーション情報を元にビューを生成できるようにストアに同期しています。今回のサンプルでは活用していませんが、例えばナビゲーションメニューのどのメニューアイテムがアクティブかどうかを決めるのににも必要ですし、クエリ文字列の ?page=2
を使ってページネーションを生成したりできます。
基本的な動作
全体のコードと構成要素の説明を終えたので流れを整理してみます。
- history でロケーション変更イベント発生
- ルーター Saga が channel でイベントを受け取ってルート設定からマッチするアクション Sagaを決定
- アクション Saga を呼び出して、完了したら再びイベント待ちに戻る
書き出す意味あったのかというくらい単純で、以下がコードです。
while (true) {
const { location } = yield take(channel); // history のイベント待ち
yield call(handler, location); // handler でマッチングとアクション Saga の呼び出し
}
このように現時点では非常にナイーブな実装になっておりまして、今後はキャンセルだったり、無限ループに陥らないようにしたり、本気の Saga を書いた時にどうなるかとか、連続的にロケーション変更が起こった場合どうなるかとか、諸々考えていかないといけません。
Real World Example
redux-tower のリポジトリにはもうちょっと現実的なサンプルアプリケーションを含めています。実装は常にこのサンプルをベースに試しているので、API が固まるまで当面はここで実現されていることができることのすべてになります。
Blog サンプル: https://github.com/kuy/redux-tower/tree/master/examples/blog
Semantic UI の React 版を使っています。ページ読み込み時に(わかりにくい)ローディング表示をしてみたり、ページネーションしたり、検索機能があったり、まだまだ足りないところはありますが、それっぽい感じのことを実現しています。
足りない部分
- ロケーション変更をキャンセルするか、1つ前のロケーションに戻る方法
- ルーティングだけするように促す何か
- スクロール問題
- SSR
最後に
redux-tower を作る前には気づいてなかったんですが、universal-router のコードとか、redux-tower のルート設定を見てみると、サーバーサイドの koa のコードとそっくりなんですよね。サーバ側のロジックをクライアント側に持ってこようとかそういう意味ではなくて、クライアント側にもユーザが快適に使えるアプリケーションを構築するためにロジックを含んだルーティングが今後は必要になってくるのかな、と感じました。redux-tower のリリース頑張ります。