22
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Vue.jsミニハンズオン(TODOリストをコンポーネント化する)

Last updated at Posted at 2017-11-29

Vue.jsミニハンズオン(TODOリストをコンポーネント化する)

AngularでもReactでもriot.jsでも満足できなかったひとに、ぴったりフィットなJSフレームワーク「Vue.js」のざっくりハンズオン第3回目です。

第1回目は「Vue.jsミニハンズオン(TODOリスト作成)」をご覧ください。

このハンズオンではnode.jsのパッケージは使わず、Google ChromeとテキストエディタがあればOKです。

Vue.jsミニハンズオンのシリーズは以下を公開しています。

  1. Vue.jsミニハンズオン(TODOリスト作成)
  2. Vue.jsミニハンズオン(TODOリストにアニメーションをつける)
  3. Vue.jsミニハンズオン(TODOリストをコンポーネント化する)

今回の目標

目標は第2回目で作ったシンプルなTODOリストをコンポーネント化することです。

コンポーネントって何だ?

そもそもコンポーネントってなんでしょう?
ざっくり言うとすれば「独自の機能をもたせた要素」というところです。
コンポーネントは外からの影響を受けないようにすることで再利用できる利点を持ちます。
iframe要素を使わなくても独立した世界を作れるってことですね。

まずはグローバルなコンポーネントを作ってみる

TODOリストをコンポーネント化する方法がまだわからないので、
ひとまずコンポーネントについて学んでいきます。

まずはグローバルなコンポーネントの作り方です。
どのVueインスタンスからでも呼び出せます。

下の例ではmy-component というグローバルなコンポーネントを作って表示させています。

component.html
<!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>
app_component.js
Vue.component('my-component', {
  template: '<div>A global component!</div>'
})
// root インスタンスを作成する
const vm = new Vue({
  el: '#example',
})

CODEPEN

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
  }
})

CODEPEN

この作り方では 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'
    }
  }
})

CODEPEN

ちなみに、return のあとに {} 使わずにデータを参照する例を作ってみます。

component.html
<div id="example-2">
  <simple-counter></simple-counter>
  <simple-counter></simple-counter>
  <simple-counter></simple-counter>
</div>
app_component.js
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'
})

CODEPEN

表示されたボタンをクリックすると数が1ずつ増えますが、ボタンごとではなく、全部のボタンの数が増えてしまいます。
ボタンごとに数が増えるようにするためには、{} を加えて、それぞれのボタンが未使用のデータを持つようにします。

app_component.js
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(プロパティでデータを伝える)

親からプロパティを使って渡すのでカスタム要素にプロパティを記述します。

component.html
<div id="example-3">
  <child message="hello!"></child>
</div>

子コンポーネント側では props プロパティで親から受け取るプロパティを指定します。

app_component.js
Vue.component('child', {
  props: ['message'],
  template: '<span>{{ message }}</span>'
})

const vm3 = new Vue({
  el: '#example-3'
})

CODEPEN

もしJavaScript側で myMessage のようにプロパティ名を付ける場合は、HTML側では my-message のようにハイフン区切りのプロパティ名にする必要があります。これはHTMLが大文字・小文字の区別がないためです。気をつけましょう。

プロパティ1つのデータバインディングしてみる

親から v-bind ディレクティブを使ってデータバインディングする渡し方もできます。

component.html
<div id="example-4">
  <input v-model="parentMsg">
  <br>
  <child v-bind:my-message="parentMsg"></child>
</div>

子コンポーネント側では v-bind ディレクティブに指定したプロパティ名を指定します。
親コンポーネント側では data オプションあるにデータを用意します。

app_component.js
Vue.component('child', {
  props: ['myMessage'],
  template: '<span>{{myMessage}}</span>'
})

const vm4 = new Vue({
  el: '#example-4',
  data: {
    parentMsg: 'Message from parent'
  }
})

CODEPEN

オブジェクトのデータバインディングしてみる

子コンポーネントにオブジェクトを渡すこともできます。
この場合は v-bind:プロパティ名 ではなく、 v-bind としてオブジェクトを値に指定します。

component.html
<div id="example-5">
  <todo-item v-bind="todos"></todo-item>
</div>

子コンポーネント側では props プロパティにオブジェクト内のプロパティ名をちゃんと指定します。

app_component.js
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
    }
  }
})

CODEPEN

オブジェクトを使ってリストを表示してみる

親コンポーネントから渡されたオブジェクトでリストを表示してみます。
HTMLではリスト表示のために v-for ディレクティブを利用し、 v-bind ディレクティブでは todosに替わって todo を渡します。

component.html
<div id="example-5">
  <todo-item v-for="todo in todos" v-bind="todo"></todo-item>
</div>

子コンポーネント側ではデータ todos にデータを複数用意すれば準備OK。

app_component.js
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
      },
    ]
  }
})

CODEPEN

ちゃんとリストが表示されましたね。

プロパティを使って渡すときの注意点

プロパティを使って親から子に数値を渡すとき、下記のように渡すと実は数値ではなく文字列になってしまいます。
これは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('イベント名') を指定して親に対してイベントを起こす

ひとまずソースを触ってみましょう。
下記はボタン(子)をクリックすると親の数字が加算されるサンプルです。

component.html
<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 という関数を実行されることになります。

app_component.js
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
    }
  }
})

CODEPEN

次に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。編集するところだけ抜き出します。

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 要素をコンポーネント化

todo.html
  <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 をグローバルなコンポーネントとして追加。

todo.js
//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を渡しています)

そして親コンポーネントの修正。

todo.js
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 オプション内の関数 addTodoid プロパティを追加

CODEPEN

まあなんとも面倒な処理ですが、親と子のそれぞれのデータが分けられているのが分かってもらえるのではないでしょうか。

Vue.js公式のコンポーネントのガイドはかなりボリュームがあります

Vue.js公式のコンポーネントのガイドはについては下記をご覧ください。
このハンズオンの例の大半はこのガイドから持ってきました。
そしてかなりはしょっています。しっかり学びたい方はこちらでも学んでみてください。
とても良くまとまっていて勉強しやすいですよ。

22
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?