Vue.jsミニハンズオン(TODOリストをコンポーネント化する)
AngularでもReactでもriot.jsでも満足できなかったひとに、ぴったりフィットなJSフレームワーク「Vue.js」のざっくりハンズオン第3回目です。
第1回目は「Vue.jsミニハンズオン(TODOリスト作成)」をご覧ください。
このハンズオンではnode.jsのパッケージは使わず、Google ChromeとテキストエディタがあればOKです。
Vue.jsミニハンズオンのシリーズは以下を公開しています。
今回の目標
目標は第2回目で作ったシンプルなTODOリストをコンポーネント化することです。
コンポーネントって何だ?
そもそもコンポーネントってなんでしょう?
ざっくり言うとすれば「独自の機能をもたせた要素」というところです。
コンポーネントは外からの影響を受けないようにすることで再利用できる利点を持ちます。
iframe要素を使わなくても独立した世界を作れるってことですね。
まずはグローバルなコンポーネントを作ってみる
TODOリストをコンポーネント化する方法がまだわからないので、
ひとまずコンポーネントについて学んでいきます。
まずはグローバルなコンポーネントの作り方です。
どのVueインスタンスからでも呼び出せます。
下の例ではmy-component
というグローバルなコンポーネントを作って表示させています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>コンポーネント</title>
</head>
<body>
<div id="example">
<my-component></my-component>
</div>
<script src="https://unpkg.com/vue"></script>
<script src="./app_component.js"></script>
</body>
</html>
Vue.component('my-component', {
template: '<div>A global component!</div>'
})
// root インスタンスを作成する
const vm = new Vue({
el: '#example',
})
Vue.component
という命令でコンポーネントを作ることができます。
重要なのは new Vue
でインスタンスを作る前に Vue.component
があることです。
グローバルなコンポーネントは先にVueに登録しておかないとインスタンス内で利用できません。
ローカルなコンポーネントを作ってみる
グローバルがあるならローカルな作り方もあります。
特定のVueインスタンス内でのみ利用するコンポーネントは new Vue
でインスタンスを作るときに
components
オプションを利用します。
下記の例では my-component
をローカルに作っています。
var Child = {
template: '<div>A local component!</div>'
}
const vm = new Vue({
el: '#example',
components: {
'my-component': Child
}
})
この作り方では components
オプションを使って作成したインスタンス内でのみ利用可能なコンポーネントを作ることができます。
コンポーネント内のデータは関数でなければならない
new Vue
でインスタンスを作り出すときに指定するほどんどのオプションはコンポーネントでも利用できますが、data
オプションについては関数でなければならないというルールがあります。
テストで下のようにグローバルなコンポーネントを作ってみます。
Vue.component('my-component', {
'<div>{{ message }}, global component!</div>',
data: {
message: 'Hello'
}
})
データが表示されず、JavaScriptコンソールに「データは関数にしてね」とエラーが表示されます。
正しく表示させるには下記のように関数にします。
Vue.component('my-component', {
template: '<div>{{ message }}, global component!</div>',
data: function(){
return {
message: 'hello'
}
}
})
ちなみに、return
のあとに {}
使わずにデータを参照する例を作ってみます。
<div id="example-2">
<simple-counter></simple-counter>
<simple-counter></simple-counter>
<simple-counter></simple-counter>
</div>
var data = { counter: 0 }
Vue.component('simple-counter', {
template: '<button v-on:click="counter += 1">{{ counter }}</button>',
data: function () {
return data
}
})
new Vue({
el: '#example-2'
})
表示されたボタンをクリックすると数が1ずつ増えますが、ボタンごとではなく、全部のボタンの数が増えてしまいます。
ボタンごとに数が増えるようにするためには、{}
を加えて、それぞれのボタンが未使用のデータを持つようにします。
Vue.component('simple-counter', {
template: '<button v-on:click="counter += 1">{{ counter }}</button>',
data: function () {
return {
counter: 0
}
}
})
const vm2 = new Vue({
el: '#example-2'
})
こうするとボタンごとに数が増えるようにすることができます。
親子でのデータのやり取り
さて、これまではコンポーネントの作り方をみてきました。
次に親コンポーネントと子コンポーネントのデータのやりとりをみていきます。
Vueでは親子コンポーネントの関係は、親から子へはprops down(プロパティでデータを伝える), 子から親へはevent up(イベントを使って親に伝える)となります。
- 親→子:props down(プロパティでデータを伝える)
- 子→親:event up(イベントを使って親に伝える)
Vueではデータは親から子に一方的に流すというルールを作ってあり、その逆はありません。
こうすることで複数の子が偶然親の状態を変更してしまうことを防いでいます。
さて、まずは親→子のデータの伝達を見てみましょう。
親→子:props down(プロパティでデータを伝える)
親からプロパティを使って渡すのでカスタム要素にプロパティを記述します。
<div id="example-3">
<child message="hello!"></child>
</div>
子コンポーネント側では props
プロパティで親から受け取るプロパティを指定します。
Vue.component('child', {
props: ['message'],
template: '<span>{{ message }}</span>'
})
const vm3 = new Vue({
el: '#example-3'
})
もしJavaScript側で myMessage
のようにプロパティ名を付ける場合は、HTML側では my-message
のようにハイフン区切りのプロパティ名にする必要があります。これはHTMLが大文字・小文字の区別がないためです。気をつけましょう。
プロパティ1つのデータバインディングしてみる
親から v-bind
ディレクティブを使ってデータバインディングする渡し方もできます。
<div id="example-4">
<input v-model="parentMsg">
<br>
<child v-bind:my-message="parentMsg"></child>
</div>
子コンポーネント側では v-bind
ディレクティブに指定したプロパティ名を指定します。
親コンポーネント側では data
オプションあるにデータを用意します。
Vue.component('child', {
props: ['myMessage'],
template: '<span>{{myMessage}}</span>'
})
const vm4 = new Vue({
el: '#example-4',
data: {
parentMsg: 'Message from parent'
}
})
オブジェクトのデータバインディングしてみる
子コンポーネントにオブジェクトを渡すこともできます。
この場合は v-bind:プロパティ名
ではなく、 v-bind
としてオブジェクトを値に指定します。
<div id="example-5">
<todo-item v-bind="todos"></todo-item>
</div>
子コンポーネント側では props
プロパティにオブジェクト内のプロパティ名をちゃんと指定します。
Vue.component('todo-item', {
props: ['text','isComplete'],
template: '<div>{{text}} - {{isComplete}}</div>'
})
const vm5 = new Vue({
el: '#example-5',
data: {
todos: {
text: 'Learn Vue',
isComplete: false
}
}
})
オブジェクトを使ってリストを表示してみる
親コンポーネントから渡されたオブジェクトでリストを表示してみます。
HTMLではリスト表示のために v-for
ディレクティブを利用し、 v-bind
ディレクティブでは todos
に替わって todo
を渡します。
<div id="example-5">
<todo-item v-for="todo in todos" v-bind="todo"></todo-item>
</div>
子コンポーネント側ではデータ todos
にデータを複数用意すれば準備OK。
Vue.component('todo-item', {
props: ['text','isComplete'],
template: '<div>{{text}} - {{isComplete}}</div>'
})
const vm5 = new Vue({
el: '#example-5',
data: {
todos: [
{
text: 'Learn Vue',
isComplete: false
},
{
text: 'Learn Vue2',
isComplete: true
},
]
}
})
ちゃんとリストが表示されましたね。
プロパティを使って渡すときの注意点
プロパティを使って親から子に数値を渡すとき、下記のように渡すと実は数値ではなく文字列になってしまいます。
これはHTMLの属性値にある数値をJavaScriptで扱うときに一般的に出てくる問題です。
<comp some-prop="1"></comp>
Vueでは v-bind
ディレクティブを使って指定する値にJavaScriptの式を指定できます。
つまりHTMLの属性値とは違って文字列ではなく数値として扱うことができます。
<comp v-bind:some-prop="1"></comp>
子→親:event up(イベントを使って親に伝える)
さて、ここまで親→子のデータ伝達を見てきました。今度は子→親のデータ伝達です。
ただ、Vueにはデータは親から子に一方的に流すというルールがあり、子から親のデータを直接編集することはできません。ここで使うのがVueのカスタムイベントです。
カスタムイベント
子から親を編集するときは下記の流れになります。
- v-on:イベント名 で子で発生したイベントを拾う設定をする
- 子の関数内で this.$emit('イベント名') を指定して親に対してイベントを起こす
ひとまずソースを触ってみましょう。
下記はボタン(子)をクリックすると親の数字が加算されるサンプルです。
<div id="counter-event-example">
<p>{{ total }}</p>
<button-counter v-on:increment="incrementTotal"></button-counter>
<button-counter v-on:increment="incrementTotal"></button-counter>
</div>
親に p
要素で total
というデータを表示する部分が作ってあります。
button-counter
要素に v-on
ディレクティブで設定しているイベントが increment
?
聞いたことのないイベント名ですね。これがカスタムイベントです。
まあなんにしろこれで increment
というイベントが起こったときに incrementTotal
という関数を実行されることになります。
Vue.component('button-counter', {
template: '<button v-on:click="incrementCounter">{{ counter }}</button>',
data: function () {
return {
counter: 0
}
},
methods: {
incrementCounter: function () {
this.counter += 1
this.$emit('increment')
}
},
})
const vmcounter = new Vue({
el: '#counter-event-example',
data: {
total: 0
},
methods: {
incrementTotal: function () {
this.total += 1
}
}
})
次にJavaScriptで親のコンポーネントを見ます。
-
methods
オプションに関数incrementTotal
が指定されていて、total
というデータに+1する -
incrementTotal
はHTMLファイルで書かれていた関数である
次に子コンポーネントを見ます。
-
template
オプションを見るとbutton-counter
要素はbutton
要素であり、click
イベントが仕込まれていて、関数incrementCounter
を呼び出している -
counter
というデータがボタンごとに存在するようにreturn {}
の中に指定されていて、template
オプションの中でそのデータを表示している -
methods
オプションに関数incrementCounter
が指定されていてcounter
というデータに+1する -
this.$emit('increment')
で親のincrement
イベントを起こすthis.$emit('increment', データ)
の形で子から親にデータを渡すことが可能
子から親に対してカスタムイベントを起こして親の関数を動作させ、親の関数を使って親のデータを編集するということになります。
まとめると、重要なのは下記になります。
- 親のデータは親が、子のデータは子が編集
- 親から子にデータを渡す仕組みがある
- イベントは子から親に伝える仕組みがある
TODOリストをコンポーネント化してみる
さて、目的の「TODOリストをコンポーネント化」に取り掛かりましょう。
今回コンポーネント化するのはリスト部分のみです。
見た目は変わらないんですが、親子コンポーネントのやりとりの感覚をつかむのにはいいと思います。
まずはHTML。編集するところだけ抜き出します。
<transition-group name="list-complete" tag="ul">
<li v-for="item in items" v-bind:key="item" class="list-complete-item">
<label v-bind:class="{ done: item.isChecked }">
<input type="checkbox" v-model="item.isChecked"> {{ item.title }}
</label>
</li>
</transition-group>
↓ li
要素をコンポーネント化
<transition-group name="list-complete" tag="ul">
<todo-item v-for="item in items" v-bind="item" v-bind:key="item.id" v-on:delete="updateCheck"></todo-item>
</transition-group>
修正点は以下の通り。
- コンポーネント名は
todo-item
です。 - コンポーネントを
v-for
ディレクティブで配列内の項目分作ります。 - コンポーネント内に表示するデータを
v-bind
でオブジェクトitem
を渡しています。 -
v-for
ディレクティブはv-bind:key
を指定する必要があるのでここでは配列内にid
というプロパティを追加して指定しました(前回はitem
を指定していましたが「StringかNumberでないとダメ」とコンソールにエラーが出ていたので修正しました)。 - カスタムイベントとして
v-on:delete
に親コンポーネントの関数updateCheck
を指定しました。
そしてJavaScriptの調整に入ります。
※データ構成が替わってしまっているので、動作確認をする前に前回までのlocalStorageのデータは削除してください(すみません…)。
まずは子コンポーネント todo-item
をグローバルなコンポーネントとして追加。
//Vue.componentをまるっと追加
Vue.component('todo-item', {
props: ['title','isChecked','id'],
template: `
<li class="list-complete-item">
<label v-bind:class="{ done: isChecked }">
<input type="checkbox" v-model="childisChecked" v-on:change="deleteCheck"> {{ title }}
</label>
</li>`,
data: function(){
return {
childisChecked: this.isChecked //v-modelでisCheckedを指定できないため追加
}
},
methods: {
deleteCheck: function(){
this.$emit('delete', this.childisChecked, this.id);
}
}
})
template
オプションの li
要素内の記述は上で表示していた修正前の li
要素の記述とちょこっと変わってます。
-
label
要素で指定するisChecked
の頭についていたitem.
は削除 →props
オプションで'title','isChecked','id'
をそれぞれ参照するように指定したため。 -
input
要素のv-model
ディレクティブの値を子コンポーネントのdata
オプションで設定したchildisChecked
に変更 → 親コンポーネントのデータを直接編集できないため - 親コンポーネントにチェックボックスの値の変更を知らせるため、
v-on:change
でイベントを拾い、子コンポーネント内のmethods
オプションに設定した関数deleteCheck
を呼び出す - 関数
deleteCheck
で$emit
を利用して、親のv-on:delete
を動作させ、引数で値を渡す(ここではチェックボックスの状態とidを渡しています)
そして親コンポーネントの修正。
const vm = new Vue({
el: '#app',
data: {
items: [],
newItemTitle: '',
},
methods: {
updateCheck: function(childChecked, childIndex){ //updateCheckメソッドをまるっと追加
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].id === childIndex) {
this.items[i].isChecked = childChecked;
break;
}
}
this.saveTodo();
},
addTodo: function(newTitle){
let date = Date.now(); //idに利用する数値
this.items.push({
title: newTitle,
isChecked: false,
id: date //idを追加
});
this.newItemTitle = '';
this.saveTodo();
},
deleteTodo: function(){
this.items = this.items.filter(function (item) {
return item.isChecked === false;
});
this.saveTodo();
},
saveTodo: function(){
localStorage.setItem('items', JSON.stringify(this.items));
},
loadTodo: function(){
this.items = JSON.parse( localStorage.getItem('items') );
if( !this.items ){
this.items = [];
}
},
},
mounted: function(){
this.loadTodo();
},
})
修正点は以下の通り。
-
methods
オプションに関数updateCheck
を追加して、子コンポーネントのTODO項目のチェック状態を親データに反映する処理を追加 -
methods
オプション内の関数addTodo
でid
プロパティを追加
まあなんとも面倒な処理ですが、親と子のそれぞれのデータが分けられているのが分かってもらえるのではないでしょうか。
Vue.js公式のコンポーネントのガイドはかなりボリュームがあります
Vue.js公式のコンポーネントのガイドはについては下記をご覧ください。
このハンズオンの例の大半はこのガイドから持ってきました。
そしてかなりはしょっています。しっかり学びたい方はこちらでも学んでみてください。
とても良くまとまっていて勉強しやすいですよ。