react-router v3 to v4移行

昨日の続きです。

path-to-regexp

まずはMISC。

github.com

react-routerのv3 to v4マイグレーション中に遭遇。v3までreact-routerが自前にURLをパースしていたのに代えてライブラリを使うようになってました。Expressスタイルというらしいコロン前置のパラメータが入ったURLパターンを分解するところで使ってます。そのほか正規表現のパワーそのままに細かくURLから情報分解することとその逆ができるので、react-router 4.xの新APIであるmatchPathを使えば直接は触れないけど、これはよいライブラリ。備忘しておきます。

アーキテクチャの変更

To be candid, we were pretty frustrated with the direction we’d taken React Router by v2. We (Michael and Ryan) felt limited by the API, recognized we were reimplementing parts of React (lifecycles, and more), and it just didn’t match the mental model React has given us for composing UI. https://reacttraining.com/react-router/core/guides/philosophy/backstory

ドキュメントの冒頭から、作者の後悔の念を目にする…

v3までのreact-routerはアプリケーション唯一のRouterコンポーネントの子として複数のRouteコンポーネントをネストさせて画面をパッチワーク定義していました。例として以下の私のマイグレーション前v3アプリをご覧ください。

// v3: Routerの下にはreact-routerのWell-knownコンポーネントしか置けない
export default () => (
    <Provider store={store}>
        <Router history={syncHistoryWithStore(browserHistory, store)}>
            <Route path="/" component={Navigation}>
                <IndexRedirect to="list" />
                <Route path="list" component={BookList} />
                <Route path=":title/:vol/:page" component={BookReader} />
                <Route path="*" component={NotFound} />
            </Route>
        </Router>
    </Provider>
);

v3までのRouteを構造定義のみに使う仕様は分かりやすかったのですが、中の実装では結構難しいことをしていました。Routerが子孫のRouteツリー構造を一度描画して辿り(だから非react-routerコンポーネントを置けない)、URLと適宜マッチさせることでコンポーネントの組みたてを決定してから続いて実際の画面描画に行きます。この機能を専ら実現するRouterContext.jsのrender()のコードは難解で、それまでは関数型プログラミングの本でしか見たことなかったreduceRightの実践例を、私は初めて見て知りました。下記リンク先のソースコード53行目ね。

react-router/RouterContext.js at v3.0.5 · ReactTraining/react-router · GitHub

さてv4に書き換えると以下のようになります。最大の違いは、Routeのツリー中に、Routeじゃないコンポーネントをv4では書けるということ。「Navigation」コンポーネントの使い方が顕著に違い、とてもテンプレートエンジン的な色を濃くしてます。。。地味であんまり変わってないように見えるけど、この変化がデカいアーキテクチャ変更の結果なのですよー。

// v4: Routerの下になんでも書ける
export default () => (
    <Provider store={store}>
        <ConnectedRouter history={history}>
            <Navigation>
                <Switch>
                    <Redirect exact from="/" to="/list" />
                    <Route path="/list" component={BookList} />
                    <Route path="/book/:title/:vol/:page">
                        <BookReader />
                    </Route>
                    <Route render={() => <NotFound />} />
                </Switch>
            </Navigation>
        </ConnectedRouter>
    </Provider>
);

Redirectコンポーネントはexactプロパティが本日最新のv4.1.2のドキュメントにもソースコードにも認められないのですが、Switchで囲むとパスと一致確認して描画コンポーネントを決定する作業をSwitch側で行うことになり、その副作用でexactが効くようになります。

Routeの描画設定は4通りあり、1) componentプロパティにコンポーネントを指定する。上記の例で「BookList」の書き方。2) renderプロパティにコンポーネントを返す関数を書く。上記の例で「NotFound」の書き方。3) JSXの子ノードに描画内容を書く。これは上記例で「BookReader」の書き方。4) Lintに引っかかる場合がありますがタグ属性としてchildrenプロパティを書いてもいい。1から4へ順に優先され、重複している場合は丁寧に警告がでる。それぞれ利点があるも、3の子ノードで書くっていうのが実はもっとも柔軟でいいように思います。

