LoginSignup
697
758

More than 5 years have passed since last update.

ReactJSで作る今時のSPA入門(基本編)

Last updated at Posted at 2017-10-15

GitHubサンプルをbabel7に対応しました。
GitHubサンプルコード:GitHub
(※この記事のサンプルはすでに古いです。最新版のサンプルと説明はGitHubのreadmeに書いてあるのでそちらを見てください)

ReactJS使ってナウいウェブアプリケーション作ろうぜってことでまとめてみようと思います。

以前はウェブアプリケーション作る時はBracketsやAtom使っていたのですが
プラグインのアップデートとかバグった時がめんどくさかったり、エディタが重かったりしたので
最近はVisual Studio Code(VSCode)に乗り換えました。
(必要な便利な機能もプラグインいれなくても揃ってるし、デフォルトでJSXサポートしてるから楽)
VSCode環境構築:VSCodeで爆速コーディング環境を構築する(主にReactJS向け設定)

ちょこっとReactJSを試したいときはStackBlitzがブラウザエディタで環境構築済み状態で書けるのでおすすめです。

前提としてES2015以降でBabelを使います。
NodeJSに関してはある程度理解している人向け前提に説明します。
下記の書籍はキャッチアップできてない人には結構おすすめです。

ReactJSでSingle Page Applicationを作る

今時なSPA構成にするには
次のモジュールを使います。

  • ReactJS(コンポーネント、JSX、差分レンダリング)
  • React-Router(ページ遷移)
  • Redux(状態管理)
  • MaterialUI(UIコンポーネント)
  • babel(トランスパイラ)
  • webpack(リソースファイルを1つにコンパイル)

環境構築

  • エディタ:基本何でもいいですが、個人的にはVSCodeがJSXをデフォルトサポートしてるからおすすめ
  • ESLint:ビルド前に文法チェックしてくれるので入れておいたほうが良い
  • React Developer Tools(ChromeのReact開発用ブラウザアドオン)

パッケージ管理はyarnを使います。yarnはnpmの後発でセキュアでインストール速度が早いです。
入れていない人は下記コマンドで入れてください。

$ npm install -g yarn

ミニマムなReactJSアプリケーション

ReactJSでDOMをレンダリングするには

  • ReactJS
  • React DOM
  • Babel

が必要です。
簡易のため、上記JSファイルをCDN経由で読み込みます

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <script src="https://unpkg.com/react@15/dist/react.min.js"></script>
  <script src="https://unpkg.com/react-dom@15/dist/react-dom.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.38/browser.min.js"></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    ReactDOM.render(
      <h1>Hello, world!</h1>,
      document.getElementById('root')
    )
  </script>
</body>
</html>

実際にレンダリングしているのは
ReactDOM.renderの部分です。
ここで注目すべき点はscriptタグ内なのに
<h1>タグ(DOM)が記述されている点です。
実際に実行される際にはBabelにて次のようなJSに変換されます。
上記のような一見DOMが混じったようなJSの記法をJSXと呼びます。

compile.js
ReactDOM.render(React.createElement(
    'h1',
    null,
    'Hello, world!'
), document.getElementById('root'));

React.CreateElementメソッドにてroot以下に
DOMが生成されます。

スクリーンショット 2017-10-09 23.36.27.png

実際にcompile.jsの形に変換されているか確認するためには
JSXのBabelトランスパイラを使います。
React JSX transform

下記コマンドでBabelとJSXトランスパイラプラグインをインストールします。

# Babelコマンドをインストール
yarn global add babel-cli
# package.json作成
yarn init -y
# BabelのJSXトランスパイラプラグインをダウンロード
yarn add --dev babel-plugin-transform-react-jsx

次のJS部分を切り出したtest.jsxファイルを作成します。

test.jsx
ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('root')
  )

次のコマンドでtest.jsxに対して直接Babelのトランスパイルを行うとcompile.jsが出力されます。

$ babel --plugins transform-react-jsx test.jsx

webpack + Babelでコンポーネントを作成する

webpackを使うことで複数のリソースファイルを1つにまとめることができます。
さらにBabelと組み合わせることでJSXの変換に加えてブラウザではまだ未対応のimport文などが利用可能になります。
これにより、JSファイルからJSファイルのモジュールを呼び出すような構成が可能になります。
webpackでビルドするためにパッケージを追加します。

$ yarn add --dev webpack babel-core babel-loader babel-plugin-transform-react-jsx babel-preset-react react react-dom

インストール後のpackage.jsonは次のようになっています。
(モジュールのバージョンは指定しないと最新版が入ります)

package.json
{
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-react-jsx": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "webpack": "^3.6.0"
  }
}

次のようなtree構造にします。

├── App.js
├── index.html
├── index.js
├── node_modules
├── package.json
└── webpack.config.js

Reactのコンポーネントを作成します。
ReactのコンポーネントはReact.Componentを継承することで作成します。
renderメソッドでDOMを返却するようにします。
export defaultで外部のJSからクラスをimportできるようにします。

App.js
import React from 'react'

export default class App extends React.Component {

    render () {
        return <h1>Hello, world!</h1>
    }

}

