JavaScript
reactjs
React
react-router
redux

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

react-routerreact-router-reduxのサンプルを作成し、Webpackの環境を構築する

react-routerが4.2で、react-router-reduxについてはα版の5.0がリリースされていた。
githubから入手して簡単な動作検証をしたかったが、なかなか動作させることができなかったので、簡単な動作確認ができる環境構築を備忘録として記載(今後随時更新予定)。

設定ファイルとindex.html

package.jsonwebpack.config.jsindex.htmlreact-routerreact-router-reduxのサンプルで共通で利用するようにした。

package.json

まずはpackage.jsonについて。
.babelrc.eslintファイルは用意せず、package.jsonに記載した。必要に応じて、
// eslint-disable-line(1行の場合)

/* eslint-disable */(ファイル全体)
をJSファイルに指定する(参考)。
また、依存関係については、dependenciesdevDependenciesのいずれかに記載があれば良い。
react-router-reduxを利用しない場合、redux関連の依存を外す。

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で利用することを前提にしている。

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ファイル。

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のサンプルから。

ディレクトリ構成

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

スクリーンショット 2017-11-02 0.33.32.png

サンプル

下記のapp.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を実行するとブラウザが起動し、下記画面が表示される。

スクリーンショット 2017-11-02 0.59.54.png

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

スクリーンショット 2017-11-02 1.00.05.png

スクリーンショット 2017-11-02 1.00.13.png

スクリーンショット 2017-11-02 1.00.22.png

react-router-reduxのサンプル

ディレクトリ構成

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

スクリーンショット 2017-11-02 1.19.34.png

サンプル

サンプルは以下。

states/constants.js
export const INCREASE = 'INCREASE'
export const DECREASE = 'DECREASE'
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
  }
}
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
}
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)
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)
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)
components/index.js
export { App } from './App'
export { Count } from './Count'
export { Display } from './Display'
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を実行するとブラウザが起動し、下記画面が表示される。

スクリーンショット 2017-11-02 1.25.50.png

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

スクリーンショット 2017-11-02 1.26.07.png

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

スクリーンショット 2017-11-02 1.26.19.png

補足

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

react-router-reduxWebpack開発環境を構築する

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

ディレクトリ構成

react-router-reduxのサンプル」のディレクトリ構成を下記のように変更する。
actionscomponentsreducersstatesapp.jssrcディレクトリを作成し、その配下に移動。
また、dstディレクトリを作成。

スクリーンショット 2017-11-04 11.35.20.png

実行ファイル

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

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
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に相当する設定ファイルを作成する。

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]'
    }
  }]
}
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' } }
        ]
    })
}
config/webpack/loaders/react.js
module.exports = {
  test: /\.(js|jsx)?$/,
  exclude: /node_modules/,
  loader: 'babel-loader',
  query: {
    presets: ['es2015', 'react', 'stage-2']
  }
}
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
}
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/
    }
  }
})
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)$/
    })
  ]
})
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']
  }
}
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.htmlindex.template.ejsとリネームし、srcディレクトリ配下に移動。
ハッシュ付きのscriptタグを挿入するため、index.htmlに直接bundle.jsを記載せずhtml-webpack-pluginにより、index.htmldstに出力させることが目的。

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のサンプルが表示される。

スクリーンショット 2017-11-04 11.58.22.png

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

bin/webpackによるビルド

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

その他

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

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

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

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 } }
    ]
  })
}