Node.js 再入門メモ(その 7)

Flux と仲直りする

こちらの話を先に読んだ方がよいかも。 Node 再入門というタイトルになっているが、かなり React 寄りのネタである。

tercel-s.hatenablog.jp

今回は、以前書いた SocketIO 製の「リアルタイムいいねボタン」的なアプリケーションのコードに Flux を適用してみようと思う。

ちなみに「リアルタイムいいねボタン」とは、アクセスしているすべてのユーザのうち、誰か1人でも「いいね!」ボタンを押すと、他のブラウザでも一斉にデータが書き換わるというものである。
f:id:tercel_s:20170827212554p:plain

今まで、Flux に関する概念的な説明はさまざまなところで目にしたが、正直いまひとつ掴みきれていない。 手を動かさないとたぶん体得できないのだろう。

なんで今さら Flux

Flux が、ソースコードが整理された状態を保つためのすぐれた方法論の一つと言われているからである。

前回のコードは、子コンポーネントから親に何らかの通知を行う際に、親側に定義したメソッドを子側からコールバックする必要があった。 コンポーネントの入れ子階層が深くなれば、このやり方は破綻する。

Flux を適用すると、複数のコンポーネント間の連携やビジネスロジックとのやり取りなど、アプリケーション全体に亘ってデータの流れが整流化されるらしいトレードオフとしてコードは冗長化されるが、短いコードが常に正義とは限らない。

僕にとって Flux が取っつきにくいと感じる理由は、Action や Dispatcher や Store といった登場人物が、それぞれどのような責務を担っているのかいまひとつイメージできないからである。 天下り的ではあるが、一旦 Flux の作法を(半ば盲信的に)全肯定した上で、流儀に従ってコードを書き換えてみようと思う。

実装編

非 Flux 版

前回の終着地点が今回の出発地点。 今回の対象となるソースコードは src/index.js のみで、その他のファイルは前回と同一である。

src/index.js(再掲)
import React, {Component} from 'react'
import ReactDOM from 'react-dom'
import request from 'superagent'
import socketio from 'socket.io-client'
const socket = socketio.connect('http://localhost:3001')

class CountUpButton extends Component {
  constructor(props) {
    super(props)
    this.state = {
      text: !props || !props.text ? '' : props.text
    }
  }

  // WebSocket からの配信結果を親コンポーネントに通知
  componentWillMount() {
     socket.on('newCountValue', (newValue) => {
      this.props.updateCounter(newValue)
    })
  }

  // カウンタの更新処理
  // WebSocket サーバにイベントを送信
  countUp() {
    socket.emit('countUp', {})
  }

  render(props) {
    return (
      <button type='button'
              onClick={e => this.countUp(e)}>
        {this.state.text}
      </button>
    )
  }
}

class App extends Component {
  // コンストラクタ
  constructor(props) {
    super(props)
    this.state = { counter: 0 }
  }

  // 初期データ取得(ここだけ Ajax)
  componentWillMount() {
    request
      .get('/api/getCountValue')
      .end((err, data) => {
        if(err) { return }

        const resultJSON = data.body
        const initValue = resultJSON.counter
        this.setState({
          counter: initValue
        })
      })
  }

  // 子コンポーネント(ボタン)から呼ばれる
  // カウンタ更新処理
  handleChange(newValue) {
    this.setState({ counter: newValue })
  }

  // コンポーネント描画
  render() {
    return(
      <div>
        <h3>{this.state.counter} いいね</h3>
        <CountUpButton 
          text='いいね!' 
          updateCounter={e => this.handleChange(e)}/>
      </div>
    )
  }
}

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

Flux 版

続いて Flux 版である。Action, Store, Dispatcher という 3つの役割を追加する。

本来ならば複数のモジュールに分割すべきだが、簡単のため 1 ファイルに集約した。

$ npm i --save flux
src/index.js (第1版)
import React, {Component} from 'react'
import ReactDOM from 'react-dom'
import request from 'superagent'
import socketio from 'socket.io-client'

// ===== ここから Flux =====
import {Dispatcher} from 'flux'

/*
                                  +--------+
                       +--------- | Action | ---------+
                       |          +--------+          |
                       v                              |
+--------+      +------------+      +-------+      +------+
| Action | ---> | Dispatcher | ---> | Store | ---> | View |
+--------+      +------------+      +-------+      +------+

・View      : React等のコンポーネント
・Action    : View 等から発火されて作られるイベント
・Dispatcher: 全てのアクションを受けてStoreにイベントを発火する
・Store     : アプリケーション全体のデータとビジネスロジック
             (必ずActionによってデータを更新する)

   <出典> http://qiita.com/knhr__/items/5fec7571dab80e2dcd92
*/