index.jsにて作成したReactコンポーネントをimportして
DOMをレンダリングします。
ここで注目してほしいのはJSXにて<App />というDOMが指定できるようになっています。
React DOMによって作成したReactコンポーネントは新しいDOMとして指定できるようになります。
(DOMの振る舞いはReactコンポーネント内部でJSで記述する)
最終的なレンダリングはReactコンポーネントのrenderメソッドにて返却されるDOMが描画されます。

index.js
import React  from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(
    <App />,
    document.getElementById('root')
)

index.htmlは次のようにbundle.jsのみ読み込むように書き換えてください
(bundle.jsはwebpackでビルド後に生成されるファイル想定)

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
</head>
<body>
  <div id="root"></div>
  <script type='text/javascript' src="bundle.js" ></script>
</body>
</html>

webpackでビルドするための設定ファイル、webpack.config.jsを作成します。

webpack.config.js
module.exports = {
    entry: './index.js', // エントリポイントのjsxファイル
    output: {
      filename: 'bundle.js' // 出力するファイル
    },
    module: {
      loaders: [{
        test: /\.js?$/, // 拡張子がjsで
        exclude: /node_modules/, // node_modulesフォルダ配下は除外
        loader: 'babel-loader', // babel-loaderを使って変換する
        query: {
          plugins: ["transform-react-jsx"] // babelのtransform-react-jsxプラグインを使ってjsxを変換
        }
      }]
    }
  }

次のコマンドでindex.jsに付随するJSファイルをまとめてビルドして一つのbundle.jsとして出力することができます

$ node_modules/webpack/bin/webpack.js 
Hash: 7a16807494494a5823f6
Version: webpack 3.6.0
Time: 6049ms
    Asset    Size  Chunks                    Chunk Names
bundle.js  835 kB       0  [emitted]  [big]  main
  [15] ./index.js 168 bytes {0} [built]
  [32] ./App.js 258 bytes {0} [built]
    + 31 hidden modules

index.htmlを開くと表示されるはずです。

スクリーンショット 2017-10-11 9.56.28.png

下記のコマンドでwebpackの監視モードにするとビルド対象のJSファイルの変更が保存されるとビルドされるようになります。(開発中は楽です。)

$ webpack --watch

Reactの基本お作法

Reactの基本的なルールを覚えつつ簡単なアプリケーションを作ってみましょう。
次のようなボックスのコンポーネントを作ってみます。

スクリーンショット 2017-10-14 15.09.25.png

それぞれのボックスクリック時にはボックス内の数字がカウントアップします。

スクリーンショット 2017-10-14 15.09.34.png

まずボックスのコンポーネントを作ります。

Rect.js
import React from 'react'

export default class Rect extends React.Component {

  constructor (props) {
    super(props)
    // ステートオブジェクト
    this.state = { number : this.props.num }    
  }

  componentWillMount () {
    // propsに属性値が渡ってくる
    const { num, bgcolor } = this.props

    // CSS スタイルはキャメルケースでプロパティを書く
    this.rectStyle = {
      background: bgcolor,
      display: 'table-cell',
      border: '1px #000 solid',
      fontSize: 20,
      width: 30,
      height: 30,
      textAlign: 'center',
      verticalAlign: 'center',
    }

  }

  // カウントアップ
  countUp (num) {
    // ステートオブジェクトのパラメータを更新→renderメソッドが呼ばれ、再描画される
    this.setState({ number : num + 1 })
  }

  render () {

    // 複数行になる場合は()で囲む
    // 返却する最上位のDOMは1つのみ
    return (
      <div style={ this.rectStyle } onClick={(e)=> this.countUp(this.state.number)}>
        <span style={{ color : '#eeeeee' }}>{this.state.number}</span>
      </div>
    )
  }
}

App.jsではRectコンポーネントを読み込んで表示します。

App.js
import React from 'react'
import Rect from './Rect'

export default class App extends React.Component {

  render () {
    return (
      <div>
        <Rect num={1} bgcolor='#e02020' />
        <Rect num={2} bgcolor='#20e020' />
        <Rect num={3} bgcolor='#2020e0' />
      </div>
    )
  }
}

Reactコンポーネントのライフサイクルについて

全体図は良い図があったのでそちらを参考にしてください。
React component ライフサイクル図
各種メソッドの説明はこちら
React Componentのライフサイクルのまとめと利用用途

初期化時と属性値変更時に呼ばれるcomponentWillMountメソッドと
描画時に呼ばれるrenderメソッドはよく使うのでまず抑えておいてください

通信処理後のpropsの変更をみて、さらに何か処理したい場合には
componentWillReceivePropsメソッドを使ったり、

どうしても直接DOM操作をしたいときにcomponentDidMountメソッドにDOMのイベントを追加して
componentWillUnmountメソッドでDOMイベントを削除したりします。

属性値について

新規に作成したRectコンポーネントには通常のDOMと
同様に属性値を定義することができます。

App.js
<Rect num={1} bgcolor='#e02020' />

独自に定義したプロパティはpropsオブジェクト内に格納されて
Rectコンポーネントに渡ってきます。

