前回、React.jsは難しいのか?Redux代替にEventEmitterを使用して難易度を下げるの中で、Reduxを使用せずにEventEmitterでもモジュール間通信は実現できるという記事を書きました。ReduxがReactの敷居を上げているので、もっと簡易的にやる方法がないのか、という趣旨の記事です。今回は、前述の記事で紹介した方法を応用して、MVCのアーキテクチャとEventEmitterの組み合わせで、イベント駆動のWebアプリを作ってみたいと思います。
一応自分のことを紹介しておくと、React歴は9ヶ月ほどと長くはありませんが、自分のプロジェクトとしてSocket.ioを用いたチャット風サービスをリリースしております。そのサービスではReduxは使用せず今回紹介するのに近いアーキテクチャを採用しており、特に問題は感じていません。Reduxについては、Facebook等の大企業が大規模開発に採用しているのでメリットはあるのでしょうが、個人的に記述の煩雑さとぱっと見の理解のしにくさ等から敬遠しており、この記事を書くモチベーションになっています。
なぜ、MVCなのか
今回作るサンプルはMVC + EventEmitterの組み合わせとしています。MVCとは、Model、View、Contorollerの3要素で構成するGUIのアーキテクチャで、昔から多方面のGUIで採用されているようです。
なぜMVCなのか、それはReduxが則っているFluxというアーキテクチャの有用性は認めているのと、MVCはFluxで実現している単方向のデータフローに近い形を実現できるからです。
以下はFlux(とその実装であるRedux)のデータフロー

以下はMVCのデータフロー

用語は異なりますが、いずれのアーキテクチャでも、ユーザからの入力を何らかの変換(FluxならReducer、MVCならController)を行った上で、アプリケーション全体のデータストア(MVCならModel、ReduxならStore)に保持し、その変更がViewに反映されるという共通点があります。違いとして、Fluxの場合はReduxという枠組みでこのデータフローの実装が強制される、一方MVCの場合はデータフローを強制する枠組みを使用しないので、プログラマが責任を持ってデータの流れを実装してやる必要があります。
MVCとEventEmitterで実現したいアーキテクチャ
上記を踏まえた上で、MVCとEventEmitterでやりたい実装の概要は以下図のようになります。

今回はオンメモリで動かすだけの仮初Todo ListなのでWeb連携もDB/Storage利用も非同期化も有りませんが、拡張させるならこんな想定になります。なお、Model – View間に矢印が2本ありますが、点線はModelからViewにイベントを通じて「何があったのか」を通知するルート、もう一本の実線はViewが画面を更新する時に「アプリケーションの状態」を取得する為のルートです。
各要素をデータの流れに沿って擬人化して説明すると、以下の雰囲気になります。
- ユーザ「データ登録」
- View-A「登録ボタンがクリックされました」
- Controller「登録ボタンがクリックされたってことは、Modelにアイテムを追加だ」
- Model「アイテム追加されたわ〜、みんなー、追加しといたから後は勝手に更新ヨロ(イベント通知)」
- View-B「画面更新か!(イベント受信)。俺はざっくりな画面だからModelから最新のデータだけ取って更新!」
- View-C「画面更新か(イベント受信)、面倒だな〜…俺はちょっと細かい画面だから、Modelから該当リスト全部取って更新しないと…ハイ完了〜」
注意点としては、以下の関係を守ることです。
- ViewはControllerとModelを知っている(但しModelの更新はしない)
- ControllerはViewを知らずModelだけを知っている
- ModelはViewもControllerも知らないが、全体に通知は発行できる
作るもの = Todo Listサンプル(Redux公式から拝借)
今回MVC + EventEmitterの組み合わせで作るサンプルとして題材に選んだのは、Redux公式でサンプルコードとして紹介されているTodoListです。こんなの↓

リストの項目をクリックすると完了(Completed)の扱いになって、取消線が引かれます。

Activeボタンを押すと未完了(Active)な項目のみ表示され、Completedボタンを押すと逆に完了(Completed)した項目のみ表示されます。

Redux公式のソースはここにあります。
そして私が今回MVC + EventEmitterで作ったのが以下。