// ----------
// Action
const ActionType = {
  QUERY: 'QUERY',
  SUBMIT: 'SUBMIT',
  RECEIVE: 'RECEIVE'
}

const Actions = {
  query: () => {    // 現在値取得
    appDispatcher.dispatch({
      actionType: ActionType.QUERY
    })
  },
  submit: () => {  // カウンタ更新要求の送信
    appDispatcher.dispatch({
      actionType: ActionType.SUBMIT
    })
  },
  receive: (newValue) => {  // カウンタ受信
    if(newValue === null) return
    appDispatcher.dispatch({
      actionType: ActionType.RECEIVE,
      value: newValue
    })
  }
}

// ----------
// Store
const counterStore = {
  counter: 0,       // カウンタ(初期値)

  onQuery: () => {  // 現在値取得イベント
    request.get('/api/getCountValue')
      .end((err, data) => {
        if(!err) Actions.receive(data.body.counter)
      })
  },
  onSubmit: () => { // 送信イベント
    socket.emit('countUp')
  },
  onReceive: null   // 受信イベント
}

// ----------
// Dispatcher
const appDispatcher = new Dispatcher()

// Action と Store を紐づける
appDispatcher.register(payload => {
  switch(payload.actionType) {
    case ActionType.QUERY:    // 現在値取得
      counterStore.onQuery()
      break
    case ActionType.SUBMIT:   // 更新要求送信
      counterStore.onSubmit()
      break
    case ActionType.RECEIVE:  // カウンタ受信
      counterStore.counter = payload.value
      counterStore.onReceive()
      break
  }
})

// SocketIO の処理
const socket = socketio.connect('http://localhost:3001')
socket.on('newCountValue', (newValue) => {
  Actions.receive(newValue) // 受信したら Action を叩く
})

// View (React コンポーネント)
class CountUpButton extends Component {
  constructor(props) {
    super(props)
    this.state = {
      text: !props || !props.text ? '' : props.text
    }
  }

  render() {
    return (
      <button type='button' 
              onClick={this.props.onClick}>
        {this.state.text}
      </button>
    )
  }
}

class App extends Component {
  // コンストラクタ
  constructor(props) {
    super(props)
    this.state = { counter: 0 }

    // データ受信時に State を更新
    counterStore.onReceive = () => {
      this.setState({ counter: counterStore.counter})
    }

    // 初期表示のため、現在のカウンタ値を取得
    Actions.query()
  }

  // コンポーネント描画
  render() {
    return(
      <div>
        <h3>{this.state.counter} いいね</h3>
        <CountUpButton 
          text='いいね!' 
          onClick={e => Actions.submit()}/>
      </div>
    )
  }
}

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

思ったこと

Action は、必ずしも View からキックされるとは限らない
  • WebSocket からのデータ受信が Action の実行契機になることもある。
Store は、必ずしも Singleton つとは限らない
  • この例で Store が 1 つだけなのは、単なる偶然である。 というか例が小規模すぎて、Store を分けるメリットが無かった。
Store に非同期処理を持たせるのはアンチパターンらしい(※1)
  • 上記のコードでは、Store (Query) から SuperAgent 経由で WebAPI を呼んでいる箇所があるが、どうやら非同期処理は Action に書くべきらしい。
Store から Action を呼ぶのはアンチパターンらしい(※2)
  • 上記のコードでは、Store (Query) から Action を呼んでいるが、これは Flux で一方通行化したデータの流れを逆走するようなものなので望ましくない。 今回は、Dispatcher が Action と Store の紐付けを適切に書き換えることで、問題を解消した。

※1, 2 を考慮して再度 Flux まわりを書き換えると、こんな感じだろうか。

// ----------
// Action
const ActionType = {
  SUBMIT: 'SUBMIT',
  RECEIVE: 'RECEIVE'
}

const Actions = {
  query: () => {    // 現在値取得
    request.get('/api/getCountValue')
      .end((err, data) => {
        if(!err) {
          appDispatcher.dispatch({
            actionType: ActionType.RECEIVE,
            value: data.body.counter
          })
        }
      })
  },
  submit: () => {  // カウンタ更新要求の送信
    appDispatcher.dispatch({
      actionType: ActionType.SUBMIT
    })
  },
  receive: (newValue) => {  // カウンタ受信
    if(newValue === null) return
    appDispatcher.dispatch({
      actionType: ActionType.RECEIVE,
      value: newValue
    })
  }
}