Rect.js
componentWillMount () {
    // propsに属性値が渡ってくる
    const { num, bgcolor } = this.props

CSS Styleについて

JSX内でスタイルを渡すにはキャメルケースで書く必要があります。
babelでCSSに変換してもらいます。
例えば、font-sizeを適応したい場合はfontSizeと記述する必要があります。

Rect.js
    // CSS スタイルはキャメルケースでプロパティを書く
    this.rectStyle = {
      background: bgcolor,
      display: 'table-cell',
      border: '1px #000 solid',
      fontSize: 20,
      width: 30,
      height: 30,
      textAlign: 'center',
      verticalAlign: 'center',
    }

JSX内で{}した箇所にはJSを書くことができます。
今回はJSのスタイルオブジェクトを渡しています。

Rect.js
   <div style={ this.rectStyle } >

コンポーネントのstateについて

コンポーネント内部で状態を保持したい場合は
stateオブジェクトという特殊なオブジェクトを定義します。
中身のパラメータに関しては自由に入れて構いません。
今回はクリックしたときに数字をカウントアップしたいため
numberパラメータを保持するようにしました。

Rect.js
  // ステートオブジェクト
  this.state = { number : this.props.num }    

イベントハンドリングとnumberオブジェクトの更新に関して記述した箇所が次の箇所になります。
Reactにはstateオブジェクトのパラメータを更新させるために
setStateメソッドが用意されています。
クリックされてsetStateメソッドが呼び出されるとstateオブジェクトのパラメータを更新し
renderメソッドを呼び出します(再描画される)。

Rect.js

  // カウントアップ
  countUp (num) {
    // ステートオブジェクトのパラメータを更新→renderメソッドが呼ばれ、再描画される
    this.setState({ number : num + 1 })
  }

  render () {
     return (
      <div onClick={(e)=> this.countUp(this.state.number)}>

renderメソッド内でsetStateメソッドを直接呼び出してはいけません。
render→setState→renderと無限ループになるからです。

クラスメソッドのイベントバインディングには何種類か方法があります。
詳しくはReactをes6で使う場合のbindの問題を参照

Reduxによる状態制御

Reduxを用いることでアプリケーション全体の状態を管理し、
イベントコールバック→一元管理されたストアのパラメータ更新→描画反映
といったことが楽になります。
(類似のフレームワークにfluxがあります。)
参考:ToDoアプリで学ぶReact/Redux入門/vtecx2_lt2
参考:Redux入門【ダイジェスト版】10分で理解するReduxの基礎
参考:React+Redux入門
SPAなReactJSと特に相性が良いです。

Reduxは次の思想で設計されています。

1.ストアがいっぱいあると不整合が起きるのでビューに使うコンポーネントから分離して1つのストアに格納する
2.ストアの状態を更新するためには決められたアクション経由で行う
3.Stateの変更を行うReducerはシンプルな関数(Pure関数)にする

ReactとReduxを連動させるためにはreact-reduxのnpmパッケージを使うのですがconnectの記述方法がいくつもあり混乱します。
ReactとReduxを結ぶパッケージ「react-redux」についてconnectの実装パターンを試す

今回は可読性の良さを重視して、decoratorsを使って実装します。
decoratorsの説明・自作に関してはこちらにまとめました
追加で下記のRedux関連のパッケージをインストールします。

$ yarn add --dev babel-plugin-transform-decorators-legacy redux redux-devtools redux-thunk react-redux react-router-redux 

package.jsonは次のようになります。

package.json
{
  "name": "meetdep",
  "version": "1.0.0",
  "description": "",
  "main": "test.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "axios": "^0.16.2",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-decorators-legacy": "^1.3.4",
    "babel-plugin-transform-react-jsx": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "react-router-dom": "^4.2.2",
    "redux": "^3.7.2",
    "redux-devtools": "^3.4.0",
    "redux-thunk": "^2.2.0",
    "react-redux": "^5.0.6",
    "react-router-redux": "^4.0.8",
    "webpack": "^3.6.0"
  },
  "dependencies": {}
}

decoratorの文法を使うので
babel-plugin-transform-decorators-legacyのプラグインを
webpack.config.jsに追加します。

webpack.config.js
module.exports = {
    entry: './index.js', // エントリポイントのjsxファイル
    output: {
      filename: 'bundle.js' // 出力するファイル
    },
    module: {
      loaders: [{
        test: /\.js?$/, // 拡張子がjsで
        exclude: /node_modules/, // node_modulesフォルダ配下でなければ
        loader: 'babel-loader', // babel-loaderを使って変換する
        query: {
          plugins: ["transform-react-jsx","babel-plugin-transform-decorators-legacy"] // babelのtransform-react-jsxプラグインを使ってjsxを変換
        }
      }]
    }
  }  

index.jsを書き換えます

index.js
import React  from 'react'
import ReactDOM from 'react-dom'
// applyMiddlewareを追加
import { createStore, applyMiddleware } from 'redux'
// react-reduxのProviderコンポーネントを追加
import { Provider } from 'react-redux'
import App from './App'
// reducerを読み込み(後述)
import reducer from './reducer'

// storeを作成
const store = createStore(reducer)

// Providerタグで囲うとApp内でstoreが利用可能になる
ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    document.getElementById('root')
)

reducer.jsに読み込むreducerを記述します

reducer.js
import { combineReducers } from 'redux'
// コメント取得のreducer
import comment from './comment'

// 作成したreducerをオブジェクトに追加していく
// combineReducersで1つにまとめてくれる
export default combineReducers({
  comment
})

comment.jsにコメント用のreducerを作成します。

comment.js
// reducerで受け取るaction名を定義
const LOAD = 'comment/LOAD'

// 初期化オブジェクト
const initialState = {
  comments: null,
}

// reducerの定義(dispatch時にコールバックされる)
export default function reducer(state = initialState, action = {}){
  // actionの種別に応じてstateを更新する
  switch (action.type) {
    case LOAD:
      return {
        comments:action.comments,
      }
    default:
      // 初期化時はここに来る(initialStateのオブジェクトが返却される)
      return state
  }
}

// actionの定義
export function load() {
  const comments = 'hello'
  // action種別と更新stateを返却(dispatchされる)
  return { type: LOAD, comments }
}

App.jsでコメントのactionをキック、reducer経由でのstate更新を行います。

App.js
import React from 'react'
import { connect } from 'react-redux';
// コメントreducerのactionを取得
import { load } from './comment'

// connectのdecorator
@connect(
  state => ({
    // reducerで受け取った結果をpropsに返却する
    comments: state.comment.comments
  }),
  // actionを指定
  { load }
)
export default class App extends React.Component {

  componentWillMount() {
    // コメントのactionをキックする
    this.props.load()
  }

  render () {
    // connectで取得したstateはpropsに入る
    const { comments } = this.props
    // 初回はnullが返ってくる(initialState)、処理完了後に再度結果が返ってくる
    console.log(comments)
    return (
      <div>{comments}</div>
    )
  }
}

次のような結果になります。
スクリーンショット 2017-10-15 1.21.57.png

スクリーンショット 2017-10-15 1.20.49.png

actionを非同期にする

react-reduxを実際に使う場面は通信や画面遷移周りだと思います。
redux-thunkを使うとaction部分の処理を非同期にできます。

通信用のライブラリ(axios)をインストールします

$ yarn add --dev axios

index.jsにてaxiosを付与した
thunkミドルウェアをreduxに適応します。

index.js
import React  from 'react'
import ReactDOM from 'react-dom'
import { createStore, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import client from 'axios'
import thunk from 'redux-thunk'

import App from './App'
import reducer from './reducer'

// axiosをthunkの追加引数に加える
const thunkWithClient = thunk.withExtraArgument(client)
// redux-thunkをミドルウェアに適用
const store = createStore(reducer, applyMiddleware(thunkWithClient))

ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    document.getElementById('root')
)

reducer.jsを修正します。
今回はユーザー用のreducerを作成します。

reducer.js
import { combineReducers } from 'redux'

import user from './user'

export default combineReducers({
  user,
})

user.jsには
Random User Generatorで生成した疑似ユーザ情報をAPIで取得するreducerを作成します。
次のようにaction部分を非同期で記述できます。

user.js
const LOAD = 'user/LOAD'

const initialState = {
  users: null,
}

export default function reducer(state = initialState, action = {}){
  switch (action.type) {
    case LOAD:
      return {
        users:action.results,
      }
    default:
      return state
  }
}

export function load() {
  // clientはaxiosの付与したクライアントパラメータ
  // 非同期処理をPromise形式で記述できる
  return (dispatch, getState, client) => {
    return client
      .get('https://randomuser.me/api/')
      .then(res => res.data)
      .then(data => {
        const results = data.results
        // dispatchしてreducer呼び出し
        dispatch({ type: LOAD, results })
      })
  }
}

取得したユーザデータを表示します。

App.js
import React from 'react'
import { connect } from 'react-redux';
import { load } from './user'

@connect(
  state => ({
    users: state.user.users
  }),
  { load }
)
export default class App extends React.Component {

  componentWillMount() {
    this.props.load()
  }

  render () {
    const { users } = this.props
    return (
      <div>
          {/* 配列形式で返却されるためmapで展開する */}
          {users && users.map((user) => {
            return (
                // ループで展開する要素には一意なkeyをつける(ReactJSの決まり事)
                <div key={user.email}>
                  <img src={user.picture.thumbnail} />
                  <p>名前:{user.name.first + ' ' + user.name.last}</p>
                  <p>性別:{user.gender}</p>
                  <p>email:{user.email}</p>
                </div>
            )
          })}
      </div>
    )
  }
}

実行結果です。Random User Generatorで生成された疑似ユーザを表示しています。

スクリーンショット 2017-10-15 12.33.48.png

Material-UIでモダンな画面を作る

マテリアルデザインはグーグルが提唱するデザインフォーマットです。
フラットデザインに現実の物理要素(影やフィードバック)を持たせたようなデザインです。
Androidアプリでの全面的な利用など最近のアプリケーションのデザインは大体マテリアルデザインでできています。
Material Design

ReactJSではマテリアルデザインを踏襲したMaterial-UIというライブラリがあります。

Material-UIのパッケージをインストールします。

$ yarn add --dev material-ui@next material-ui-icons

package.jsonは次のようになります。

package.json
{
  "name": "meetdep",
  "version": "1.0.0",
  "description": "",
  "main": "test.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "axios": "^0.16.2",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-decorators-legacy": "^1.3.4",
    "babel-plugin-transform-react-jsx": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "material-ui": "^1.0.0-beta.16",
    "material-ui-icons": "^1.0.0-beta.15",
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "react-redux": "^5.0.6",
    "redux": "^3.7.2",
    "redux-devtools": "^3.4.0",
    "redux-thunk": "^2.2.0",
    "webpack": "^3.6.0"
  },
  "dependencies": {}
}