フォント以外は割と完コピしたつもり。
ソースコードはgithubのここに上げています。
ソースを全部記事内に書くと長くなってしまうので抜粋だけ書くと、以下のような感じです。
まずViewの一番上の層であるApp.js。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import "../App.css" import Footer from './Footer' import AddTodo from './AddTodo' import VisibleTodoList from './VisibleTodoList' function App() { return ( <div className="App"> <AddTodo /> <VisibleTodoList /> <Footer /> </div> ); } export default App; |
AddTodoが入力フォームとAddTodoボタン。VisibleTodoListがTodoで動的に変わるリスト。最後のFooterがShow All, Active, Completedと並んでいるフィルタボタン。これらを縦に並べた構造になっている。
次に、AddTodo(入力フォームと追加ボタン)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import React from 'react' import * as TodoController from '../controllers/TodoController' const AddTodo = () => { let input return ( <div> <form onSubmit={e => { e.preventDefault() if (!input.value.trim()) { return } TodoController.addTodo(input.value); input.value = '' }}> <input ref={node => input = node} /> <button type="submit"> Add Todo </button> </form> </div> ) } export default AddTodo |
ボタンを押されるとコントローラにTodo追加を依頼する。コントローラからModelへは単純にデータの横流しなので、コントローラ実装は割愛。
以下はModelの実装。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
import * as EventManager from '../EventManager' let todos = []; let nextTodoId = 0; export const getVisibleTodos = function(){ switch (filterState) { case VisibilityFilters.SHOW_ALL: return todos case VisibilityFilters.SHOW_COMPLETED: return todos.filter(t => t.completed) case VisibilityFilters.SHOW_ACTIVE: return todos.filter(t => !t.completed) } } export const VisibilityFilters = { SHOW_ALL: 'SHOW_ALL', SHOW_COMPLETED: 'SHOW_COMPLETED', SHOW_ACTIVE: 'SHOW_ACTIVE' } let filterState = VisibilityFilters.SHOW_ALL; export const getFilterState = function(){ return filterState; } export const addTodo = function(text){ let todo = {id:nextTodoId++, text:text, completed:false} todos.push(todo); EventManager.emit("UpdateTodo", {}); } export const toggleItemState = function(id){ if(!todos[id])return; todos[id].completed = !todos[id].completed; EventManager.emit("UpdateTodo", {}); } export const setFilterState = function(sts){ filterState = sts; EventManager.emit("UpdateTodo",{}) } |
addTodo、toggleItemState、setFilterStateではTodoの状態が変化するので、最後にEventManager.emitを発行して変更を通知しているのがポイント。Viewがこれを受信して状態を変化させる。
最後にまたViewに戻ってきて、VisibleTodoList(タスク一覧)の辺り。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
import {getVisibleTodos} from '../models/TodoModel' import {toggleItemState} from '../controllers/TodoController' import Todo from './Todo' import { useEffect, useState } from 'react'; import { registerEvent, unregisterEvent } from '../EventManager'; function VisibleTodoList() { const [todos,setTodos] = useState(getVisibleTodos()); //受信するイベントのリスト const eventMap = { "UpdateTodo":(msg)=>{ const todo_list = getVisibleTodos(); setTodos(todo_list.slice()); //リストsetしても常に変更された扱いにならないのでsliceする } } //初回と終了時のみの処理を書く useEffect(()=>{ registerEvent(eventMap); //初回のみ受信イベントの登録 return ()=>{ unregisterEvent(eventMap); //終了時解除 } },[]) return ( <ul> {todos.map(todo => <Todo key={todo.id} {...todo} onClick={() => toggleItemState(todo.id)} /> )} </ul> ) } export default VisibleTodoList; |
eventMap(イベント名とコールバックがならんだオブジェクト)に待ち受けるイベントと、受信時の動作(Listener)を登録する。今回は”UpdateTodo”イベントの受信しか無いが、他に複数並べることも出来る。Modelからのイベントが受信されると、eventMapに登録されたListenerのメソッドが呼ばれることになる。
EventManagerはEventEmitterの薄いwrapperで、SingletonのEventEmitterに対し、Viewのイベント登録・解除やModelからのイベント発行を行えるようにしたもの。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import EventEmitter from "events"; const emitter = new EventEmitter(); export const registerEvent = function( eventMap ){ for( let key in eventMap){ emitter.on(key,eventMap[key]); } } export const unregisterEvent = function( eventMap ){ for( let key in eventMap){ emitter.off(key,eventMap[key]); } } export const emit = function (event, param){ emitter.emit(event, param); } |
メリット・デメリット
小規模とはいえ同じ仕様のアプリケーションを、別々のアーキテクチャで作り分けてみると文献を読むのとは違った知見を得られます。個人的感覚ですが、メリット・デメリットを比較すると以下のようになりました。
◎MVC + EventEmitterについて
◆メリット
- 特別にライブラリの知識がなくても、ソースコードを追っていけば処理内容や処理順序が分かる(EventEmitterの知識は要るが、割とすぐ理解可能)。
- よって初心者や他言語の経験者等でも実装を始めやすい。
◆デメリット
- プログラマが気をつけないとViewがModelを直接更新したりEvent発行したりできてしまう。
- MVCって言葉があんまり流行ってないので、導入ハードルがあるかも(意外と流行ってるかどうかって重視されたりする気がする)
◎Reduxについて
◆メリット
- データの流れが強制されるので、新規メンバがデータフローを乱すことは無い
- みんな使ってる(Facebookで使ってる)、という理由で導入しやすい。
◆デメリット
- 難解。Reduxは以前ある程度勉強していたが、今回久しぶりにReduxのコードを眺めていたら、正直どうやってデータを追うんだったか殆ど忘れていて最初読めなかった。
- 基本Store側で通知するデータのみに基づいて表示が更新されるので、だれが何を表示したいかStore側である程度気を使ってデータ更新してやらないといけない?※(後述)
◆同じくらいだったもの
- Step数: Redux 256行 / MVC + EventEmitter 263行 (※)
- File数: Redux 13個 / MVC + EventEmitter 10個 (※)
(※いずれもsrc配下のみ、テストコード等機能と無関係なソースは除く)
難解なものを取り入れると結局新規メンバへの説明や学習コストが必要になるし、それならMVCでデータフロー乱さないように説明・教育しても同じだと思うんです。そもそもMVCでデータフローを乱しうるのは、View→Modelの直接更新がよくありそうですが、仮に直接更新してしまったとしても実はそれほど大きな問題にはなりません。①ユーザ入力含めたアプリ状態が一元的にModelで管理される、②Modelの変更時、影響を受けるViewが自動で更新される。この2点が担保されている限り、Controllerを介さない更新くらいではそうそう変なことにはならないからです。
MVC + EventEmitterのアーキテクチャも改良の余地はあると思うのですが、Reduxを使う場合より、少なくとも難易度は低減できると思いました。
※Reduxのデメリット2については少しわかりにくいですが、例えば、Todo登録後、全データを名前順に並べ替えて表示する画面Aと、最新だけ表示すればいい画面Bの2つがあった時に、Storeからは名前順のデータをUIに送ってしまうとB側が困るし、最新だけ送るとA側が困る、そんな状況のことを言ってます。名前順ソートや最新抽出のロジックをUI側に実装するか、AにもBにも配慮した最新+名前順の結合データを送る等の美しくない解決方法くらいしか私には思いつかないです。画面更新のタイミングだけ教えてもらって、後は各画面がModelの任意の関数でデータ取得し更新する方法、例えばA画面はgetNameSortedTodos、B画面はgetLatestTodo等でModelからデータを貰ってきて表示更新する方法なら、考えることが少なくてすみます。
→一応Reduxでもstore.getStore()という関数でStore状態全部を取得できるので、同じような事(画面更新用任意データ取得)ができるかもしれない?でも、getStoreしてデータ加工してUIに渡すコードは、例のReduxのデータフローの中のどこに書くんだろう?その層を追加しちゃってもFluxと言えるのだろうか?
※なお今回の私のサンプルはReact Hooksの機能を使って書いていますが、Class Componentの書き方でも問題も無く書くことが出来ます。Hooks特有の書き方はアーキテクチャ全体の難易度の本質とは無関係なので、今回は触れていません。
※Todoなのにサンプルが食べ物の名前なのは、私が今日、妻の買い物の頼みを忘れ怒られて、それで頭がいっぱいだったから。これは買い物リストなのです。
コメント