Vue.js のコンポーネントと親子間データの送受信
コンポーネントを使ってサイトの部品を作っていきます。
コンポーネントの使いどころ
例えば私のサイトのようなブログでは、一つのページにヘッダー、フッター、メニュー、記事一覧、お問い合わせフォーム、というようなまとまりがあります。さらに記事一覧の中には一つの記事・タグなどネストされた部品もあります。これをひとつひとつ独立させる事が出来ます。
細かくコンポーネント化させることで機能やデータ、HTML/CSS 要素をカプセル化できてメンテナンス性が上がったり、必要な場面で単体で使いまわす事も出来るようになります。この図を作るのにも使ってるイラレのシンボルにも似てますね~!
コンポーネントの書き方
コンポーネントは Vue のルートコンストラクタと同じようにデータやメソッドと独自のテンプレートを持つことが出来ます。
Vue.component で名前付きで作成するとグローバルに登録されるのでどこからでも使用する事が出来ます。引数2ではオプションをオブジェクトで渡します。
Vue.component('名前', { template: '<p>example</p>' })
オプションオブジェクトを特定のコンポーネントの components に登録することでそのコンポーネント内だけで使用可能にする事もできます。
var myComponent = { template: '<p>example</p>' }
new Vue({
components: {
'my-component': myComponent
}
})
Vue.extend を使ってサブクラスを作成でき、そのままコンポーネントとして登録することが出来ます。
var myComponent = Vue.extend({ template: '<p>example</p>' })
new Vue({
components: {
'my-component': myComponent
}
})
Vue.extend の返り値は new でインスタンスを作成して独自にマウントする事も出来ます。インスタンスを使ってルートや他のインスタンスとのやりとりをしたり、自動テストなども出来るようです。
コンポーネントを登録する際オブジェクトなら Vue.extend を通すので子コンポーネントとかではどっちを渡しても同じみたいです。
テンプレートの中でタグを書いて使用できる
コンポーネントは HTML 上の描画したい場所にカスタムタグで記述して使用します。
<div id="app">
<my-component></my-component>
</div>
コンポーネントタグ自体に ID などの HTML の属性を付けていた場合コンポーネント側のテンプレートのルートタグに上書きまたはマージされます。複数の値が指定可能な class などはマージされます。
<my-component class="p"/>
実際に表示されるHTML
<p class="p">example</p>
また特別な理由がある場合 is="コンポーネント名" を使って要素をコンポーネントに置き換える事もできます。
<p is="my-component"></p>
v-bind でコンポーネント名を返すデータや式も使える!
<component :is="currentComponent"></component>
データは関数で作成する
コンポーネントのデータはオブジェクトを返す関数にします。これはインスタンス毎にデータを区別するためと変化を察知するためのようです。
Vue.component('my-component', {
template: '<p>example</p>',
data: function() {
return {
message: 'hello!'
}
}
})
テンプレートの書き方
Vue ではテンプレートの書き方が複数あります。用途に合わせて選択できます。
通常のテンプレートオプション
オプションオブジェクトで指定するものです。
Vue.extend({
template: '<p>テンプレート</p>'
})
もし ES6 の記述が可能ならバッククォートの複数行テンプレートリテラルが使用できます。
Vue.extend({
template: `<div>
<p>テンプレート</p>
<p>テンプレート</p>
</div>`
})
text/x-template + セレクタ
script タグに x-template を使うとブラウザに認識されずにテンプレートを DOM 内に記述する事が出来ます。
<script type="text/x-template" id="tpl">
<p>テンプレート</p>
</script>
コンポーネント側で ID を指定します。
Vue.extend({
template: '#tpl'
})
通常の要素をテンプレートとして使うことも出来ますが、これは DOM と認識されるので少しだけ内容に注意する必要があるのです。
<template id="tpl">
<p>テンプレート</p>
</template>
描画関数 render
テンプレートをスクリプトで動的に作成したい場合や外部から HTML を取得する場合など少し複雑になる時に使用します。記述されている場合 template オプションより優先度が高いです。
Vue.extend({
render: function (createElement) {
return createElement( ... )
}
})
一度 Pjax で受け取った HTML と連携させる時に使いましたがまだあまり詳しくないので説明は控えます(っ;ω;c) 詳しくはこちらをご覧ください。
inline-template
コンポーネントの内側にある HTML をテンプレートとして使用します。記述されている場合 render 関数より優先度が高いです。
<my-component inline-template>
<p>テンプレート</p>
</my-component>
単一ファイルコンポーネント
Vue でガシガシ書いていく場合多分これがメインになってくると思います。.vue 拡張子のファイルにコンポーネント単位で HTML や CSS スクリプトを分離するのですが、このファイルは今までのように <script> で読み込むだけだと使用できないので Webpack などのビルドツールを使う必要があります。これは次回の記事で書いています。
テンプレートは要素で囲もう
少したった頃に私はハマったんですけどテンプレート直下の要素はひとつだけじゃないといけません。囲まれていなかったり複数の要素は指定できないので注意なのです。
これは NG
Vue.extend({
template: '<p>テンプレート</p><p>テンプレート</p>'
})
タグで囲んであげましょう(•ө•)ノ
Vue.extend({
template: '<div><p>テンプレート</p><p>テンプレート</p></div>'
})
コンポーネントの名前の付け方
HTML と Web Components の規則でタグの名前はケバブケース(小文字でハイフン区切り)で記述して1つ以上のハイフンを含む必要がありますが、Vue のマニュアルには文字列テンプレートの場合は特に制約は無いよという事です。ちなみに文字列テンプレートは以下の事を指します。この中ならコンポーネントタグを自己終了で書いてもOK。
- <script type="text/x-template">
- JavaScript のインラインテンプレート文字列(template: '~' で書いたもの)
- .vue コンポーネント
これ以外の inline-template や id を使ったテンプレート指定はブラウザには通常の DOM と認識されてしまうのでケバブケースで記述するのが良いようです。
スコープを覚える
スコープというのは影響範囲でコンポーネントを使う場合発生します。大まかに言うと自分自身のテンプレート範囲が自分のスコープです。各コンポーネントはそれぞれのスコープを持っているので自分のデータやメソッド以外にはアクセス出来ません。完全にアクセス出来ないと不便なので勿論アクセスする方法もあります。
親子間でデータの受け渡しをする
早速ですが今後絶対的に必要になりそうな親子間のデータの参照をしてみます。自分のスコープ外のデータにアクセスしたい場合は手続きをします。最初は若干面倒に感じるかもしれないですが公式で推奨されてるコポーネント同士の密結合を抑えて連携する方法です。
親から子
子コンポーネントのタグを配置する時に属性または v-bind ディレクティブ( : で省略可能)を付けた式タイプの属性でデータを指定して、子は props でその属性を受け取る事で値を使えるようになります。別のデータに代入するみたいな感じですがスコープが違うので属性名はデータと同じでも別の名前を使ってもOKです。
ソースコード
<div id="app">
<!-- A:静的な文字列を渡す -->
<child-comp val="ただのテキスト"></child-comp>
<!-- B:データを渡す -->
<child-comp :val="message"></child-comp>
</div>
<script>
Vue.component('child-comp', {
template: '<p>{{ val }}</p>',
props: ['val']
})
new Vue({
el: '#app',
data: {
message: 'hello!' /* 親が持っているテキストデータ */
}
})
</script>
結果
ただのテキスト
hello!
Aの場合なら「ただのテキスト」Bの場合なら親から受け取った val = message の値の「hello!」が表示されるはずです。
デバッグがしやすくなるのでなるべく受け取り型を指定しておくのが推奨されています。運用の際のバリデーションではなくて開発中にうっかり文字列以外で渡ってしまっていたらエラーにしてくれるのでバグにも気づきやすくなります。
Vue.component('child-comp', {
props: {
'val': String
},
mounted() {
/* データと同じでthisで使用できるようになる */
console.log(this.val)
this.val = '変更しようとすると…' /* ※親にゲンコツされるよ */
}
})
この時 val は親から借りてい���だけなので子側から内容を書き換えようとすると怒られます。以下の $emit を通して親に変更してもらいます。
子から親
子が持っているデータを親が参照したり子が親の持っているデータを変更したい場合はカスタムイベントと $emit を使用します。
親は子コンポーネントを配置する際に v-on ディレクティブ(@で省略可能)で子のイベントへの処理を登録しておきます。子が適当なタイミングで $emit を使ってイベントを発火すると親側で登録してある処理が実行されます。
以下の例は childs-event という名前のイベントに parentsMethod という名前のメソッドを登録します。値の部分は式でもOK。
親側のテンプレート内
<child-comp @childs-event="parentsMethod"></child-comp>
子側でイベントを発火する
親にデータを渡したい場合は引数をもって発火することも出来ます。
this.$emit('childs-event', 'hello!')
親側で受け取る
new Vue({
el: '#app',
methods: {
parentsMethod: function (message) {
alert(message) /* 子から受け取ったメッセージ */
}
}
})
ちなみに関数を括弧付きのインラインで実行する場合、持たせる引数は親のスコープのデータになります。
<child-comp @childs-event="parentsMethod(parentsData)"/>
インラインで実行した場合子が渡しておいたデータは $event で使用できます。コンポーネントに v-for を使う際親側からもインデックス番号をメソッドに送りたい時に便利です。
<div id="app">
<child-comp @childs-event="parentsMethod($event, 'Dog')"></child-comp>
</div>
<script>
Vue.component('child-comp', {
template: '<p><button @click="$emit(\'childs-event\', message)">click!</button></p>',
data: function () {
return {
message: 'わんわん!'
}
}
})
new Vue({
el: '#app',
methods: {
parentsMethod: function (childVal, opt) {
alert(opt + ': ' + childVal) /* 子から受け取ったメッセージ */
}
}
})
</script>
親子間でデータを送受信するサンプル
上記のポイントを踏まえてコンポーネントを作ってみます。
<div id="app">
<div class="box">
<h3>ここは親のスコープ</h3>
<p>{{ childMessage }}</p>
<child-component
:parent-message="parentMessage" @send-message="getChildMessage"></child-component>
</div>
</div>
/* 子コンポーネント */
var childComp = Vue.extend({
/* 親から parentMessage を受け取る */
props: ['parentMessage'],
template: '<div class="box"><h3>ここは子のスコープ</h3>' +
'<p>{{ parentMessage }}</p></div>',
/* コンポーネントのデータはオブジェクトを返す関数にする */
data: function() {
return {
childMessage: 'これは子のデータだよ'
}
},
created: function() {
this.$emit('send-message', this.childMessage)
/* 渡した後に変更したら再度emitしないと反映されない */
/* オブジェクトだと反映されるけどホントは良くない */
/* こういう管理が大変になってきた場合は状態管理を使おう! */
this.childMessage = '子がデータを変更したよ'
}
})
/* 親のルートインスタンス */
new Vue({
el: '#app',
data: {
/* 親が持っているデータ */
parentMessage: 'これは親のデータだよ',
/* 子から受け取ったデータを保管する為の空データ */
childMessage: ''
},
components: {
/* 子コンポーネントを登録する */
'child-component': childComp
},
methods: {
/* 子がイベントを発火した時に実行したい処理 */
getChildMessage: function(text) {
this.childMessage = text
}
}
})
実はこの $emit の使い方、this.$on()で登録して $parent を使って実行したりとか何度もいろんな書き方しまくってハマってて理解するのに結構時間を使いました…。初めての人は結構分かりにくいと思います。
なぜか受け取れない…属性名のハマりポイント
変数名に大文字を使いたい場合、DOM に記述する属性名はケバブケースにしないといけません。イベント名も同じです。
<child-comp :parentMessage="message"> ×
<child-comp :parent-message="message"> ○
スクリプト内で使用する場合は parentMessage です。
スコープのハマりポイント
ちなみに初めてだと若干ハマりポイントになりそうなのが、子コンポーネントの記述タグにバインドしている値のデータやメソッドは親のスコープです。
<child-comp :子に渡った時の変数名="ここは親のスコープ">
コンポーネントにイベント処理が登録されている場合、親が子に登録したイベントを発火するのではなくて子側で自分のイベントを発火させます。
<child-comp @子が発火させる為のイベント名="ここは親のスコープ">
親が子のメソッドを実行したい場合
適当なデータを props で渡しておいて、親がそのデータを変更する事で子がする処理のタイミングを操作できます。あとの記事で書いているウォッチャを使うといいでしょう。
props と $emit のバケツリレーがつらい
単純なやりとりならこれで十分ですが、コンポーネントのネストが深くなったり送受信するデータの量が増えると面倒だしミスも増えてきます。そう感じ始めたら Vuex での状態管理を導入するのがオススメです。
インタラクティブなコンポーネントを作る
もう少し実���的っぽい感じの例でフレンズ用のリストをコンポーネントを使って作ってみます。「イイネ」を押すとカウンターが増えて算出プロパティでカウンターの数値順にソートされるようにします。
テンプレートの書き方は template オプションで書く方法と、inline-template で書く方法の2種類を使いました。
<div id="app">
<friends-list inline-template>
<div>
<h3>Friends List</h3>
<transition-group name="flip-list" tag="ul">
<friends-profile
v-for="(friend, idx) in sortedFriends"
:val="friend"
:key="friend.id"
@count-trigger='countUp(idx)'></friends-profile>
</transition-group>
</div>
</friends-list>
</div>
マニュアルからコピペしただけですが組み込みコンポーネントの <transition-group> も使ってみました。組み込みのトランジションは基本 CSS でアニメーションをしているのですが、終わったタイミングで非表示を入れてくれるのか display:none; を気にせず簡単に使えてとってもたのしい!トランジションを使う場合は v-bind:key は必須みたいです。
/* プロフィール詳細用のコンポーネント */
var friendsProfile = Vue.extend({
props: ['val'],
template: '<li>name:<span :style="{color:val.color}">{{ val.name }}</span> [{{ val.count }}]' +
'<a href="#" @click.prevent="$emit(\'count-trigger\')">イイネ</a></li>'
})
/* プロフィールリスト用のコンポーネント */
var friendsList = Vue.extend({
data: function() {
return {
friends: [
{ id: '1', name: 'サーバル', color: '#e69313', 'count': 0 },
{ id: '2', name: 'フェネック', color: '#a7a264', 'count': 0 },
{ id: '3', name: 'アライグマ', color: '#bbbbbb', 'count': 0 },
]
}
},
computed: {
sortedFriends: function() {
return this.friends.sort(function(a, b) {
if (a.count < b.count) return 1
if (a.count > b.count) return -1
return 0
})
}
},
components: {
'friends-profile': friendsProfile
},
methods: {
countUp: function(idx) {
this.friends[idx].count++
}
}
})
/* ルートインスタンス */
new Vue({
el: '#app',
components: {
'friends-list': friendsList
}
})
一応期待通りに動いてくれてました!これでカウンターを Ajax 使って 保存&取得したりすると割りとそれなりな感じになりますね。
コンポーネントに v-model を使う
上ではカスタムイベントを作成していますが、コンポーネントに v-model で値を紐付けておくと通常のフォームのイベントがそのまま使用できるようです。上の例を少し書き換えてやってみます。
<div id="app3">
<friends-list inline-template>
<div>
<h3>Friends List</h3>
<transition-group name="flip-list" tag="ul">
<friends-profile
v-for="(friend, idx) in sortedFriends"
:val="friend"
:key="friend.id"
v-model="friend.count"></friends-profile>
</transition-group>
</div>
</friends-list>
</div>
friends-profile の v-model に friend.count を結びつけてみました。
/* プロフィール詳細用のコンポーネント */
var friendsProfile = Vue.extend({
props: ['val'],
template: '<li>name:{{ val.name }} [{{ val.count }}]' +
'<a href="#" @click.prevent="$emit(\'input\', val.count + 1)">イイネ</a></li>'
})
/* プロフィールリスト用のコンポーネント */
var friendsList = Vue.extend({
data: function() {
return {
friends: [
{ id: '1', name: 'サーバル', color: '#e69313', 'count': 0 },
{ id: '2', name: 'フェネック', color: '#a7a264', 'count': 0 },
{ id: '3', name: 'アライグマ', color: '#bbbbbb', 'count': 0 }
]
}
},
computed: {
sortedFriends: function() {
return this.friends.sort(function(a, b) {
if (a.count < b.count) return 1
if (a.count > b.count) return -1
return 0
})
}
},
components: {
'friends-profile': friendsProfile
}
})
/* ルートインスタンス */
new Vue({
el: '#app3',
components: {
'friends-list': friendsList
}
})
friends-profile コンポーネントの中から input イベントを発火させると friend.count が入力された事になります。
$emit('input', val.count + 1)
親から props で受け取っているカウント数 val.count は常に新しい数値で更新されていくので、この数値に+1するとどんどんカウントが増えていく感じですね。正直ただ+1してるだけなので親がやっても良いんですけど勿論引数も使えます。リアルタイムに反映させたい場合はこっちの方がシンプルに書けそうです。ボタンを押した時に反映するとか送信を遅延させたい場合は、親データとは分離した内部データで持っておいて送信したいタイミングになったら input やカスタムイベントでその値を送信するのが Good みたいです。
まとめ
コンポーネントは Vue の大きな特徴で小規模なアプリでも重要になってくるのでどんどん使って早めに慣れるとこれから先も凄くスムーズになると思います。そのうち体が勝手にコンポーネント化してしまうようになるはず…。
しかし、だんだん長くなってきたテンプレートをスクリプト内に書くのは結構大変ですね…。次回はちょっと気が早いですが、もっと快適に Vue を書くための開発環境を作りたいと思います。