ユーザを取得したApp.jsをmaterial-uiで書き直します。

App.js
import React from 'react'
import { connect } from 'react-redux'
import { load } from './user'

import { withStyles } from 'material-ui/styles'
import { AppBar,Toolbar, Avatar, Card, CardContent, Button, Dialog, DialogTitle, DialogContent } from 'material-ui'
import Typography from 'material-ui/Typography'
import { Email } from 'material-ui-icons'

@connect(
  state => ({
    users: state.user.users
  }),
  { load }
)
export default class App extends React.Component {

  constructor (props) {
    super(props)
    this.state = {
      open:false,
      user:null,
    }
  }

  componentWillMount () {
    this.props.load()
  }

  handleClickOpen (user) {
    this.setState({
      open: true,
      user: user,
    })
  }

  handleRequestClose () {
    this.setState({ open: false })
  };

  render () {
    const { users } = this.props
    return (
      <div>
        <AppBar position="static" color="primary">
          <Toolbar>
            <Typography type="title" color="inherit">
              タイトル
            </Typography>
          </Toolbar>
        </AppBar>
          {/* 配列形式で返却されるためmapで展開する */}
          {users && users.map((user) => {
            return (
                // ループで展開する要素には一意なkeyをつける(ReactJSの決まり事)
                <Card key={user.email} style={{marginTop:'10px'}}>
                  <CardContent style={{color:'#408040'}}>
                    <Avatar src={user.picture.thumbnail} />
                    <p style={{margin:10}}>{'名前:' + user.name.first + ' ' + user.name.last} </p>
                    <p style={{margin:10}}>{'性別:' + (user.gender == 'male' ? '男性' : '女性')}</p>
                    <div style={{textAlign: 'right'}} >
                      <Button onClick={() => this.handleClickOpen(user)}><Email/>メールする</Button>                    
                    </div>
                  </CardContent>
                </Card>                
            )
          })}        
          {
            this.state.open &&
            <Dialog open={this.state.open} onRequestClose={() => this.handleRequestClose()}>
              <DialogTitle>メールアドレス</DialogTitle>
              <DialogContent>{this.state.user.email}</DialogContent>
            </Dialog>
          }  
      </div>
    )
  }
}

