大きな規模のWebアプリケーション、例えばコンポーネントのが4,5階層になるようなアプリをVueのデフォルト機能を使用して制作すると起こる問題として、『データ』や『$emit』のバケツリレーがあります。
この問題を解決するために各パーツ、コンポーネントにそのコンポーネントで使用する処理を閉じ込めるという対応をすると、依存性が高くなり、再利用性が低下する原因となります。
そんな課題を解決するためにFluxを採用してみたところ、うまくいったのでその設計についてまとめます。
前提として、設計を行う上で抑えておきたい以下のポイントがあります。
- コンポーネントには共通のAPI形式を定義し、どのコンポーネントともに連結/切り離しが可能で、依存性を失くす。
- コンポーネントの再利用性を高める
そしてこの要件を満たすために以下を徹底しました。
- データ更新の流れはFluxにのっとる
- 各コンポーネントにはその中で完結する処理以外は持たせない
- 各コンポーネントはその中で完結するデータ以外は持たせない
ディレクトリ構造
まず、ディレクトリ構造から理解すると全体像が理解でき、イメージがしやすいかと思います。
プロジェクト全体を掲載すると、今回関係のないcssやWebAPI通信用のコードが入ったディレクトリなども含まれてまうので、説明に使用するもののみ記載します。
ディレクトリ構造は以下のようになっています。
js/component → componentレベルのパーツを格納 js/project → projectレベルのパーツを格納 js/layout → layoutレベルのパーツを格納 js/lib → アプリ全体で使用する関数や処理を格納
それぞれのディレクトリの役割は記載されている通りです。
componentにボタンやテキストインプットなど、使いまわせる最小のパーツ単位のコンポーネントを格納します。
そのコンポーネントを使用してフォームや検索ボックスなど、1つの機能を制作します。
コンポーネントを組み合わせて制作したパーツをプロジェクトと呼び、projectに格納します。
そのプロジェクトを複数集めて1つのページを構成します。
その際、プロジェクトを適切な箇所に配置する役目が必要ですので、プロジェクトを集めたものをレイアウトと呼び、layoutに格納します。
libフォルダはアプリ全体で使用する関数や処理をまとめ、それらを各プロジェクトやコンポーネントで再利用すると言った形です。
全体の設計
今回のメインとなるアプリ全体の設計の説明をします。
アプリ制作をする際に大切になる事がデータの更新方法ですが、現在、ほとんどの場合MVC設計を取り入れ、データのやり取りを一本化する事が主流かと思います。
そのMVC設計の中でも今回はFluxを取り入れました。
Fluxについては以下の記事にサンプルコードとともに簡単に説明していますので参考にどうぞ
では、Fluxは知っている前提で話を進めます。
今回の設計のポイントはコンポーネントの再利用性と簡単に連結/切り離しが可能という点でした。
Fluxを使用する事により以下のようなデータ構造を取ることが可能です。
情報がその中で簡潔するパーツを1つのプロジェクトとし、プロジェクトの中に必要なコンポーネントをぶら下げ、そこで使用するデータをFluxのサイクルで回して運用します。
上記コンポーネントツリーをDOMにすると以下のようになります。
<div id="root"> <ComponentA> <ComponentE /> <ComponentF /> </ComponentA> <ComponentB /> <ComponentB /> <ComponentC> <ComponentG /> <ComponentG /> </ComponentC> <ComponentD /> </div>
rootがprojectのtopになり、全てのデータすべての処理を保持し、Observerへの登録などを行います。
AからGのnodeが各コンポーネントとなり、上記画像のようにツリー構造で連結し、この中でデータをFluxに従い更新します。
各コンポーネントはツリーのどの箇所からも切り離したり、どの箇所にも連結できるようにします。
各コンポーネントのデータ(状態)と処理
AからGの各コンポーネントのデータ(状態)と処理はroot(project)に持たせ、コンポーネント自体は*自己完結するもの以外は何も持たない状態にします。
*例えばテキストインプットなら、フォーカスが当たった場合につけるclass名を定数で保持する場合などはコンポーネント内でそのデータを保持してもよいでしょう。
状態A=1と処理AはコンポーネントAの値、状態B=2と処理BはコンポーネントBの値と言った具合に関連しています。
ですのでコンポーネントはツリー構造ですが、状態や処理は平行で保持しているイメージです。
こうする事により、このツリー構造を作り替える事で様々なパーツを制作する事が可能です。
例えばテキストインプットとボタンコンポーネントをつなげてログインフォームプロジェクトを制作出来ます。
ログインフォームなのでパスワード入力がありますが、パスワード入力には英数字のみを受け付けるためのバリデーションが必要になるかもしれません。
バリデーション処理を各コンポーネントの中に記載しそうですが、図の『処理』の部分に外だしで定義する事で、ログインフォームで使用したテキストフィールドコンポーネントを再利用して新たに検索ボックスプロジェクトを制作することも可能となります。
そして制作した各プロジェクトをさらにレイアウト内でツリー構造にしてつなげる事で1つのアプリケーションを構築する事が可能となります。
上記図のようにデータはそれぞれのプロジェクトの中でサイクルを回しているので、各プロジェクトツリーはroot部分から簡単に切り離しが可能ですし、各コンポーネントはどこからでも連結/切り離しが可能となり、無限の拡張性を持った形で制作する事が可能となります。
Fluxのデータ更新サイクルのイメージ
データの更新は上記の図のようにFluxのデータ更新の流れにそって行います。
上記図の『データ』がキャプション” project-データ(状態)処理 ”の図の『状態』のA=1,B=2の部分となり、上記図の『処理』が キャプション” project-データ(状態)処理 ” の図の『処理』のA,Bの部分となります。
Fluxのデータ更新の具体的な流れ
次は具体的にユーザーアクションからコンポーネントのデータを更新するまでの流れ説明します。
例として簡単なログインフォームを取り上げてみます。
ログインフォームには以下を満たします。
- ユーザー名、パスワード、ログインボタンの3つのパーツから構成
- ユーザー名、パスワード入力フィールドはユーザーからのテキスト入力を受け付ける
- パスワード入力フィールドは英数字しか受け付けない
- ログインボタンはユーザー名とパスワードが両方入力されていなければ非活性状態となる
- ログインボタンをクリックするとユーザー名、パスワードがconsoleに出力される
テキストフィールドやボタンのコンポーネントの細かい実装の説明は一旦おいて、このログインフォームのデータの流れを今回の設計通りに更新してみます。
全体設計をコードに落とし込む
全体の設計がわかったところで具体的に設計をコードに落とし込みます。
こちらのファイルがprojectのrootになり、『DOM』『状態』『処理』を記載してFluxの仕組みを実現するObserverに登録していきます。
index.vue
<template> <div> <TextInput v-bind:data="userInput"/> <TextInput v-bind:data="passInput"/> <Button v-bind:label="button.label" v-bind:Observer="button" v-bind:disabled="button.disabled"/> </div> </template> <script> import Button from '../../component/Button.vue'; import TextInput from '../../component/TextInput.vue'; import Observer from '../../lib/Observer'; // データを制作 let store = { userInput: { value: null, action: null, name: 'userKeyEvent' }, passInput: { value: null, action: null, name: 'passKeyEvent' }, button: { label: 'ログイン', disabled: true, action: null, name: 'clickEvent' } } let observer = new Observer(store); // userInputにキー入力があった時の処理 store.userInput.action = observer.action.bind(observer); class UserKeyEvent { event(store) { console.log(`UserKeyEvent`); ( store.userInput.value !== null && store.userInput.value !== '' && store.passInput.value !== null && store.passInput.value !== '' ) ? store.button.disabled = false : store.button.disabled = true; } } observer.register('userKeyEvent', new UserKeyEvent()); // passInputにキー入力があった時の処理 store.passInput.action = observer.action.bind(observer); class PassKeyEvent { event(store) { console.log(`PassKeyEvent`); // 英数字以外を消してvalueに再代入 if(store.passInput.value !== null) { let fixedInput = store.passInput.value.match(/[a-zA-Z0-9]/g); fixedInput = (fixedInput !== null) ? fixedInput.join('') : ''; store.passInput.value = fixedInput; } ( store.userInput.value !== null && store.userInput.value !== '' && store.passInput.value !== null && store.passInput.value !== '' ) ? store.button.disabled = false : store.button.disabled = true; } } observer.register('passKeyEvent', new PassKeyEvent()); // ボタンをクリックした時の処理 store.button.action = observer.action.bind(observer); class ClickEvent { event(store) { console.log(`ClickEvent`); console.log(`user: ${store.userInput.value}, pass: ${store.passInput.value}`); } } observer.register('clickEvent', new ClickEvent()); export default { data: function() { return store; }, components: { Button, TextInput } } </script>
DOM構造
1行目~7行目にDOM構造を定義しています。
divがroot要素となり、その下にテキストインプットとボタンコンポーネントが並列で並ぶ構造となっています。
各コンポーネントの作りは後ほど解説します。
データと処理
各コンポーネントは状態と処理を持っていますが、これらは外部から注入するため、こちらのファイルにまとめて記載します。
15行目~32行目にデータを記載し、
37行目~83行目に処理を記載しています。
説明のサンプルとしてpassInputを上げると、52~83行目に処理を記載しております。
まず、actionに通知のためのメソッドを持たせるため、52行目でobserver.actionを渡します。
store.passInput.action = observer.action.bind(observer);
次に登録する処理を定義します。
処理は53行目~71行目に定義しており、eventメソッドの中で英数字を消して再代入する処理とuserInputとpassInputのvalueに値が存在すればログインボタンのdisabledを解除する処理を実装しています。
そして、registerメソッドを使ってpassKeyEventという名前で制作したイベントを登録します。
コンポーネントの設計
全体の設計は完了しましたので、次は今回の例で使用するテキストフィールドとボタンコンポーネントの設計です。
コンポーネントには状態や処理は持たせず、イベントを親に通知するだけの、極力シンプルな形にします。
テキストフィールド
<template> <div> <input type="text" v-model="data.value" v-on:keyup="keyUpEvent"/> </div> </template> <script> export default { props: ['data'], methods: { keyUpEvent: function() { if( this.data.hasOwnProperty('action') && typeof this.data.action == 'function' ) { this.data.action(this.data.name); } } } } </script>
keyupイベントが発生するとactionメソッドを実行し、ディスパッチャーに通知を送るだけの機能しか実装していません。
ボタン
<template> <button v-on:click="clickHandler" v-bind:disabled="disabled" v-bind:class="[{'-disabled': disabled}]">{{label}}</button> </template> <script> export default { props: [ 'Observer', 'label', 'disabled' ], methods: { clickHandler: function() { console.log(`clickHandler`); this.Observer.action(this.Observer.name); } } } </script>
こちらも同じく、clickイベントが発生するとactionメソッドを実行し、ディスパッチャーに通知を送るだけの機能しか実装していません。
処理の実行タイミングをコントロールする
極力各コンポーネントには状態や処理は持たせませんが、コンポーネントによっては持たせた方がよい場合があります。
その際に、外部から注入している処理の実行タイミングをコントロールしたいという場面に出くわします。
その要求に対応するために、処理の実行タイミングをコントロールする機能を実装しました。
interface argsTypes { defaultEvent: any | null; additionalEventData: any | null; actionPropertyName: string; optionArgs?: any; } export default class ActionHookController { private eventList: any[] = []; private defaultEvent: any | null = null; private additionalEvent: any | null = null; private additionalEventData: any | null = null; private actionPropertyName: string = ''; private optionArgs?: any | null = null; constructor(obj:argsTypes) { this.defaultEvent = obj.defaultEvent; this.additionalEventData = obj.additionalEventData; this.actionPropertyName = obj.actionPropertyName; this.optionArgs = (obj.optionArgs !== null) ? obj.optionArgs : null; } public init():void { this.eventList = []; this.eventList.push(this.defaultEvent); this.additionalEvent = ( this.additionalEventData !== false && this.additionalEventData.hasOwnProperty(this.actionPropertyName) && typeof this.additionalEventData[this.actionPropertyName] === 'object' && this.additionalEventData[this.actionPropertyName].hasOwnProperty('action') && this.additionalEventData[this.actionPropertyName].action != null && typeof this.additionalEventData[this.actionPropertyName].action === 'function' ) ? this.additionalEventData[this.actionPropertyName].action : null; } public start():void { if(this.additionalEventData[this.actionPropertyName].hasOwnProperty('loc')){ switch (this.additionalEventData[this.actionPropertyName].loc) { case 'override': this.eventList = (this.additionalEvent != null) ? [this.additionalEvent] : []; break; case 'before': if(this.additionalEvent != null) this.eventList.unshift(this.additionalEvent); break; case 'after': if(this.additionalEvent != null) this.eventList.push(this.additionalEvent); break; default: } } else { if(this.additionalEvent != null) this.eventList.push(this.additionalEvent); }; for(let event of this.eventList) { if(typeof event === 'function') event(this.additionalEventData[this.actionPropertyName].name, this.optionArgs); }; } }
locプロパティに実行タイミングを記載する事で登録した処理を実行するタイミングを変更する事ができます。
実行のタイミングは以下の通りです。
名前 | タイミング |
override | デフォルトの処理を上書きして実行します(デフォルトの処理は実行されません) |
before | デフォルトの処理の前に実行します |
after(デフォルト) | デフォルトの処理の後に実行します |
使用例
例えば、検索機能付きのセレクトボックスコンポーネントを実装したとします。
検索機能や選択肢を開く機能の処理が必要になりますが、コンポーネントを使用する度にこの処理を外部からセットする事は面倒です。
こちらのコンポーネントを使用するという事は必ず検索や選択肢を開く機能は必要となってきますし、外のコンテンツと連携する事はおそらくないのでコンポーネント内に閉じ込めてもよいでしょう。
ただ、何かしらの処理を外部から注入する事はできるような仕様にしておくと、後からコンポーネントを拡張したい時に便利ですので、ActionHookControllerを使用して可能にします。
// component let defaultEvent = () => { // デフォルトの処理 }; let actionHookController = new ActionHookController({ defaultEvent: defaultEvent, additionalEventData: 'event data', actionPropertyName: 'event name' }); // 初期化 actionHookController.init(); // 実行 actionHookController.start(); actionHookController = null; defaultEvent = null; // project root 'event name': { action: null, name: 'register name', loc: '実行タイプ' }
各コンポーネントのイベント処理に2から13行目の処理を記載します。
defaultEvent関数の中にデフォルトの処理を記載し、7行目でActionHookControllerにセットします。
ActionHookControllerには引数としてadditionalEventData,actionPropertyNameを取る事ができます。
additionalEventData → observer.actionのイベントをセット
actionPropertyName → observer.registerでセットしたイベントの名前
各eventデータのプロパティにはlocをセットする事ができ、locの値で追加の処理の実行タイミングをコントロールする事が可能です。
実際に使用した場合、以下のようなコートとなります。
project rootファイル
<template> <div> <div style="margin: 30px;"> <ComboBox v-bind:data="comboBox[0]"/> </div> </div> </template> <script> import ComboBox from '../../component/ComboBox.vue'; import Observer from '../../lib/Observer'; let store = { comboBox: [ { data: { selected: '', options:['items1','items2','items3','items4','items5'], isOpen: false, }, event: { focus: { action: null, name: 'comboFocusEvent' }, blur: { action: null, name: 'comboBlurEvent' }, optionClick: { action: null, name: 'comboClickEvent' }, search: { action: null, name: 'searchEvent', loc: 'override' } } } ] } let observer = new Observer(); store.comboBox[0].event.search.action = observer.action.bind(observer); class SearchEvent { event() { console.log('searchEvent!'); if(store.comboBox[0].data.selected == 1) { store.comboBox[0].data.selected = 'item1'; } } } observer.register('searchEvent', new SearchEvent()); export default { data: function() { return store }, components: { ComboBox } } </script>
comboBoxコンポーネント
少し長くなってしまいましたが、例えば64~78行目で使用しています。
<template> <div class="c-select" ref="rootTarget" > <input class="c-select__input" type="text" v-model="state.selected" placeholder="検索" autocomplete="none" v-on:focus="focusHandler" v-on:blur="blurHandler" ref="getInputTarget" /> <div class="c-select__option-area"> <PositionFix v-bind:open="state.isOpen" v-bind:wide="true" v-bind:fix="true"> <template> <ul class="c-select__option" v-bind:class="{'-open': state.isOpen}" ref="getChild"> <li class="c-select__item" v-for="(item, index) in displayOptions" v-bind:key="index" v-on:mousedown.stop="optionClick(item.label, index)" > <div ref="targetOptionEl" v-bind:class="[{'-check': index === current}]" > <span>{{item.label}}</span> </div> </li> <!-- 表示するアイテムがなかった場合 --> <li class="c-select__item" v-if="displayOptions.length === 0"> <div><span>検索結果が見つかりません...</span></div> </li> </ul> </template> </PositionFix> </div> </div> </template> <script> // 選択肢の展開位置をコントロールする処理 import PositionFix from './PositionFix.vue'; import ActionHookController from '../lib/ActionHookController'; export default { props: ['data'], data: function(){ return { state: this.data.data, event: this.data.event, options: [], // 内部保持用選択肢 displayOptions: [], // 表示する選択肢 current: null, // キー選択中のアイテムのindex番号 isOptionClick: false, } }, watch: { // selectedの内容でoptionを絞り込む 'state.selected': function() { let defaultEvent = () => { if(this.isOptionClick === false) this.searchItem(this.state.selected); }; let actionHookController = new ActionHookController({ defaultEvent: defaultEvent, additionalEventData: this.event, actionPropertyName: 'search' }); actionHookController.init(); actionHookController.start(); actionHookController = null; defaultEvent = null; }, }, methods: { // inputにフォーカスが当たった時 focusHandler: function() { let defaultEvent = () => { // デフォルトの処理 console.log('focusHandler'); this.state.isOpen = true; this.isOptionClick = false; this.resetOptionsStatus(); this.$nextTick(() => { let itemsIndex = null; this.displayOptions.map((item, index) => { if(item.label === this.state.selected) itemsIndex = index; }); this.current = (itemsIndex !== null) ? itemsIndex : null; }) }; let actionHookController = new ActionHookController({ defaultEvent: defaultEvent, additionalEventData: this.event, actionPropertyName: 'focus' }); actionHookController.init(); actionHookController.start(); actionHookController = null; defaultEvent = null; }, // inputからフォーカスが外れた時 blurHandler: function() { let defaultEvent = () => { // デフォルトの処理 console.log(`blurHandler`); setTimeout(() => { this.state.isOpen = false; this.isOptionClick = false; },200); }; let actionHookController = new ActionHookController({ defaultEvent: defaultEvent, additionalEventData: this.event, actionPropertyName: 'blur' }); actionHookController.init(); actionHookController.start(); actionHookController = null; defaultEvent = null; }, // 選択肢をクリックした時 optionClick: function(itemName, index) { let defaultEvent = () => { // デフォルトの処理 console.log(`optionClick: ${itemName}`); this.isOptionClick = true; this.state.selected = itemName; this.current = index; }; let actionHookController = new ActionHookController({ defaultEvent: defaultEvent, additionalEventData: this.event, actionPropertyName: 'optionClick', optionArgs: itemName }); actionHookController.init(); actionHookController.start(); actionHookController = null; defaultEvent = null; }, // 文字が含まれているアイテムを探す searchItem: function(word) { // 表示する選択肢をリセット this.displayOptions = []; this.current = null; this.options.forEach(item => { item.show = true; let reg = new RegExp(word, "gi"); let check = reg.test(item.label); item.show = check; if(check) { this.displayOptions.push({ label: item.label, el: null }); }; }); this.$nextTick(()=> { this.getOptionsEls(); }); this.isFocus = false; this.state.isOpen = false; setTimeout(() => { this.isFocus = true; this.state.isOpen = true; }, 50); }, // 選択肢の表示ステータスをリセットする resetOptionsStatus: function() { this.displayOptions = []; this.state.options.forEach(item => { this.options.push({ label: item, show: true, current: false }); this.displayOptions.push({ label: item, el: null }); }); this.$nextTick(()=> { this.getOptionsEls(); }); }, // 表示するoptionのDOM要素を取得する getOptionsEls: function() { let elList = this.$refs.targetOptionEl; this.displayOptions.forEach((item,index) => { item.el = elList[index]; }); }, }, mounted: function() { // propsから取得した選択肢を内部でコントロールするために改変したobjectを制作 this.state.options.forEach(item => { this.options.push({ label: item, show: true, current: false }); this.displayOptions.push({ label: item, el: null }); }); this.$nextTick(()=> { // 表示する選択肢の各DOM要素を保持する this.getOptionsEls(); }) }, components: { PositionFix } } </script>
今回の設計の改善点
1: 必ず以下のデータを持つ必ようがある。
event name:{ action: observer.action, name: string }
イベントをつける必要のないものは書略できるようにしたい。
2: 子コンポーネントからデータの中身、型を知る事ができない。グローバルでどこからでも登録してある全データに簡単にアクセスできるようにしたい。
- カレントデータへのアクセス。
- 親/子データへのアクセス。
- データをn階層さかのぼる/n階層下る。
- データの一覧表示。
3: 子コンポーネントにデータを定義すると親や他のコンポーネントからアクセスできない。(2を解決するために、同じファイル内に記載するか、別ファイルから読み込むという方法があるが、そうすると他コンポーネントからアクセスできなくなるので解決したい)
4. 初めにデータを定義しないとobserverの方が決まらないので順番が限られて使いずらい