// ----------
// Store
const counterStore = {
  counter: 0,       // カウンタ(初期値)
  onSubmit: () => { // 送信イベント
    socket.emit('countUp')
  },
  onReceive: null   // 受信イベント
}

// ----------
// Dispatcher
const appDispatcher = new Dispatcher()

// Action と Store を紐づける
appDispatcher.register(payload => {
  switch(payload.actionType) {
    case ActionType.SUBMIT:   // 更新要求送信
      counterStore.onSubmit()
      break
    case ActionType.RECEIVE:  // カウンタ受信
      counterStore.counter = payload.value
      counterStore.onReceive()
      break
  }
})

あらためて、上記を考慮して再度リファクタリングを行なった結果をノーカットで以下に示す。

src/index.js (第2版)
import React, {Component} from 'react'
import ReactDOM from 'react-dom'
import request from 'superagent'
import socketio from 'socket.io-client'

// ===== ここから Flux =====
import {Dispatcher} from 'flux'

/*
                                  +--------+
                       +--------- | Action | ---------+
                       |          +--------+          |
                       v                              |
+--------+      +------------+      +-------+      +------+
| Action | ---> | Dispatcher | ---> | Store | ---> | View |
+--------+      +------------+      +-------+      +------+

・View      : React等のコンポーネント
・Action    : View 等から発火されて作られるイベント
・Dispatcher: 全てのアクションを受けてStoreにイベントを発火する
・Store     : アプリケーション全体のデータとビジネスロジック
             (必ずActionによってデータを更新する)

   <出典> http://qiita.com/knhr__/items/5fec7571dab80e2dcd92
*/

// ----------
// Action
const ActionType = {
  SUBMIT: 'SUBMIT',
  RECEIVE: 'RECEIVE'
}

const Actions = {
  query: () => {    // 現在値取得
    request.get('/api/getCountValue')
      .end((err, data) => {
        if(!err) {
          appDispatcher.dispatch({
            actionType: ActionType.RECEIVE,
            value: data.body.counter
          })
        }
      })
  },
  submit: () => {  // カウンタ更新要求の送信
    appDispatcher.dispatch({
      actionType: ActionType.SUBMIT
    })
  },
  receive: (newValue) => {  // カウンタ受信
    if(newValue === null) return
    appDispatcher.dispatch({
      actionType: ActionType.RECEIVE,
      value: newValue
    })
  }
}

// ----------
// Store
const counterStore = {
  counter: 0,       // カウンタ(初期値)
  onSubmit: () => { // 送信イベント
    socket.emit('countUp')
  },
  onReceive: null   // 受信イベント
}

// ----------
// Dispatcher
const appDispatcher = new Dispatcher()

// Action と Store を紐づける
appDispatcher.register(payload => {
  switch(payload.actionType) {
    case ActionType.SUBMIT:   // 更新要求送信
      counterStore.onSubmit()
      break
    case ActionType.RECEIVE:  // カウンタ受信
      counterStore.counter = payload.value
      counterStore.onReceive()
      break
  }
})

// SocketIO の処理
const socket = socketio.connect('http://localhost:3001')
socket.on('newCountValue', (newValue) => {
  Actions.receive(newValue) // 受信したら Action を叩く
})

// View (React コンポーネント)
class CountUpButton extends Component {
  constructor(props) {
    super(props)
    this.state = {
      text: !props || !props.text ? '' : props.text
    }
  }

  render() {
    return (
      <button type='button' 
              onClick={this.props.onClick}>
        {this.state.text}
      </button>
    )
  }
}

class App extends Component {
  // コンストラクタ
  constructor(props) {
    super(props)
    this.state = { counter: 0 }

    // データ受信時に State を更新
    counterStore.onReceive = () => {
      this.setState({ counter: counterStore.counter})
    }

    // 初期表示のため、現在のカウンタ値を取得
    Actions.query()
  }

  // コンポーネント描画
  render() {
    return(
      <div>
        <h3>{this.state.counter} いいね</h3>
        <CountUpButton 
          text='いいね!' 
          onClick={e => Actions.submit()}/>
      </div>
    )
  }
}

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

おまけ: diff

非 Flux 版と Flux 版の diff を取ってみた。 Component まわりがすっきりしていることが確認できる。

git diff src/index.js

diff --git a/src/index.js b/src/index.js
index 04c1337..ac3f2a7 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,8 +2,95 @@ import React, {Component} from 'react'
 import ReactDOM from 'react-dom'
 import request from 'superagent'
 import socketio from 'socket.io-client'
