react-routerとreact-router-reduxのサンプルと環境構築

#`react-router`と`react-router-redux`のサンプルを作成し、`Webpack`の環境を構築する
`react-router`が4.2で、`react-router-redux`についてはα版の5.0がリリースされていた。
[github](https://github.com/ReactTraining/react-router)から入手して簡単な動作検証をしたかったが、なかなか動作させることができなかったので、簡単な動作確認ができる環境構築を備忘録として記載（今後随時更新予定）。

##設定ファイルと`index.html`
`package.json`、`webpack.config.js`、`index.html`は`react-router`と`react-router-redux`のサンプルで共通で利用するようにした。

###`package.json`
まずは`package.json`について。
`.babelrc`と`.eslint`ファイルは用意せず、`package.json`に記載した。必要に応じて、
`// eslint-disable-line`（1行の場合）
や
`/* eslint-disable */`（ファイル全体）
をJSファイルに指定する（[参考](https://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments)）。
また、依存関係については、`dependencies`と`devDependencies`のいずれかに記載があれば良い。
`react-router-redux`を利用しない場合、`redux`関連の依存を外す。

```json:package.json
{
  "name": "react-router-sample",
  "version": "0.1.0",
  "description": "sample",
  "repository": "",
  "license": "MIT",
  "authors": [
    "Author Name"
  ],
  "babel": {
    "presets": [
      "es2015",
      "es2016",
      "es2017",
      "react",
      "stage-2"
    ]
  },
  "eslintConfig": {
    "parser": "babel-eslint",
    "env": {
      "node": true,
      "es6": true,
      "jest": true
    },
    "plugins": [
      "import",
      "react"
    ],
    "extends": [
      "eslint:recommended",
      "plugin:import/errors",
      "plugin:react/recommended"
    ],
    "rules": {
      "prefer-arrow-callback": 2,
      "react/display-name": 0,
      "semi": [
        2,
        "never"
      ]
    }
  },
  "dependencies": {
    "webpack": "^3.8.1",
    "webpack-dev-server": "^2.9.4"
  },
  "devDependencies": {
    "history": "^4.7.2",
    "prop-types": "^15.6.0",
    "react-json-tree": "^0.11.0",
    "react-router": "^4.2.0",
    "react-router-redux": "^5.0.0-alpha.8",
    "babel-cli": "^6.26.0",
    "babel-core": "^6.26.0",
    "babel-eslint": "^8.0.1",
    "babel-jest": "^21.2.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-react-remove-prop-types": "^0.4.10",
    "babel-preset-env": "^1.6.1",
    "babel-preset-es2015": "^6.14.0",
    "babel-preset-es2016": "^6.24.1",
    "babel-preset-es2017": "^6.24.1",
    "babel-preset-react": "^6.5.0",
    "babel-preset-stage-0": "^6.24.1",
    "babel-preset-stage-1": "^6.24.1",
    "babel-preset-stage-2": "^6.24.1",
    "babel-preset-stage-3": "^6.24.1",
    "create-react-class": "^15.6.2",
    "css-loader": "^0.28.7",
    "eslint": "^4.9.0",
    "eslint-config-rackt": "^1.1.1",
    "eslint-plugin-import": "^2.2.0",
    "eslint-plugin-react": "^7.4.0",
    "gzip-size": "^4.0.0",
    "jest": "^21.2.1",
    "pretty-bytes": "^4.0.2",
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "react-redux": "^5.0.6",
    "react-router-dom": "^4.2.2",
    "react-test-renderer": "^16.0.0",
    "redux": "^3.6.0",
    "rollup": "^0.50.0",
    "rollup-plugin-babel": "^3.0.2",
    "rollup-plugin-uglify": "^2.0.1",
    "style-loader": "^0.19.0",
    "webpack-manifest-plugin": "^1.3.2",
    "webpack-merge": "^4.1.1",
    "html-webpack-plugin": "^2.30.1",
    "chunk-manifest-webpack-plugin": "^1.1.2",
    "extract-text-webpack-plugin": "^3.0.2",
    "js-yaml": "^3.10.0",
    "path-complete-extname": "^0.1.0",
    "compression-webpack-plugin": "^1.0.1"
  },
  "scripts": {
    "start": "webpack-dev-server --history-api-fallback --no-info --open"
  }
}
```

`package.json`のあるディレクトリで`npm install`または、`yarn install`を実行し、インストールする。

###`webpack.config.js`
`webpack-dev-server`で利用することを前提にしている。

```js:webpack.config.js
const path = require('path');

module.exports = {
  entry: './app.js',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  module: {
    loaders: [{
      test: /\.js$/,
      loader: 'babel',
      exclude: /node_modules/,
      include: __dirname,
      options: {
        presets: [
          ['env', {'modules': false}]
        ]
      }
    }]
  }
}

var src = path.join(__dirname, '..', '..', 'src')
var fs = require('fs')
if (fs.existsSync(src)) {
  // Use the latest src
  module.exports.resolve = { alias: { 'react-router-redux': src } }
  module.exports.module.loaders.push({
    test: /\.js$/,
    loaders: ['babel'],
    include: src
  });
}
```

###`index.html`
起動元となる`html`ファイル。

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <title>react-router-redux basic example</title>
    <meta charset="utf8"/>
  </head>
  <body>
    <div id="mount"></div>
    <script src="/bundle.js"></script>
  </body>
</html>
```

##`react-router`のサンプル
まずは`react-router`のサンプルから。

###ディレクトリ構成
ディレクトリ構成は下図。

<img width="217" alt="スクリーンショット 2017-11-02 0.33.32.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/5f96418e-784a-bdd6-73e3-e4006c4e2d7b.png">


###サンプル
下記の`app.js`を作成する。

```js:app.js
import React from 'react'
import ReactDOM from 'react-dom'
import { render } from 'react-dom'
import { Router, Route } from 'react-router'
import { Link } from 'react-router-dom'
import createHistory from 'history/createBrowserHistory'

const history = createHistory()

export class App extends React.Component {
  render() {
    return (
      <div>
        <h1>App</h1>
        <ul>
          <li><Link to="/about">About</Link></li>
          <li><Link to="/inbox">Inbox</Link></li>
          <li><Link to="/messages/10">Message</Link></li>
        </ul>
      </div>
    )
  }
}

export class About extends React.Component {
  render() {
    return <h3>About</h3>
  }
}

export class Inbox extends React.Component {
  render() {
    return (
      <div>
        <h2>Inbox</h2>
        {this.props.children || "Welcome to your Inbox"}
      </div>
    )
  }
}

export class Message extends React.Component {
  render() {
    return <h3>Message {this.props.match.params.id}</h3>
  }
}

ReactDOM.render(
  <Router history={history}>
    <div>
      <Route exact path="/" component={ App }/>
      <Route path="/about" component={About} />
      <Route path="/inbox" component={Inbox} />
      <Route path="/messages/:id" component={Message} />
    </div>
  </Router>,
  document.getElementById('mount')
)
```

###実行とデモ
`package.json`のあるディレクトリで`yarn run start`または`npm run start`を実行するとブラウザが起動し、下記画面が表示される。

<img width="219" alt="スクリーンショット 2017-11-02 0.59.54.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/426589e7-e426-7c70-cfc2-2f6ac4ed0b76.png">

それぞれのリンクをクリックすると下記のように表示される。

<img width="136" alt="スクリーンショット 2017-11-02 1.00.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/a2654a3e-3a13-3f60-59a3-8e2edf438766.png">

<img width="229" alt="スクリーンショット 2017-11-02 1.00.13.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/e93890ad-17c6-bc79-414b-87b057e342d2.png">

<img width="224" alt="スクリーンショット 2017-11-02 1.00.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/6e75b815-eea6-5fe8-3716-81b50b959f84.png">

##`react-router-redux`のサンプル
###ディレクトリ構成
ディレクトリ構成は下図。

<img width="261" alt="スクリーンショット 2017-11-02 1.19.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/c339ad89-d9d0-9fd4-6ee6-3a2a2184e228.png">


###サンプル
サンプルは以下。

```js:states/constants.js
export const INCREASE = 'INCREASE'
export const DECREASE = 'DECREASE'
```

```js:actions/count.js
import { INCREASE, DECREASE } from '../states/constants'

export function increase(n) {
  return {
    type: INCREASE,
    amount: n
  }
}

export function decrease(n) {
  return {
    type: DECREASE,
    amount: n
  }
}
```

```js:reducers/count.js
import { INCREASE, DECREASE } from '../states/constants'

const initialState = {
  number: 1
}

export function countReducer(state = initialState, action) {
  if(action.type === INCREASE) {
    return { number: state.number + action.amount }
  }
  else if(action.type === DECREASE) {
    return { number: state.number - action.amount }
  }
  return state
}
```

```js:components/App.js
import React, { Component } from 'react'
import { Link } from 'react-router-dom'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as myActions from '../actions/count'

class AppComponent extends Component {
  render() {
    return (
      <div>
        <header>
          Links:
          <ul>
            <li><Link to="/count">count</Link></li>
            <li><Link to="/display">display</Link></li>
          </ul>
        </header>
      </div>
    )
  }
}

const mapState = (state) => ({
  count: state.count
})

const mapDispatch = (dispatch) => ({
  actions: bindActionCreators(myActions, dispatch)
})

export const App = connect(mapState, mapDispatch)(AppComponent)
```

```js:components/Count.js
import React from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as myActions from '../actions/count'

class CountComponent extends React.Component {
  render() {
    const { count, actions } = this.props
    return (
      <div>
        Some state changes:
        { count.number }
        <button onClick={() => actions.increase(1)}>Increase</button>
        <button onClick={() => actions.decrease(1)}>Decrease</button>
      </div>
    )
  }
}

const mapState = (state) => ({
  count: state.count
})

const mapDispatch = (dispatch) => ({
  actions: bindActionCreators(myActions, dispatch)
})

export const Count = connect(mapState, mapDispatch)(CountComponent)
```

```js:components/Display.js
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as myActions from '../actions/count'

class DisplayComponent extends Component {
  render() {
    const { count, actions } = this.props

    return (
      <div style={{ width: "300px", marginLeft: "200px" }} >
      { count.number }
      </div>
    )
  }
}

const mapState = (state) => ({
  count: state.count
})

const mapDispatch = (dispatch) => ({
  actions: bindActionCreators(myActions, dispatch)
})

export const Display = connect(mapState, mapDispatch)(DisplayComponent)
```

```js:components/index.js
export { App } from './App'
export { Count } from './Count'
export { Display } from './Display'
```

```js:app.js
import React from 'react'
import ReactDOM from 'react-dom'

import { createStore, combineReducers, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'

import createHistory from 'history/createBrowserHistory'
import { Route } from 'react-router'

import { ConnectedRouter, routerReducer, routerMiddleware } from 'react-router-redux'

import { App, Count, Display } from './components'
import { countReducer } from './reducers/count'

const history = createHistory()
const middleware = routerMiddleware(history)


const store = createStore(
  combineReducers({
    count: countReducer,
    routing: routerReducer
  }),
  applyMiddleware(middleware)
)

ReactDOM.render(
  <Provider store={store}>
    <ConnectedRouter history={ history }>
      <div>
        <Route exact path="/" component={ App }/>
        <Route path="/count" component={ Count }/>
        <Route path="/display" component={ Display }/>
      </div>
    </ConnectedRouter>
  </Provider>,
  document.getElementById('mount')
)
```

###実行とデモ
`package.json`のあるディレクトリで`yarn run start`または`npm run start`を実行するとブラウザが起動し、下記画面が表示される。

<img width="181" alt="スクリーンショット 2017-11-02 1.25.50.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/2b648294-0839-5650-d4d1-dfa245590235.png">

"count"をクリックして、ボタン操作で数値を変更する。

<img width="347" alt="スクリーンショット 2017-11-02 1.26.07.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/66906f06-d333-5816-7b14-5620929cbbcf.png">

バックキー等で戻って、"display"をクリックすると、変更された数値が表示される。

<img width="335" alt="スクリーンショット 2017-11-02 1.26.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/4953b625-9424-21db-6b86-41f4d1ff4f0b.png">

###補足
`Link`を利用しなくても、`this.props.history.push('/count')`を実行することで、画面遷移ができる。

##`react-router-redux`の`Webpack`開発環境を構築する
`Rails 5.1`で`React`環境を構築した際、`Webpack`の設定が非常に便利だったので、`react-router-redux`の開発環境にも反映する。
構築方法にあたり、[こちら](`react-router-redux`の開発環境)に記載されている内容が参考になった。

###ディレクトリ構成
「`react-router-redux`のサンプル」のディレクトリ構成を下記のように変更する。
`actions`、`components`、`reducers`、`states`、`app.js`を`src`ディレクトリを作成し、その配下に移動。
また、`dst`ディレクトリを作成。

<img width="307" alt="スクリーンショット 2017-11-04 11.35.20.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/85140404-a32a-5c24-ac2b-869fa6f14883.png">

###実行ファイル
`bin`ディレクトリを作成し、`ruby2.x`で実行ファイルを作成する。

```rb:bin/webpack`
#!/usr/bin/env ruby
$stdout.sync = true

require "shellwords"
require "yaml"

ENV["NODE_ENV"] = "production"
NODE_ENV = "production"

APP_PATH          = File.expand_path("../", __dir__)
NODE_MODULES_PATH = File.join(APP_PATH, "node_modules")
WEBPACK_CONFIG    = File.join(APP_PATH, "config/webpack/#{NODE_ENV}.js")

unless File.exist?(WEBPACK_CONFIG)
  puts "Webpack configuration not found."
  puts "Please run bundle exec rails webpacker:install to install webpacker"
  exit!
end

newenv  = { "NODE_PATH" => NODE_MODULES_PATH.shellescape }
cmdline = ["yarn", "run", "webpack", "--", "--config", WEBPACK_CONFIG] + ARGV

Dir.chdir(APP_PATH) do
  exec newenv, *cmdline
end
```

```rb:bin/webpack-dev-server
#!/usr/bin/env ruby
$stdout.sync = true

require "shellwords"
require "yaml"

ENV["NODE_ENV"] = "development"

APP_PATH          = File.expand_path("../", __dir__)
CONFIG_FILE       = File.join(APP_PATH, "config/webpacker.yml")
NODE_MODULES_PATH = File.join(APP_PATH, "node_modules")
WEBPACK_CONFIG    = File.join(APP_PATH, "config/webpack/development.js")

def args(key)
  index = ARGV.index(key)
  index ? ARGV[index + 1] : nil
end

begin
  dev_server = YAML.load_file(CONFIG_FILE)["development"]["dev_server"]

  DEV_SERVER_HOST = "http#{"s" if args('--https') || dev_server["https"]}://#{args('--host') || dev_server["host"]}:#{args('--port') || dev_server["port"]}"

rescue Errno::ENOENT, NoMethodError
  puts "Webpack dev_server configuration not found in #{CONFIG_FILE}."
  exit!
end

newenv = {
  "NODE_PATH" => NODE_MODULES_PATH.shellescape,
  "ASSET_HOST" => DEV_SERVER_HOST.shellescape
}.freeze

cmdline = ["yarn", "run", "webpack-dev-server", "--", "--progress", "--color", "--config", WEBPACK_CONFIG] + ARGV

Dir.chdir(APP_PATH) do
  exec newenv, *cmdline
end
```

###設定ファイル
`config`ディレクトリを作成、その配下に`webpack.config.js`に相当する設定ファイルを作成する。

```js:config/webpack/loaders/assets.js
const { env, publicPath } = require('../configuration.js')

module.exports = {
  test: /\.(jpg|jpeg|png|gif|svg|eot|ttf|woff|woff2)$/i,
  use: [{
    loader: 'file-loader',
    options: {
      publicPath,
      name: env.NODE_ENV === 'production' ? '[name]-[hash].[ext]' : '[name].[ext]'
    }
  }]
}
```

```js:config/webpack/loaders/css.js
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const { env } = require('../configuration.js')

module.exports = {
    test: /\.(css)$/i,
    use: ExtractTextPlugin.extract({
        fallback: 'style-loader',
        use: [
            { loader: 'css-loader', options: { minimize: env.NODE_ENV === 'production' } }
        ]
    })
}
```

```js:config/webpack/loaders/react.js
module.exports = {
  test: /\.(js|jsx)?$/,
  exclude: /node_modules/,
  loader: 'babel-loader',
  query: {
    presets: ['es2015', 'react', 'stage-2']
  }
}
```

```js:config/webpack/configuration.js
const { join, resolve } = require('path')
const { env } = require('process')
const { safeLoad } = require('js-yaml')
const { readFileSync } = require('fs')

const configPath = resolve('config', 'webpacker.yml')
const loadersDir = join(__dirname, 'loaders')
const settings = safeLoad(readFileSync(configPath), 'utf8')[env.NODE_ENV]

const output = {
  path: resolve('./dst', settings.public_output_path),
  publicPath: `http://${settings.dev_server.host}:${settings.dev_server.port}/${settings.public_output_path}`
}

module.exports = {
  settings,
  env,
  loadersDir,
  output
}
```

```js:config/webpack/development.js
const merge = require('webpack-merge')
const sharedConfig = require('./shared.js')
const { settings, output } = require('./configuration.js')

module.exports = merge(sharedConfig, {
  devtool: 'cheap-eval-source-map',

  stats: {
    errorDetails: true
  },

  output: {
    pathinfo: true
  },

  devServer: {
    clientLogLevel: 'none',
    https: settings.dev_server.https,
    host: settings.dev_server.host,
    port: settings.dev_server.port,
    contentBase: output.path,
    publicPath: output.publicPath,
    compress: true,
    headers: { 'Access-Control-Allow-Origin': '*' },
    historyApiFallback: true,
    watchOptions: {
      ignored: /node_modules/
    }
  }
})
```

```js:config/webpack/production.js
/* eslint global-require: 0 */

const webpack = require('webpack')
const merge = require('webpack-merge')
const CompressionPlugin = require('compression-webpack-plugin')
const sharedConfig = require('./shared.js')

module.exports = merge(sharedConfig, {
  output: { filename: '[name]-[chunkhash].js' },
  devtool: 'source-map',
  stats: 'normal',

  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      minimize: true,
      sourceMap: true,

      compress: {
        warnings: false
      },

      output: {
        comments: false
      }
    }),

    new CompressionPlugin({
      asset: '[path].gz[query]',
      algorithm: 'gzip',
      test: /\.(js|css|html|json|ico|svg|eot|otf|ttf)$/
    })
  ]
})
```

```js:config/webpack/shared.js
/* eslint global-require: 0 */
/* eslint import/no-dynamic-require: 0 */