// v4の書き方を整えたもの。どう書いてもいいケースならば、私はこれが仕様変更に強くていいように思う。
export default () => (
    <Provider store={store}>
        <ConnectedRouter history={history}>
            <Navigation>
                <Switch>
                    <Redirect exact from="/" to="/book" />
                    <Route exact path="/book">
                        <BookList />
                    </Route>
                    <Route path="/book/:title/:vol/:page">
                        <BookReader />
                    </Route>
                    <Route>
                        <div>
                            <h1>Not Found</h1>
                            <div>このように子ノードだと自由になんでも書ける</div>
                        </div>
                    </Route>
                </Switch>
            </Navigation>
        </ConnectedRouter>
    </Provider>
);

v4ではReactのコンポーネントシステムをシンプルに応用し、URLに反応してSwitchコンポーネントもしくはRouteコンポーネント毎に描画のOn/Offが制御されます。URLに反応して描画が制御されるコンポーネントで書き換えるって、結果を見るとあたりまえになって、なんで前からこうじゃなかったか作者が悔しがる気持ちもわかる。v4のコードは格段にシンプルになっていて、コードの意味を図るのに難儀なものはほとんどありません。アプリ作り手側もRouteにホストする描画コンポーネントへは新APIとして公開されたmatchPathで都度URLを処理した結果オブジェクトがプロパティとして渡されてくるので遷移情報に応じた機能はこれまでよりもシンプルに作りやすい。

しかし全画面再描画される

react-routerをv4へマイグレーションしても、ソースを書き換えるとブラウザの全画面リロードが走り、変更したReactコンポーネントの部分だけ再描画されるという風にはならない。調べるとまだreact-routerがらみのところはトリッキーで落ち着いていない様子。そこここで議論されているのだけど明快な答がない。引き続き試行錯誤していきます。

Jestとproductionバンドルのための対応

さてBabelの設定を変えてしまったためにJestが動かなくなってしまってます。Jestが動かないのは困るので対応します。

$ npm install -save-dev babel-plugin-transform-es2015-modules-commonjs
$ npm install -save-dev cross-env

Jestはブラウザではなくnode上で動くので、modulesシステムをCommon-JS(requireとか)にトランスパイルすることで対応します。このためのプラグイン環境変数切り替えで差し込むようにbabel.env設定を追加して変更。eslintはそのままで問題ないので、Jestだけbabel.env.JESTを設定してcross-envでBABEL_ENV=JESTを用いました。これでOK。

// package.json
{
  "scripts": {
    "build": "rimraf ./static/js/*; webpack -p --config webpack.config.production.js",
    "start": "webpack-dev-server",
    "test": "cross-env BABEL_ENV=JEST jest",
    "lint": "eslint --ext [.jsx,.js] js"
  },
  "babel": {
    "presets": [
      ["es2015", { "modules": false }],
      "react",
      "stage-1"
    ],
    "plugins": [
      "react-hot-loader/babel",
      "transform-decorators-legacy"
    ],
    "env": {
      "JEST": {
        "plugins": [
          "babel-plugin-transform-es2015-modules-commonjs"
        ]
      }
    }
  }
}

productionバンドルは別にwebpack.config.production.jsを作って対応。

webpack.js.org

気をとりなおして

react-router-reduxのマイグレーションも同時に行いましたので、そのへん後日にBlogへまとめます。この記事のv4例の「ConnectedRouter」はreact-router-redux v5のものです。さて、HMRの環境で気になるのは、バックグラウンドでコード書き換えを監視して自動ビルドを行うわけですから、実行速度やマシンへの圧迫度です。私のMBP15(Mid 2015)メモリ16GBは今ならちょっと古いぐらいですけど、ビルドしてるからって重くなったりとかまったくないです。