blog.euxn.me

react-router v4 でFlux アプリケーションをHot Module Replacement する

2016-12-06 Tue.

この記事は React Advent Calendar 2016 の 6 日目の記事です。 (アドベントカレンダーに紐づけるの忘れたまま日付超えてしまいました……ごめんなさい!)

react-router v4 が良さそうという話を聞き、flux/utils で作られたアプリケーションを書き換えたので、特徴を簡単に説明します。

TL;DR

  • react-router v4 はだいぶわかりやすい感じにはなっているものの、資料はまだ少ない
  • react-routerHot Module Replacement する場合は構成を意識する必要あり
  • 実装したサンプルはこちら yutaszk/flux-react-router-v4-hmr-example

1. はじめに

2. React Router v4 での色々な書き方

上で紹介した投稿にもありますが、React Router はかなり大きく、その書き方に縛られる部分が結構あるように感じられました。 v4 では props.children を使って子 Component のレンダリング場所を指定したり、画面の遷移にhistory を渡す必要があったり、というのがなくなり、素直に書けるようになっているように感じられます。 ただし、現状ではドキュメントやサンプルとなる資料が少なかったので、いくつかの実装例をコードを交えて説明します。

共通部分(ナビゲーションバー/サイドバー)

react-router v4 では以下のように素直に書けます。

1<BrowserRouter>
2 <div>
3 <nav className="navbar navbar-inverse">
4 <div className="container">
5 <Link className="navbar-brand" to="/">SampleApp</Link>
6 <ul className="nav navbar-nav">
7 <li><Link to="/menu1">Menu1</Link></li>
8 <li><Link to="/menu2">Menu2</Link></li>
9 <li><Link to="/menu3">Menu3</Link></li>
10 </ul>
11 </div>
12 </nav>
13 <div className="container">
14 <Match exactly pattern="/" component={Top} />
15 <Match exactly pattern="/menu1" component={Menu1} />
16 <Match exactly pattern="/menu2" component={Menu2} />
17 <Match exactly pattern="/menu3" component={Menu3} />
18 </div>
19 </div>
20</BrowserRouter>

これでナビゲーションバーはそのままに、内部(ここでは .containerdiv の内側)だけが差し変わる形式になります。 親子関係を Route に記述して props.children として渡していた v3 に比べると、より宣言的、直観的に書けるようになっていると思います。

flux の Container を作る

BrowserRouter の中は Stateless Functional Component でなければならないため、BrowserRouter の外で定義します。

1class Root extends React.Component {
2 constructor(props) {
3 super(props);
4 }
5 static getStores() {
6 return [
7 BookStore,
8 ];
9 }
10 static calculateState() {
11 return {
12 appState: {
13 books: BookStore.getState()
14 },
15 };
16 }
17
18 render() {
19 return (
20 <BrowserRouter>
21 <div className="container">
22 <Match exactly pattern="/menu1" component={Menu1} />
23 <Match exactly pattern="/menu2" component={Menu2} />
24 <Match exactly pattern="/menu3" component={Menu3} />
25 </div>
26 </BrowserRouter>
27 );
28 }
29}
30
31const App = Container.create(Root);
32export default App;

props を渡す

flux を使っていると、アプリケーション全体の state を子要素に渡す実装になると思います。 公式のドキュメントには props を渡す方法についてのわかりやすい記述がありませんが、以下のように render を使うことで実装できます。

1<Match
2 exactly pattern="/books"
3 render={() => <BookList appState={this.state.appState} />}
4/>

path パラメータを取得する

URL から path パラメータを取得するには、 Matchpattern/:id といった形で指定します。 paramsprops として渡すため上記の通り render を使用します。 render に渡す匿名関数に渡される値の中の params を渡すことで実装できます。 匿名関数に渡される値は params の他に、 isExact location pathname patternが取得できます。

1<Match
2 pattern="/books/detail/:id"
3 render={({params}) => <BookDetail appState={this.state.appState} params={params} />}
4/>

以下の用に渡した先の Component から取得できます。

1const book =
2 this.props.appState.books.find((b) => b.id === +this.props.params.id) || {};

js の処理内で画面を遷移させる

BrowserRouter 直下にある router を遷移処理を行いたい Component まで渡します。

1<BrowserRouter>
2 {({ router }) => (
3 <div className="container">
4 <Match
5 pattern="/books/new"
6 render={() => <BookCreate appState={this.state.appState} router={router} />}
7 />
8 </div>
9 )}
10</BrowserRouter>

props として渡した routertransitionTo() を使用することで画面遷移ができます。

1handleForm(ev) {
2 ev.preventDefault();
3 BookAction.create(this.state);
4 this.props.router.transitionTo("/books");
5}

BrowserRouter 直下では router の他に、 action location (後述) が取得できます。

現在の URL を取得する

