Edited at

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

More than 1 year has passed since last update.


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