const webpack = require('webpack')
const ManifestPlugin = require('webpack-manifest-plugin')
const ChunkManifestPlugin = require('chunk-manifest-webpack-plugin')
const { basename, dirname, join, relative, resolve } = require('path')
const { sync } = require('glob')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const extname = require('path-complete-extname')
const { env, settings, output, loadersDir } = require('./configuration.js')
const HtmlWebpackPlugin = require('html-webpack-plugin')

const extensionGlob = `**/*{${settings.extensions.join(',')}}*`
const entryPath = join(settings.source_path, settings.source_entry_path)
const packPaths = sync(join(entryPath, extensionGlob))

module.exports = {
  entry: packPaths.reduce(
    (map, entry) => {
      const localMap = map
      const namespace = relative(join(entryPath), dirname(entry))
      localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry)
      return localMap
    }, {}
  ),

  output: {
    filename: '[name].js',
    path: output.path,
    publicPath: output.publicPath
  },

  module: {
    rules: sync(join(loadersDir, '*.js')).map(loader => require(loader))
  },

  plugins: [
    new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(env))),
    new ExtractTextPlugin(env.NODE_ENV === 'production' ? '[name]-[hash].css' : '[name].css'),
    new ManifestPlugin(),
    new ChunkManifestPlugin({
      filename: 'chunk-manifest.json',
      manifestVariable: 'webpackManifest',
    }),
    new HtmlWebpackPlugin({
      hash: true,
      chunks: ['app'],
      filename: 'index.html',
      template: join(entryPath, '/index.template.ejs'),
      inject: 'body',
    })
  ],

  resolve: {
    extensions: settings.extensions,
    modules: [
      resolve(settings.source_path),
      'node_modules'
    ]
  },

  resolveLoader: {
    modules: ['node_modules']
  }
}
```

```yaml:config/webpacker.yml
default: &default
  source_path: src
  source_entry_path: ""
  public_output_path: ""

  extensions:
    - .js
    - .jsx
    - .ts
    - .vue
    - .scss
    - .css
    - .png
    - .svg
    - .gif
    - .jpeg
    - .jpg

  server:
    host: 0.0.0.0
    port: 3000
    https: false

