こんにちは。JJです。
久しぶりの投稿になります。
今回はVue.jsにおけるコンポーネント間のデータのやり取りについて紹介していきます。
はじめに
Vue.jsを利用したフロントエンドの開発を進めていく中で、複雑な機能であったり、反復性を有する機能の開発を行う際、1つのVueコンポーネントで実装するよりも機能の単位、粒度に応じて複数のコンポーネントに分解して実装すると思います。後者の方が、経験上、ソースコードの可読性やメンテナンス性、さらにVue.jsの特徴の一つであるコンポーネントの再利用性も生かすことができるので、大変便利です。
しかしながらこの場合、複数のコンポーネント間でのデータのやり取りというものが必要不可欠になります。開発経験の浅い方の場合、その扱いに最初は悩むことでしょう。
そこで今回は簡単な実装例も交えつつ、Vue.jsの重要な機能の1つである「props」と「emit」について取り上げます。このテクニックに慣れることで開発効率も随分と向上すると思います。ぜひ、マスターしましょう。
コンポーネント間のデータのやり取り
コンポーネント分割を行う際に片方のコンポーネントがもう片方のコンポーネントを取り込み(import)する時、importする側を「親(コンポーネント)」と呼び、される側を「子(コンポーネント)」と呼びます。importにより、親と子は図のように互いがノード(黒い直線)によって結ばれます。しかしこのままでは、親と子は互いのオプション(data, methods, computedなど)の情報や状態を把握・共有することができません。これらを外部のコンポーネントから参照したいとき、明示的な記述が必要になります。特に今回のテーマである「コンポーネント間のデータのやり取り」では「親から子」にdataを伝達する手段「props」、そして「子から親」にdataを伝達する手段「emit」が存在します。それでは下記にそれぞれの概念について述べてまいります。
親から子へ、props
親から子に「data」を伝達したいとき、親側でv-bind(:)を経由してデータを子に流します。バインドしているので、props(プロパティ)の値の変更を検知できます。図のケースではParent.vueからChild1.vue、Child2.vue、Child3.vueに対してデータを渡しており、Child4.vueにはpropsの機能を用いていないことを示しています。ここで子は親からのデータを受け取るとき、配列による列挙形式か、オブジェクトの配列として記述する方法の2種類存在します。後者は、プロパティに対して、型指定やバリデーション等、詳細な設定を指定できます。
1 2 3 4 |
<child :param0="item" :param1="value" /> |
まず配列による列挙について紹介いたします。下記のように簡潔に指定できる反面、それぞれのデータの情報が記載されていないため、子では「item」も「value」も同じように扱います。
1 |
props: ['item', 'value'] |
こちらはオブジェクトによる列挙になります。こちらの方がより詳細に指定できるため、「item」と「value」の違いついて明記できます。
1 2 3 4 5 6 7 8 9 |
props: { item: { type: Object }, value: { type: Number, default: 0 } } |
子から親へ、emit
子から親に対してデータを伝達させたい場合は、カスタムイベントを使用します。カスタムイベントを活用する場合、下記のように$emitと$onが一対として機能します。親コンポーネントから子のイベントを監視したい場合は「v-on:」または「@」によりイベント名(今回の場合「updateEvt」)を紐づけます。図のケースではChild3.vueで発したイベントトリガー$emitによりParent.vueにてそのイベントが$onにより実行される様子を表しています。これをソースコードで示すと次のようになります。
子コンポーネント(Child3.vue)
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 |
<template> <div class="item"> <span>{{ value }}</span> <button @click="plusOne">追加</button> </div> </template> <script> export="" default="" {="" data="" ()="" return="" value:="" 0="" }="" },="" methods:="" plusone="" this.value="" +="1" this.$emit('updated')="" <="" script><="" pre=""> <h4 class="m-category_header"><span class="inner">親コンポーネント(Parent.vue)</span></h4> <pre class="lang:default decode:true "><template> <counter @updated="updateEvt"> </template> <script> import Child from './Child.vue' export default { components: { Child }, methods: { updateEvt () { console.log('The value updated') } } } </script></counter> |
また$emitの第2引数、第3引数…と特定の変数を指定することで、子から親に対してその変数の値を受け取ることができます。このとき、受け手である親はイベントと紐づけたメソッドに対して、その引数を指定します。
子コンポーネント(Child3.vue)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<template> <div class="item"> <span>{{ value }}</span> <button @click="plusOne>追加</button> </div> </template> <script> export default { data () { return { value: 0 } }, methods: { plusOne () { this.value += 1 this.$emit('updated', this.value) } } } </script> |
親コンポーネント(Parent.vue)
1234567891011121314151617
<template> <counter @updated="updateEvt"></template> <script>import Child from './Child.vue'export default { components: { Child }, methods: { updateEvt (value) { console.log('The value updated as ' + value) } }}</script></counter>
実装例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<template> <counter @updated="updateEvt"> </template> <script> import Child from './Child.vue' export default { components: { Child }, methods: { updateEvt (value) { console.log('The value updated as ' + value) } } } </script></counter> |
ここではpropsやemitを用いた実装例を紹介します。ある生産ラインの生産実績をオペレータが入力する簡易画面を題材とします。オペレータは製品の生産本数を数え上げ、上図の画面に入力します。この時、製造された製品が良品であれば、左側の追加ボタンを押し、不良品であれば右側の追加ボタンを押します。
今回の開発環境はVue CLI(ver3.7)を使用します。Vue CLIを扱うNodeパッケージモジュール「@vue/cli」がインストールされていない場合、まずはこれを追加します。
1 |
$ npm install -g @vue/cli |
正しくインストールされたか、バージョンを確認します。
1 |
$ vue --version |
新たにプロジェクト名「production-result」を生成します。この時の初期設定はデフォルトとします。
1 |
$ vue create production-result |
上記の手順が完了すれば、下準備は完了です。
ソースコード
下記にソースコードとして3つvueコンポーネントを示します。
ProductionResult.vue
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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
<template> <div class="frame"> <h1 class="title">生産実績画面</h1> <sticker :title="'生産実績'" :unit="'本'" :value="good + bad"> <sticker :title="'不良率'" :unit="'%'" :value="defectivePercent"> <counter :title="'良品'" :unit="'本'" :varname="'good'" @updated="commit"> <counter :title="'不良品'" :unit="'本'" :varname="'bad'" @updated="commit"> </counter></counter></sticker></sticker></div> </template> <script> import="" sticker="" from="" '.="" sticker.vue'="" counter="" counter.vue'="" export="" default="" {="" components:="" sticker,="" },="" data="" ()="" return="" good:="" 0,="" bad:="" 0="" }="" computed:="" defectivepercent="" number((this.bad="" (this.good="" +="" this.bad)="" *="" 100).tofixed(2))="" ||="" methods:="" commit="" (newvalue,="" variable)="" this[variable]="newValue" <="" script>="" <style="" scoped>="" #app="" .frame="" width:="" 500px;="" height:="" 550px;="" background:="" #ddd;="" border-radius:="" 5px;="" margin:="" auto;="" .title="" padding:="" font-size:="" 20px;="" color:="" #333;="" border-bottom:="" solid="" white="" 1px;="" #c8c4b7;="" border-top-left-radius:="" border-top-right-radius:="" <="" style>="" pre=""> <h4 class="m-category_header"><span class="inner">Counter.vue</span></h4> <pre class="lang:sh decode:true"><template> <div class="item"> <sticker :title="title" :unit="unit" :value="value"> <button class="add-button" @click="plusOne" >追加<="" button=""> </button></sticker></div> </template> <script> import="" sticker="" from="" '.="" sticker.vue'="" export="" default="" {="" components:="" },="" props:="" title:="" type:="" string="" unit:="" varname:="" }="" data="" ()="" return="" value:="" 0="" methods:="" plusone="" this.value="" +="1" this.$emit('updated',="" this.value,="" this.varname)="" <="" script=""> <style scoped> #app .item { width: 220px; margin: 0px 10px; float: left; } #app .add-button { width: 100px; margin: 10px; padding: 5px; background: #B7C4C8; border-radius: 3px; color: #666; font-size: 20px; border-style: none; } #app .add-button:hover { background: #93A7AC; } </style> </script>> |
Sticker.vue
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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
<template> <div class="item"> <h2>{{ title }}</h2> <span class="value">{{ value }}</span> <span class="unit">[ {{ unit }} ]</span> </div> </template> <script> export="" default="" {="" props:="" title:="" type:="" string="" },="" value:="" number,="" default:="" 0="" unit:="" }="" <="" script>="" <style="" scoped>="" #app="" .item="" border:="" solid="" #666="" 1px;="" border-radius:="" 5px;="" margin:="" 20px;="" width:="" calc(100%="" -="" 40px);="" h2="" font-size:="" 16px;="" text-align:="" left;="" 0px="" 10px;="" .value="" 48px;="" padding:="" <="" style>="" pre=""> それぞれのコンポーネントの関係については下図に示す通りとなります。ここではProductionResult.jsを親として、本数を数え上げるCounter.vueと、値を表示するためのSticker.vueをimportしています。さらにCounter.vueにおいてもSticker.vueを再利用しています。今回はemitとpropsを説明する理由もあり、Sticker.vueの再利用性を意識した設計となります。ただし今回のコンポーネント設計についてはその一例に過ぎず、用途や機能、要件に応じて構造が変化します。 <div style="text-align:center"> <img src="/wp-content/uploads/2019/05/text1062-2-8-7-2-1-74.png" alt="" width="687" height="681" class="alignnone size-full wp-image-8646"> </div> ここでProductionResult.vueはCounter.vueに対して「タイトル(title)」、「表示単位(unit)」、そして「対象とする変数名の文字列指定(varName)」をpropsで渡します。同様にProductionResult.vueおよびCounter.vueからSticker.vueに対して「タイトル(title)」、「表示単位(unit)」、「値(value)」をpropsで渡します。Counter.vueのようにProductionResult.vueからマスタとなる値を受け取り、これらをカスケードしてStricker.vueに値を引き渡すという使い方もできます。 <h3 class="category_header"><span class="inner">画面操作とコンポーネント間の作用</span></h3> 次に良品の「追加」ボタンを押したときの画面の挙動について説明します。下図に示すとおり、vueファイル間でのデータのやり取りが行われるため、内容が少々複雑な印象を受けるかもしれません。ここで図は動作を説明するための必要分しか記載しておりません。それでは順番に説明します。 <div style="text-align:center"> <img src="/wp-content/uploads/2019/05/text9422.png" alt="" width="774" height="681" class="alignnone size-full wp-image-8647"> </div> <h4 class="m-category_header"><span class="inner">「追加」ボタン押下時</span></h4> <ul class="list-style_none"> <li><span class="color_key">① </span>良品の「追加」ボタンを押下します。</li> <li><span class="color_key">② </span>本数を1本カウントする「plusOne」メソッドが作動します。この時、Counter.vueが管理するdata「value」を用います。</li> </ul> ここで、plusOneメソッドの動作により処理が分岐し並列的に実行されます。処理の内容として <ul> <li>良品数のカウント</li> <li>不良率の算出</li> <li>良品と不良品の合計値</li> </ul> の3種類になります。ここでは「良品数のカウント」と「不良率の算出」について説明します。 [promobox column=2] [promo iconalign="left"] <h4 class="m-category_header"><span class="inner">不良率の算出</span></h4> <ul class="list-style_none" style="font-size: 16px"> <li><span class="color_key">③ </span>pulsOneメソッド内の$emitによりupdateイベントが実行されます。このとき親となるProductionResult.vueにて良品数「good」の値を確定させるための「commit」メソッドが実行されます。</li> <li><span class="color_key">④ </span>commitメソッドにより「good」の値が更新されます。</li> <li><span class="color_key">⑤ </span>「good」の値の変化に伴い、不良率「defectivePercent」が再計算されます。</li> <li><span class="color_key">⑥ </span>再計算された値はStickar.vueの「value」プロパティにより値が伝達されます。</li> <li><span class="color_key">⑦ </span>Sticket.vueのvalueの値がテンプレート層で描画されます。</li> </ul> [/promo] [promo iconalign="left"] <h4 class="m-category_header"><span class="inner">良品数のカウント(')</span></h4> <ul class="list-style_none" style="font-size: 16px"> <li><span class="color_key">③'</span>plusOneによりCounter.vueのdata「value」が更新されます。</li> <li><span class="color_key">④'</span>Counter.vueのdata「value」はStickar.vueの「value」プロパティにより値が伝達されます。</li> <li><span class="color_key">⑤'</span>Sticket.vueのvalueの値がテンプレート層で描画されます。</li> </ul> [/promo] [/promobox] 「良品と不良品の合計値」についても、「不良率の算出」と原理は同様です。 また、$emitを用いずに$parentを用いることで親側のdataに直接アクセスすることができます。 Counter.vueのplusOneメソッドにおいて下記のように記述することができます。 <pre class="lang:default decode:true ">plusOne () { this.value += 1 this.$parent[this.varName] = this.value } |
このように記述することで、ProductionResult.vueにおけるcommitメソッドを省略することができます。
今回のケースでは規模も小さいため、十分に活用する余地があります。
しかし、公式のドキュメントに記述されている見解の通り、$parentや$childrenは非推奨とされています。
これらの多用は大規模開発になるにつれて、どのコンポーネントでどのようにdataが更新されているかという事実が不透明になるためです。
その結果としてコンポーネント間のやり取りにおいて可読性の低下を招いたり、
理解の混乱を引き起こす原因となる可能性があります。
使用には十分に吟味しましょう。
ここで重要なこととして、親がマスタとなるデータ(良品および不良品の数)を保持、管理しているということです。子はあくまで、親に対して受動的に作用する単位機能として見てみましょう。Counter.vueはあくまで数え上げるためのコンポーネントであり、Sticker.vueではpropsで受け取ったタイトルや値を表示するためのコンポーネントです。データは上流となる「親」から下流に至る「子」への一方向に流れていることに注意しましょう。
おわりに
今回はコンポーネント間のデータのやり取りの中でも最も基本的なpropsとemitについて紹介しました。基本的には上記の内容を押さえておけば、データのやり取りについては苦労されないかと思います。データの伝達手段は他にもVuexやlocalStorageなどが存在し、場合や目的によって使い分ける必要があります。これらも学習されることで、より汎用的なデータの伝達が可能になるかと思います。