現在の URL に応じてナビゲーションバーのアクティブを変更することも、以下のように location から pathname を取得することで実装できます。

1<BrowserRouter>
2 {({ location }) => (
3 <div>
4 <nav className="navbar navbar-inverse">
5 <div className="container">
6 <ul className="nav navbar-nav">
7 <li className={location.pathname === '/menu1' ? 'active' : ''}>
8 <Link to="/menu">Menu1</Link>
9 </li>
10 <li className={location.pathname === '/menu2' ? 'active' : ''}>
11 <Link to="/menu2">Menu2</Link>
12 </li>
13 <li className={location.pathname === '/menu3' ? 'active' : ''}>
14 <Link to="/menu3">Menu3</Link>
15 </li>
16 </ul>
17 </div>
18 </nav>
19 <div className="container">
20 <Match exactly pattern="/menu1" component={Menu1} />
21 <Match exactly pattern="/menu2" component={Menu2} />
22 <Match exactly pattern="/menu3" component={Menu3} />
23 </div>
24 </div>
25 )}
26</BrowserRouter>

3. Hot Module Replacement への対応

react-router v4 を使って動かすだけなら上までで動きますが、 HMR に対応させるのにも手間が掛かったので、別項目として書きます。

webpack のに plugin を使うように指定したり、 babel の plugin を指定したりが必要になります。 本番ビルド向けの webpack.config に加えて、こんな感じに設定を追加してあります。

以下でそれぞれ必要な手順を簡単に説明します。

webpack.config.dev.js の設定

HRM 用の plugin 等を追記した開発用の webpack の config は以下の通りです。

1const path = require("path");
2const webpack = require("webpack");
3const HtmlWebpackPlugin = require("html-webpack-plugin");
4
5module.exports = {
6 entry: [
7 "react-hot-loader/patch",
8 `webpack-dev-server/client?http://${devServerHost}:${devServerPort}`,
9 "webpack/hot/only-dev-server",
10 "./src/index",
11 ],
12
13 plugins: [
14 new webpack.HotModuleReplacementPlugin(),
15 new webpack.NamedModulesPlugin(),
16 new HtmlWebpackPlugin({
17 hash: false,
18 template: "./src/index.html",
19 }),
20 new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /nb/),
21 ],
22
23 devtool: "inline-source-map",
24
25 output: {
26 path: path.resolve(__dirname, "public"),
27 publicPath: "/",
28 filename: "bundle.js",
29 },
30
31 module: {
32 loaders: [
33 {
34 test: /\.jsx?$/,
35 exclude: /node_modules/,
36 loader: "babel",
37 },
38 ],
39 },
40
41 resolve: {
42 extensions: ["", ".js", ".jsx"],
43 },
44};

.babelrc への plugin 指定

react-hot-loader/babel plugin が必要なので、 .babelrc で指定します。

1{
2 "presets": ["es2015", "react"],
3 "plugins": ["react-hot-loader/babel"]
4}

webpack-dev-server の起動

webpack-dev-server を起動させる際は、オプションが記述されている devserver.js ファイルを用意し、これを起動します。

1const webpack = require("webpack");
2const WebpackDevServer = require("webpack-dev-server");
3
4const config = require("./webpack.config.dev.js");
5
6new WebpackDevServer(webpack(config), {
7 publicPath: config.output.publicPath,
8 contentBase: "src",
9 inline: true,
10 hot: true,
11}).listen(8080, "localhost", (err) => {
12 if (err) return console.log(err);
13});

contentBase に指定した /src を起点として開発サーバが起動します。

React Component への設定

これだけだと自動で更新されないので、アプリ全体を AppContainer 以下に置く必要があります。 ここでは entry である index.js を以下のようにしています。

1"use strict";
2
3import React from "react";
4import ReactDOM from "react-dom";
5import { AppContainer } from "react-hot-loader";
6
7import App from "./components/app";
8
9ReactDOM.render(<App />, document.querySelector("#app"));
10
11// For Development
12if (module.hot) {
13 module.hot.accept("./components/app", () => {
14 const NextApp = require("./components/app").default;
15 ReactDOM.render(
16 <AppContainer>
17 <NextApp />
18 </AppContainer>,
19 document.querySelector("#app")
20 );
21 });
22}

プロダクションビルドでは自動リロードの機能が動かないようになっていますが、ちゃんとファイルから除きたかったらいろいろ工夫する必要がありそうです。 (イケてる解決方法があれば教えてください……)

これで react-router でも画面が差分更新されて開発が効率化できます。

4. 最後に

本文中で説明に出したコードを用いた実装は以下にあります。 npm start を実行すると Hot Module Replacement が動く開発サーバが起動するので、是非試してみてください。

yutaszk/flux-react-router-v4-hmr-example

明日のReact Advent Calendar 2016 7 日目は @amagitakayosi さんによる React 本体のコード解説 です。

5. 参考