development:
  <<: *default

  dev_server:
    host: 0.0.0.0
    port: 3000
    https: false


production:
  <<: *default

  dev_server:
    host: 0.0.0.0
    port: 80
    https: false
```

###`index.html`のテンプレート作成
`index.html`を`index.template.ejs`とリネームし、`src`ディレクトリ配下に移動。
ハッシュ付きの`script`タグを挿入するため、`index.html`に直接`bundle.js`を記載せず`html-webpack-plugin`により、`index.html`を`dst`に出力させることが目的。

```html:src/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>react-router-redux basic example</title>
    <meta charset="utf8"/>
  </head>
  <body>
    <div id="mount"></div>
  </body>
</html>
```

###`bin/webpack-dev-server`によるビルド
`bin/webpack-dev-server`を実行するとビルドされて、`http://localhost:3000/`にアクセスすると`react-router-redux`のサンプルが表示される。

<img width="841" alt="スクリーンショット 2017-11-04 11.58.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/da16d8f3-07a3-7109-a962-8fad7258037e.png">

アプリケーションのファイル単位でブレークポイントを指定することができるので、デバッグや検証の利便性が向上する。

###`bin/webpack`によるビルド
`bin/webpack`を実行すると、リリース（プロダクション）向けのビルドができる。
`dst`配下にビルドされたハッシュ付きの`js`コードと、`index.html`が作成されるので、`nginx`等のWebサーバが起動している環境で、実行する。

###その他
例えば、開発環境で`coffee`スクリプトを利用する場合、`coffee-loader`のプラグインを追加でインストールし、下記ファイルを追加する。

```js:config/webpack/loaders/react.js
module.exports = {
  test: /\.coffee(\.erb)?$/,
  loader: 'coffee-loader'
}
```

`scss/sass`の場合は以下。必要に応じて、プラグインを追加でインストールする必要がある。

```js:config/webpack/loaders/react.js
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const { env } = require('../configuration.js')

module.exports = {
  test: /\.(scss|sass|css)$/i,
  use: ExtractTextPlugin.extract({
    fallback: 'style-loader',
    use: [
      { loader: 'css-loader', options: { minimize: env.NODE_ENV === 'production' } },
      { loader: 'postcss-loader', options: { sourceMap: true } },
      'resolve-url-loader',
      { loader: 'sass-loader', options: { sourceMap: true } }
    ]
  })
}
```