VueとFluxアーキテクチャを使用してwebアプリを制作したので実際のコードを用いてFluxの仕組みと具体的な実装方法についてまとめました。
Fluxとは
FluxとはMeta社(Facebook)が発案したwebアプリケーションのフロント部分を開発するためのアーキテクチャです。
Fluxはデータの流れを一方向に制限する事でアプリケーション全体に秩序を持たせ、管理開発しやすくするものです。
Fluxのデータの流れ
Fluxのデータの流れのイメージは以下になります。
- web apiなどからデータを受け取る
- 描画される
- ユーザーがイベントを起こすとアクションをディスパッチャーに通知
- ディスパッチャーが適切な処理実行
- データが更新される
- データの更新を検知し、再描画
1~6の流れをぐるぐる繰り返し、アプリケーション全体を動かしていきます。
Fluxはライブラリなどのように、誰かが制作した何かを使用するのではなく、考え方ですので、実際のコードは自分で実装する必要があります。
実装にはデザインパターンのObserverパターンもしくはPubSubパターンを使用しています。
Observerパターン/PubSubパターンとは
Observerパターン/PuSubパターンとはデザインパターンの一種です。
どちらも似たような仕組みを提供しますが、ObserverよりもPubSubの方が高機能です。
その変わりPubSubの方が実装が難しいので、必要な機能によって使分けると言った形となります。
Fluxではどちらを使用してもよいのですが、今回はObserverパターンを使用して実装を行います。ですので解説はObserverのみにします
Observerパターン
Observerパターンとは、例えるならレストランと、そこに予約した顧客のような関係です。
レストランを予約後、予約内容に変更があれば顧客から連絡を行う事は当たり前です。
でなければ毎度レストランからお客さんに対して予約内容の変更はないかの連絡を入れて確認する必要が出てきます。
そうではなく、変更があればお客さんの方から連絡を入れてもらい予約内容を変更する。
これが最も効率が良いですよね。
Observerパターンはこの仕組みをプログラムにも取り入れたものになります。
監視する側と監視される側に分かれ、監視される側に何か変更があれば監視役に「変更がありました!」とお知らせを送ります。
すると監視側はその通知を受とり、次のアクションにつなげます。
今回のFluxの場合はVueで制作したボタンなどが監視される側になります。
ユーザーがボタンをクリックするなどのイベントを行うと、監視役に「クリックしたよ!」とアクションを伝えます。
事前に監視役にはクリックした時に実行する処理を設定しておき、アクションを受け取ると「クリックしたよ!」というアクションに紐づいている処理を実行しデータの内容を更新します。
「クリックしたよ!」というアクションに紐づいている処理 を探す役目を行うのがディスパッチャーの役割です。
Observerパターンを実装
以下が実装したコードになります。
export interface registerEvent<T> { event(data: T, option?:any):void; } export default class Observer<T> { private eventList:{ [s: string]: registerEvent<T> } = {}; private rawData:T; constructor(rawData: T) { this.rawData = rawData; } /** * イベントをマップに登録する。 * @param name - 登録する時の名前。同じ名前は使えない * @param event 登録したいイベントの関数(registerEvent)を継承している事 * @returns 登録できたかすでに使用されている名前かを返す */ public register(name: string, event: registerEvent<T>):boolean { if(this.eventList[name] != null) return false; this.eventList[name] = event; return true; } /** * イベントをマップから消す * @param name - 登録した時の名前 * @returns 消せたかどうかを返すtrue => 削除 false => 操作無し */ public remove(name: string):boolean { if(this.eventList[name] == null) return false; delete this.eventList[name]; return true; } /** * actionからの値を元に実行すべきイベントを実行する * @param name - イベントを登録したときに使用した名前 */ private dispatcher(name: string, option?:any):void { if(this.eventList[name] == null) return; if(typeof this.eventList[name].event === 'function') this.eventList[name].event(this.rawData, option); } /** * 実際に呼び出されるメソッド。dispatcherに通知をする * @param name - イベントを登録したときに使用した名前 * @param option - 追加の引数を渡す事が可能 */ public action(name:string, option?:any):void { if(name == null) return; this.dispatcher(name, option); } /** * 登録されているイベントをconsoleに表示 */ public printEvent():void { let keys:string[] = Object.keys(this.eventList); if(keys.length <= 0) return; console.log('printEvent'); keys.map(item => { console.log(item); }); } }
Observerクラスはハッシュマップを持っており、そこに各イベントをマッピングして各処理を保持します。
registerでイベントを登録する
各UIコンポーネントがユーザーからのイベントを検知し、actionを通知した際に実行される処理をregisterメソッドを使用して登録します。
registerメソッドは第一引数に登録する処理の名前、第二引数に実際の処理を渡す事でObserverクラス内のeventListプロパティに値を追加します。
eventListはハッシュマップなので処理の名前は重複させる事はできません。
↓↓ registerでイベントを登録するサンプルコード ↓↓
// Observerを読み込み import Observer, { registerEvent } from './Observer'; // Observerをインスタンス化 let observer:Observer = new Observer(); // 登録するイベントを制作 class SampleEvent implements registerEvent<storeType>{ public event(store:storeType, option:{value:number, index:number}):void { console.log(`SampleEvent: ${option.value}, ${option.index}`); } }; // registerメソッドを使用してイベントを登録 // 登録が正常に完了するとtrueが返される let res = observer.register('sampleEventName', new SampleEvent()); if(res === true) console.log('登録が完了しました。');
登録するイベントはregisterEvent型を継承します。
registerEventを継承したクラスはeventメソッドをオーバーライドし、その中に実行するイベントを記載する必要があります。
登録してあるイベントをactionから実行する
各UIコンポーネントがユーザーからのイベントを検知するとactionメソッドを実行し、イベントが発生した事をdispatcherに通知します。
第一引数にregisterで登録した、実行したい処理の名前を渡す事で実行可能です。
オプションとして第二引数からオブジェクトを渡す事が可能です。
actionメソッドの処理の内容としは、引数をそのままにdispatcherメソッドを呼び出しているだけです。
actionメソッドの使い方のサンプルコード
let eventHandler = ():void => { let option:{ value: number, index: number } = { value: 0, index: 0 }; // sampleEventNameに紐づく処理を実行 observer.action('sampleEventName', option); }
ユーザーのアクションによりeventHandlerが実行されるとactionメソッドが実行され、 dispatcherに通知が行きます。
dispatcherメソッド
dispatcherメソッドはactionから受け取ったイベントの名前に応じて実行する処理を探して実行します。
registerメソッドで登録したイベントはeventListにハッシュマップとして登録されているのでイベントのメソッド名をオーダーとして検索する事が可能です。
もしもマップ上になければfalseを返し何も実行はしません。
イベントが登録してあればeventメソッドを実行します。
registerからイベントを登録する際にregisterEvent型を継承する必要があり、eventメソッドはregisterEvent型で存在が保証されています。
実際に簡単なカウンターアプリケーションを制作してみる
VueとFluxを使用して以下のような簡単なカウンターアプリを実装してみます。
先ほど制作したObserverはそのまま使用します。
Observer.tsを制作し以下のコードを記載します。
export interface registerEvent<T> { event(data: T, option?:any):void; } export default class PubSub<T> { private eventList:{ [s: string]: registerEvent<T> } = {}; private rawData:T; constructor(rawData: T) { this.rawData = rawData; } /** * イベントをマップに登録する。 * @param name - 登録する時の名前。同じ名前は使えない * @param event 登録したいイベントの関数(registerEvent)を継承している事 * @returns 登録できたかすでに使用されている名前かを返す */ public register(name: string, event: registerEvent<T>):boolean { if(this.eventList[name] != null) return false; this.eventList[name] = event; return true; } /** * イベントをマップから消す * @param name - 登録した時の名前 * @returns 消せたかどうかを返すtrue => 削除 false => 操作無し */ public remove(name: string):boolean { if(this.eventList[name] == null) return false; delete this.eventList[name]; return true; } /** * actionからの値を元に実行すべきイベントを実行する * @param name - イベントを登録したときに使用した名前 */ private dispatcher(name: string, option?:any):void { if(this.eventList[name] == null) return; if(typeof this.eventList[name].event === 'function') this.eventList[name].event(this.rawData, option); } /** * 実際に呼び出されるメソッド。dispatcherに通知をする * @param name - イベントを登録したときに使用した名前 * @param option - 追加の引数を渡す事が可能 */ public action(name:string, option?:any):void { if(name == null) return; this.dispatcher(name, option); } /** * 登録されているイベントをconsoleに表示 */ public printEvent():void { let keys:string[] = Object.keys(this.eventList); if(keys.length <= 0) return; console.log('printEvent'); keys.map(item => { console.log(item); }); } }
親コンポーネントの制作
親コンポーネントファイルを制作していきましょう。
index.vue
<template> <div> <div>{{counter}}</div> <Button v-bind:label="countUpData.label" v-bind:Observer="countUpData"/> <Button v-bind:label="countDownData.label" v-bind:Observer="countDownData"/> </div> </template> <script> import Button from '../../../component/Button'; import Observer from '../../../lib/Observer'; // データを制作 // countUpDataがカウンターをインクリメントするボタンのデータ。 // countDownDataがカウンターをデクリメントするデータ。 // actionが親に通知をするためのobserver.actionメソッドを格納するプロパティ。 // nameに呼び出す処理の名前をセット。 let store = { counter: 0, countUpData: { label: '+1', action: null, name: 'countUpEvent' }, countDownData: { label: '-1', action: null, name: 'countDownEvent' }, }; let observer = new Observer(store); // actionメソッドを格納 store.countUpData.action = observer.action.bind(observer); // カウンターをインクリメントする処理を制作 class CountUpEvent { event(store) { console.log(`CountUpEvent`); store.counter++; } } // インクリメント処理をobserverに登録 observer.register('countUpEvent', new CountUpEvent()); // actionメソッドを格納 store.countDownData.action = observer.action.bind(observer); // カウンターをデクリメントする処理を制作 class CountDownEvent { event(store) { console.log(`CountDownEvent`); store.counter--; } } // デクリメント処理をobserverに登録 observer.register('countDownEvent', new CountDownEvent()); export default { data: function() { return store; }, components: { Button } } </script>
データ
今回使用するデータはまとめて18~30行目にstoreとして定義しています。
let store = { counter: 0, countUpData: { label: '+1', action: null, name: 'countUpEvent' }, countDownData: { label: '-1', action: null, name: 'countDownEvent' }, };
counterで現在のカウント値を保持。
countUpDataがカウンターの値をインクリメントするボタンのデータ。
countDownDataがカウンターをデクリメントするボタンのデータ。
UI(template)部分
ユーザーに表示されるtemplate部分は1~7行目に定義しています。
<template> <div> <div>{{counter}}</div> <Button v-bind:label="countUpData.label" v-bind:Observer="countUpData"/> <Button v-bind:label="countDownData.label" v-bind:Observer="countDownData"/> </div> </template>
3行目に現在のカウンターの値を表示し、4,5行目に次のセクションで紹介するボタンコンポーネントをを使用してインクリメントボタンとデクリメントボタンを表示しています。
処理の制作と登録
ボタンをクリックした際に実行される処理の制作とobserverへの登録を32~56行目で行っています。
let observer = new Observer(store); // actionメソッドを格納 store.countUpData.action = observer.action.bind(observer); // カウンターをインクリメントする処理を制作 class CountUpEvent { event(store) { console.log(`CountUpEvent`); store.counter++; } } // インクリメント処理をobserverに登録 observer.register('countUpEvent', new CountUpEvent()); // actionメソッドを格納 store.countDownData.action = observer.action.bind(observer); // カウンターをデクリメントする処理を制作 class CountDownEvent { event(store) { console.log(`CountDownEvent`); store.counter--; } } // デクリメント処理をobserverに登録 observer.register('countDownEvent', new CountDownEvent());
observerをインスタンス化後、ボタンコンポーネントでactionメソッドを呼び出す事ができるようにstore.countUpData.actionにactionメソッドを格納します。
※参照先をbindするためにbindメソッドの使用を忘れずに。
37~42行目で実際に実行する処理を定義してます。
処理の内容としては簡単で、counterの値をインクリメントするだけです。
※ボタンクリックでディスパッチャーイベントを通知後、eventメソッドが実行されるので必ず処理はeventの中に記載する必要があります。
次に制作した処理をobserverに登録します。
registerメソッドを使用して登録しますが、registerは第一引数に登録する時の名前、第二引数に実行する処理のインスタンスをセットします。
今回はstore.countUpData.name(インクリメントボタンのクリックイベントがあった際に通知される名前)にcountUpEventとセットしています。
ですので、こちらと同じくcountUpEventでインクリメント処理を登録します。
残りのデクリメント処理の制作と登録は同じような流れで実装可能です。
子コンポーネントの制作
ボタンとなる子コンポーネント、Button.vueを制作します。
<template> <button v-on:click="clickHandler">{{label}}</button> </template> <script> export default { props: ['Observer', 'label'], methods: { clickHandler: function() { console.log(`clickHandler`); this.Observer.action(this.Observer.name); } } } </script>
button要素を設置し、v-on:clickを使用してボタンをクリックする度にclickHandler関数が呼ばれるようにします。
clickHandlerでObserver.actionを実行し、親にイベントが発生した事を通知します。
この時引数から渡すイベントの登録名はObserver.nameに設定した名前になります。
以上。