+
+// ===== ここから Flux =====
+import {Dispatcher} from 'flux'
+
+/*
+                                  +--------+
+                       +--------- | Action | ---------+
+                       |          +--------+          |
+                       v                              |
++--------+      +------------+      +-------+      +------+
+| Action | ---> | Dispatcher | ---> | Store | ---> | View |
++--------+      +------------+      +-------+      +------+
+
+・View      : React等のコンポーネント
+・Action    : View 等から発火されて作られるイベント
+・Dispatcher: 全てのアクションを受けてStoreにイベントを発火する
+・Store     : アプリケーション全体のデータとビジネスロジック
+             (必ずActionによってデータを更新する)
+
+   <出典> http://qiita.com/knhr__/items/5fec7571dab80e2dcd92
+*/
+
+// ----------
+// Action
+const ActionType = {
+  SUBMIT: 'SUBMIT',
+  RECEIVE: 'RECEIVE'
+}
+
+const Actions = {
+  query: () => {    // 現在値取得
+    request.get('/api/getCountValue')
+      .end((err, data) => {
+        if(!err) {
+          appDispatcher.dispatch({
+            actionType: ActionType.RECEIVE,
+            value: data.body.counter
+          })
+        }
+      })
+  },
+  submit: () => {  // カウンタ更新要求の送信
+    appDispatcher.dispatch({
+      actionType: ActionType.SUBMIT
+    })
+  },
+  receive: (newValue) => {  // カウンタ受信
+    if(newValue === null) return
+    appDispatcher.dispatch({
+      actionType: ActionType.RECEIVE,
+      value: newValue
+    })
+  }
+}
+
+// ----------
+// Store
+const counterStore = {
+  counter: 0,       // カウンタ(初期値)
+  onSubmit: () => { // 送信イベント
+    socket.emit('countUp')
+  },
+  onReceive: null   // 受信イベント
+}
+
+// ----------
+// Dispatcher
+const appDispatcher = new Dispatcher()
+
+// Action と Store を紐づける
+appDispatcher.register(payload => {
+  switch(payload.actionType) {
+    case ActionType.SUBMIT:   // 更新要求送信
+      counterStore.onSubmit()
+      break
+    case ActionType.RECEIVE:  // カウンタ受信
+      counterStore.counter = payload.value
+      counterStore.onReceive()
+      break
+  }
+})
+
+// SocketIO の処理
 const socket = socketio.connect('http://localhost:3001')
+socket.on('newCountValue', (newValue) => {
+  Actions.receive(newValue) // 受信したら Action を叩く
+})
 
+// View (React コンポーネント)
 class CountUpButton extends Component {
   constructor(props) {
     super(props)
@@ -12,23 +99,10 @@ class CountUpButton extends Component {
     }
   }
 
-  // WebSocket からの配信結果を親コンポーネントに通知
-  componentWillMount() {
-     socket.on('newCountValue', (newValue) => {
-      this.props.updateCounter(newValue)
-    })
-  }
-
-  // カウンタの更新処理
-  // WebSocket サーバにイベントを送信
-  countUp() {
-    socket.emit('countUp', {})
-  }
-
-  render(props) {
+  render() {
     return (
-      <button type='button'
-              onClick={e => this.countUp(e)}>
+      <button type='button' 
+              onClick={this.props.onClick}>
         {this.state.text}
       </button>
     )
@@ -40,27 +114,14 @@ class App extends Component {
   constructor(props) {
     super(props)
     this.state = { counter: 0 }
-  }
-
-  // 初期データ取得(ここだけ Ajax)
-  componentWillMount() {
-    request
-      .get('/api/getCountValue')
-      .end((err, data) => {
-        if(err) { return }
 
-        const resultJSON = data.body
-        const initValue = resultJSON.counter
-        this.setState({
-          counter: initValue
-        })
-      })
-  }
+    // データ受信時に State を更新
+    counterStore.onReceive = () => {
+      this.setState({ counter: counterStore.counter})
+    }
 
-  // 子コンポーネント(ボタン)から呼ばれる
-  // カウンタ更新処理
-  handleChange(newValue) {
-    this.setState({ counter: newValue })
+    // 初期表示のため、現在のカウンタ値を取得
+    Actions.query()
   }
 
   // コンポーネント描画
@@ -70,7 +131,7 @@ class App extends Component {
         <h3>{this.state.counter} いいね</h3>
         <CountUpButton 
           text='いいね!' 
-          updateCounter={e => this.handleChange(e)}/>
+          onClick={e => Actions.submit()}/>
       </div>
     )
   }