次のような画面になります。

スクリーンショット 2017-10-15 16.42.51.png

「メールする」をクリックするとダイアログが表示されます。

スクリーンショット 2017-10-15 16.43.12.png

Material-UIの各コンポーネントに関しては
公式:Material-UIのComponents Demoに各種コンポーネントのデモを見たほうが理解できると思います。

Material UIのアイコンに関しては下記アイコンが使えます。
Material icons

今回はメールのアイコンを使っています。
emailというアイコン名になっているので、
次のように先頭大文字でメールアイコンを読み込みできます。

App.js
import { Email } from 'material-ui-icons'

Webpack Dev ServerとReact Hot Loaderによる変更時自動ブラウザリロード

ソースコード変更時のwebpackビルドを
webpack --watchにより行っていましたが
Webpack Dev ServerとReact Hot Loaderの設定を行うことで
ソースコード変更時にwebpackビルドしてくれる上にビルド完了後にブラウザを自動リロードしてくれます。

下記パッケージを追加でインストールします。

$ yarn add --dev webpack-dev-server babel-polyfill react-hot-loader

webpack.config.jsにreact-hot-loaderの設定を追加します。

webpack.config.js
const webpack = require('webpack')

module.exports = {
  devtool: 'inline-source-map', // ソースマップファイル追加 
  entry: [
    'babel-polyfill',
    'react-hot-loader/patch',
    __dirname + '/index', // エントリポイントのjsxファイル
  ],
  // React Hot Loader用のデバッグサーバ(webpack-dev-server)の設定
  devServer: {
    contentBase: __dirname, // index.htmlの格納場所
    inline: true, // ソース変更時リロードモード
    hot: true, // HMR(Hot Module Reload)モード
    port: 8081, // 起動ポート
    // CORSの対策(debugホストが違うため)
    proxy: {
      // CORSを許可するパスとサーバ
      '/api/**': {
        target: 'http://localhost:8080',
        secure: false,
        changeOrigin: true
      }
    }
  },
  output: {
    publicPath: '/', // デフォルトルートにしないとHMRは有効にならない
    filename: 'bundle.js'
  },
  plugins: [
    new webpack.NamedModulesPlugin(), // 名前変更無効プラグイン利用
    new webpack.HotModuleReplacementPlugin() // HMR(Hot Module Reload)プラグイン利用 
  ],
  module: {
    rules: [{
      test: /\.js?$/, // 拡張子がjsで
      exclude: /node_modules/, // node_modulesフォルダ配下は除外
      include: __dirname,// プロジェクトディレクトリ配下のJSファイルが対象
      use: {
        loader: 'babel-loader',
        options: {
          plugins: ["transform-react-jsx","babel-plugin-transform-decorators-legacy","react-hot-loader/babel"] // babelのtransform-react-jsxプラグインを使ってjsxを変換
        }
      }
    }]
  }
}

index.jsにReact Hot Loaderの設定を追加します。
AppContainerコンポーネントでコンポーネント全体をwrapして
module.hotの処理を追加します。

index.js
import React  from 'react'
import ReactDOM from 'react-dom'
import { createStore, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import client from 'axios'
import thunk from 'redux-thunk'
import { AppContainer } from 'react-hot-loader'

import App from './App'
import reducer from './reducer'

// axiosをthunkの追加引数に加える
const thunkWithClient = thunk.withExtraArgument(client)
// redux-thunkをミドルウェアに適用
const store = createStore(reducer, applyMiddleware(thunkWithClient))

const render = Component => {
  ReactDOM.render(
    <AppContainer warnings={false}>
      <Provider store={store}>
        <Component />
      </Provider>
    </AppContainer>,
    document.getElementById('root'),
  )
}

render(App)

// Webpack Hot Module Replacement API
if (module.hot) {
  module.hot.accept('./App', () => { render(App) })
}

次のコマンドで起動します。

$ ./node_modules/webpack-dev-server/bin/webpack-dev-server.js 

もしくはpackage.jsonにscriptsを書いておくと楽です。

package.json
  "scripts": {
    "dev": "webpack-dev-server"
  },

この場合、下記コマンドでwebpack-dev-serverが起動します。

$ yarn run dev

React Router Reduxでページ遷移(SPAでの画面遷移)

React RouterはSPAでの擬似的な画面遷移を提供してくれます。
実態はURLパス指定のReactComponentのレンダリング表示非表示切り替えです。
また、React Router Reduxを使うと画面遷移をhistoryオブジェクト経由で管理することができます。
下記コマンドでreact-router-domとreact-router-reduxとhistoryをインストールします。
React Routerはバージョンごとで破壊的変更が入って互換性がないためv4を使用します

$ yarn add --dev react-router-dom@4.2.2 history react-router-redux@next

また、historyApiFallbackをtrueにします。後で使うhistory APIのブラウザリロード時に対応します。

webpack.config.js
  // React Hot Loader用のデバッグサーバ(webpack-dev-server)の設定
  devServer: {
    historyApiFallback: true, // history APIが404エラーを返す時、index.htmlに遷移(ブラウザリロード時など) 

index.jsにてhistoryオブジェクトの作成、

reduxのstoreに紐付け、Componentのpropsにhistoryオブジェクトを渡します。

index.js
import React  from 'react'
import ReactDOM from 'react-dom'
import createHistory from 'history/createBrowserHistory'
import { createStore, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import client from 'axios'
import thunk from 'redux-thunk'
import { AppContainer } from 'react-hot-loader'
import { routerMiddleware } from 'react-router-redux'

import App from './App'
import reducer from './reducer'

// ブラウザ履歴保存用のストレージを作成
const history = createHistory()
// axiosをthunkの追加引数に加える
const thunkWithClient = thunk.withExtraArgument(client)
// routerMiddlewareとredux-thunkをミドルウェアに適用
const store = createStore(reducer, applyMiddleware(routerMiddleware(history),thunkWithClient))

const render = Component => {
  ReactDOM.render(
    <AppContainer warnings={false}>
      <Provider store={store}>
        <Component history={history} />
      </Provider>
    </AppContainer>,
    document.getElementById('root'),
  )
}

render(App)

// Webpack Hot Module Replacement API
if (module.hot) {
  module.hot.accept('./App', () => { render(App) })
}

reducer.jsにrouterReducerを追加します。  

reducer.js
import { combineReducers } from 'redux'
import { routerReducer } from 'react-router-redux'

import user from './user'

export default combineReducers({
  routing: routerReducer,
  user
})

App.jsにてルーティングの指定をします。

今回は

  • ユーザページ
  • TODOページ
  • NotFoundページ

を作成します。

どの画面でもhistoryオブジェクトを扱えるようにRouterコンポーネントのpropsにhistoryを渡します。

App.js
import React from 'react'
import { ConnectedRouter as Router } from 'react-router-redux'
import { Route, Redirect, Switch } from 'react-router-dom'

import NotFound from './NotFound'
import UserPage from './UserPage'
import TodoPage from './TodoPage'

export default class App extends React.Component {
  render() {
    const { history } = this.props
    return (
      <Router history={history}>
        <Route component={AppRoute} />
      </Router>
    )
  }
}

const AppRoute = (props) => (
  <Switch>
    <Route exact path="/" component={UserPage} />
    <Route path="/todo" component={TodoPage} /> 
    {/* それ以外のパス */}
    <Route component={NotFound} /> 
  </Switch>
)

Switchコンポーネントで対象のパスをグルーピングします。
exactはパスの完全一致指定です。この指定がないと/todoでもUserPageコンポネントがレンダリングされてしまいます。
//todo以外のときはパス未指定のNotFound.jsが呼ばれます。

NotFound.js
import React from 'react'

export default class NotFound extends React.Component {
  render() {
    return  <div>NotFound</div>
  }
}

UserPage.jsです。
ほぼ変わりませんがヘッダー部分にTodoリストページに遷移するためのメソッドを追加しています。

UserPage.js
import React from 'react'
import { connect } from 'react-redux';
import { load } from './user'

import { withStyles } from 'material-ui/styles'
import { AppBar,Toolbar, Avatar, Card, CardContent, Button, Dialog, DialogTitle, DialogContent } from 'material-ui'
import Typography from 'material-ui/Typography'
import { Email } from 'material-ui-icons'


// connectのdecorator
@connect(
  // propsに受け取るreducerのstate
  state => ({
    users: state.user.users
  }),
  // propsに付与するactions
  { load }
)
export default class UserPage extends React.Component {

  constructor (props) {
    super(props)
    this.state = {
      open:false,
      user:null,
    }
  }

  componentWillMount() {
    // user取得APIコールのactionをキックする
    this.props.load()
  }

  handleClickOpen (user) {
    this.setState({
      open: true,
      user: user,
    })
  }

  handleRequestClose () {
    this.setState({ open: false })
  }

  handlePageMove(path) {
    this.props.history.push(path)
  }

  render () {
    const { users } = this.props
    // 初回はnullが返ってくる(initialState)、処理完了後に再度結果が返ってくる
    // console.log(users)
    return (
      <div>
          <AppBar position="static" color="primary">
            <Toolbar>
              <Typography type="title" color="inherit">
                ユーザページ
              </Typography>
              <Button style={{color:'#fff',position:'absolute',top:15,right:0}} onClick={()=> this.handlePageMove('/todo')}>TODOページへ</Button>
            </Toolbar>
          </AppBar>
          {/* 配列形式で返却されるためmapで展開する */}
          {users && users.map((user) => {
            return (
                // ループで展開する要素には一意なkeyをつける(ReactJSの決まり事)
                <Card key={user.email} style={{marginTop:'10px'}}>
                  <CardContent style={{color:'#408040'}}>
                    <Avatar src={user.picture.thumbnail} />
                    <p style={{margin:10}}>{'名前:' + user.name.first + ' ' + user.name.last} </p>
                    <p style={{margin:10}}>{'性別:' + (user.gender == 'male' ? '男性' : '女性')}</p>
                    <div style={{textAlign: 'right'}} >
                      <Button onClick={() => this.handleClickOpen(user)}><Email/>メールする</Button>                    
                    </div>
                  </CardContent>
                </Card>    
            )
          })}
          {
            this.state.open &&
            <Dialog open={this.state.open} onRequestClose={() => this.handleRequestClose()}>
              <DialogTitle>メールアドレス</DialogTitle>
              <DialogContent>{this.state.user.email}</DialogContent>
            </Dialog>
          }  
      </div>
    )
  }
}

historyオブジェクトのpushメソッドにて画面遷移ができるようになります。
また、遷移履歴もhistoryオブジェクトで一元管理されているため、ブラウザバックなども有効に働きます。

handlePageMove(path) {
  this.props.history.push(path)
}

ヘッダー部分にユーザページへ戻るリンクがあります。

TodoPage.js
import React from 'react'
import { connect } from 'react-redux';
import { load, add } from './user'

import { withStyles } from 'material-ui/styles'
import { AppBar,Toolbar, Avatar, Card, CardContent, Button, Dialog, DialogTitle, DialogContent } from 'material-ui'
import Typography from 'material-ui/Typography'
import { Email } from 'material-ui-icons'


export default class TodoPage extends React.Component {

  handlePageMove(path) {
    this.props.history.push(path)
  }

  render () {
    const { todos } = this.props

    return (
      <div>
          <AppBar position="static" color="primary">
            <Toolbar>
              <Typography type="title" color="inherit">
                TODOページ
              </Typography>
              <Button style={{color:'#fff',position:'absolute',top:15,right:0}} onClick={()=> this.handlePageMove('/')}>ユーザページへ</Button>
            </Toolbar>
          </AppBar>
      </div>
    )
  }
}

ユーザページプレビューです。
スクリーンショット 2017-11-26 0.01.35.png
TODOページプレビューです。
スクリーンショット 2017-11-26 0.01.46.png

ReduxFormでのバリデーションチェックとフォーム投稿

ReduxFormを使うとReactでのフォーム投稿とバリデーションチェックが構造化できます。
次のnpmコマンドでReduxFormをダウンロードします。

$ yarn add --dev redux-form

package.jsonは次のようになります。

package.json
{  
  "devDependencies": {
    "axios": "^0.16.2",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-plugin-transform-decorators-legacy": "^1.3.4",
    "babel-plugin-transform-react-jsx": "^6.24.1",
    "babel-polyfill": "^6.26.0",
    "babel-preset-react": "^6.24.1",
    "body-parser": "^1.18.2",
    "history": "^4.7.2",
    "material-ui": "^1.0.0-beta.17",
    "material-ui-icons": "^1.0.0-beta.17",
    "react": "^16.0.0",
    "react-dom": "^16.0.0",
    "react-hot-loader": "^3.1.1",
    "react-redux": "^5.0.6",
    "react-router-dom": "^4.2.2",
    "react-router-redux": "^5.0.0-alpha.8",
    "redux": "^3.7.2",
    "redux-devtools": "^3.4.0",
    "redux-form": "^7.1.2",
    "redux-thunk": "^2.2.0",
    "webpack": "^3.8.1",
    "webpack-dev-server": "^2.9.4"
  }
}

reducer.jsにReduxFormのreducerを追加します。

reducer.js
import { combineReducers } from 'redux'
import { routerReducer } from 'react-router-redux'
import { reducer as formReducer } from 'redux-form' // as は名前がかぶらないようにインポート時に別名にできる

import user from './user'

export default combineReducers({
  routing: routerReducer,
  form: formReducer, // 追加
  user
})

TodoPage.jsにReduxFormにてフォームを作成します。
@reduxFormデコレータで入力変更時のバリデーションチェックを行います。
送信対象の項目はFieldコンポーネントで定義します。
componentには対象のDOMを指定します。
今回はMaterial-uiのTextFieldコンポーネントとselectタグを指定します。
submit時はhandleSubmitにsendItemsメソッドを指定して送信処理をキックします。

TodoPage.js
import React from 'react'

import { withStyles } from 'material-ui/styles'
import { AppBar,Toolbar, Avatar, Card, CardContent, Button, TextField } from 'material-ui'
import Typography from 'material-ui/Typography'
import { Email } from 'material-ui-icons'
import { Field, reduxForm } from 'redux-form'
import { error } from 'util';

const FormTextField = ({
  input,
  label,
  type,
  meta: { touched, error, warning }
}) => {
  const isError = !!(touched && error)
  return (
    <TextField style={{margin:5}} error={isError} label={label} helperText={isError ? error : null} {...input} type={type} />
  )
}

@reduxForm({
  form: 'syncValidation',
  validate: values => {

    // 入力変更時にパラメータが渡ってくる
    const errors = {}
    if (!values.firstname) {
      errors.firstname = '必須項目です'
    } 
    if (!values.lastname) {
      errors.lastname = '必須項目です'
    } 
    if (!values.email) {
      errors.email = '必須項目です'
    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.email)) {
      errors.email = 'メールアドレスとして認識できません'
    }

    return errors
  }
})
export default class TodoPage extends React.Component {

  constructor(props) {
    super(props)
    this.sendItems = this.sendItems.bind(this) // sendItemsメソッド内でthisを使えるようにbindする
  }

  handlePageMove(path) {
    this.props.history.push(path)
  }

  sendItems(values) {
    const user = {
      firstname: values.firstname,
      lastname: values.lastname,
      gender: values.gender || 'male',
      email: values.email
    }
    // redux-connectで送信処理などする
    //this.props.add(user).then( () => alert('送信完了')) 
  }

  render () {
    const { handleSubmit, submitting } = this.props

    return (
      <div>
        <AppBar position="static" color="primary">
          <Toolbar>
            <Typography type="title" color="inherit">
              TODOページ
            </Typography>
            <Button style={{color:'#fff',position:'absolute',top:15,right:0}} onClick={()=> this.handlePageMove('/')}>ユーザページへ</Button>
          </Toolbar>
        </AppBar>
        <Card style={{padding:10}}>
          <form onSubmit={handleSubmit(this.sendItems)}>
            <Field name="firstname" type="text" component={FormTextField} label="" />
            <Field name="lastname" type="text" component={FormTextField} label="" />
            <div style={{margin:5}}>
              <label style={{marginRight: 5}}>性別</label>
              <span>
                <Field name="gender" component="select">
                  <option value="male">男性</option>
                  <option value="female">女性</option>
                </Field>
              </span>
            </div>
            <Field name="email" type="email" component={FormTextField} label="メールアドレス" />
            <br/>
            <Button style={{marginTop:10}} raised type="submit" disabled={submitting}>送信</Button>
          </form>
        </Card>
      </div>
    )
  }
}

プレビューです。
スクリーンショット 2017-11-25 23.59.14.png

TO BE CONTINUED...

大体必要そうな機能に関してモジュールを導入しての対応を一通り説明しました。
長くなってしまったのでリリースや上級編に関して別途記事を書きます。

リリースビルド編:ReactJSで作る今時のSPA入門(リリース編)
遅延レンダリング(Code Spliting):WebpackしたReactコンポーネントを非同期に読み込む
React Native編:React Nativeで楽に作るスマホアプリ開発入門(基本編)
単体テスト:ReactJSとReduxの単体テストをきちんと書く
フォーム(応用編):ReduxFormとMaterial-UIでモダンなフォームを作る(MUI v3版)

697
758
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
697
758