Vue.js のコンポーネントと親子間データの送受信

最終更新日
2017.11.18

コンポーネントを使ってサイトの部品を作っていきます。

コンポーネントの使いどころ

例えばショッピングカートの場合、ヘッダー、メニュー、商品一覧、検索商品一覧、というようなまとまりがあり、さらに商品一覧の中には一つの商品情報、その中にはカートに入れるフォームなどネストされた部品もあったりします。これをひとつひとつ独立させる事が出来ます。

細かくコンポーネント化させることで機能やデータ、HTML/CSS 要素をカプセル化してメンテナンス性が上がったり、必要な場面で単体で使いまわす事も出来るようになります。この図を作るのにも使ってるイラレのシンボルにも似てます。分割単位は自由ですが、サイトの規模やコンポーネント設計によって後々のメンテナンスコストも変わってきます。

コンポーネントの作り方

コンポーネントはルートコンストラクタ new Vue() のオプションと同じようにデータやメソッドのほかコンポーネント用のテンプレートを定義します。

Vue.component を使って名前付きで作成するとグローバルに登録されるのでどこからでも使用する事が出来ます。2番目のパラメータにオプションをオブジェクトで渡します。

Vue.component('名前', {
  template: '<p>example</p>' // コンポーネントでは el ではなく template で指定
})

オプションのオブジェクトを特定のコンポーネントの 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 を通すので子コンポーネントに使う場合どっちを渡しても同じみたいです。下のVue.extendについてで少し続きを書いています。

コンポーネントの呼び出し方

コンポーネントは親となるコンポーネントまたはルートのテンプレートの表示したい場所にカスタムタグを記述して使用します。

<div id="app">
  <my-component></my-component>
</div>

コンポーネントタグ自体に ID などの HTML の属性を付けていた場合コンポーネント側のテンプレートのルートタグに上書き、複数の値が指定可能な class などはマージされます。

<div id="app">
  <my-component class="p"></my-component>
</div>

実際に表示されるHTML

<div id="app">
  <p class="p">example</p>
</div>

条件でコンポーネントを選択するなど特別な理由がある場合 is="コンポーネント名" を使って要素をコンポーネントに置き換える事も出来ます。

<div is="my-component"></div>
コンポーネント名を返すデータや式も使える。
<div :is="currentComponent"></div>

データは関数で作成する

コンポーネントのデータはデータを返す関数にします。これはインスタンス毎にデータを区別するためと変化を察知するためのようです。

Vue.component('my-component', {
  template: '<p>example</p>',
  // ルートのオプションと少し違うので注意
  data: function() {
    return {
      message: 'hello!'
    }
  }
})

スコープの存在

スコープというのは影響範囲でコンポーネントを使う場合発生します。大まかに言うと自分で作成したデータとメソッドに加えて自分自身のテンプレート範囲が自分のスコープです。各コンポーネントはそれぞれのスコープを持っているので他のデータやメソッドにはアクセス出来ませんが、完全にアクセス出来ないと不便なので勿論アクセスする方法もあります。

親子間でデータの受け渡しをする

自分のテンプレート内に他のコンポーネントを配置すると親子関係という事になります。

早速だけど今後絶対的に必要になる親子間のデータのやりとりをしてみましょう。自分のスコープ外のデータにアクセスしたい場合は何を使うか&何を使わせるか明示的にする必要があります。最初は若干面倒に感じるかもしれないけど公式で推奨されてるコポーネント同士の密結合を抑えて連携する方法です。

親から子

親が持っているデータを子のテンプレート内で表示させます。

コンポーネントのタグを記述する時に、単純な属性 または v-bind ディレクティブ( : で省略可能)を付けたバインドタイプの属性で使わせるデータを指定します。

ソースコード

<div id="app">
  <!-- A:単純な属性では文字列を渡せる -->
  <child-component val="message"></child-component>
  <!-- B:バインドタイプの属性ではデータまたは式を渡せる -->
  <child-component :val="message"></child-component>
</div>

子は props でその属性を受け取る事で値を使えるようになります。別のデータに代入するみたいな感じですがスコープが違うので属性名はデータと同じでも別の名前を使ってもOK。

// 子
Vue.component('child-component', {
  template: '<p>{{ val }}</p>',
  props: ['val'] // 受け取る属性名を指定
})
// 親
new Vue({
  el: '#app',
  data: {
    message: 'hello!' // 親が持っているテキストデータ
  }
})

結果


message
hello!

Aの場合なら単純にテキストとして渡されるので「message」Bの場合なら親のデータの `message` の値の「hello!」が表示されるはずです。

props には受け取り型を指定しておくのが推奨されています。スタイルガイド的には必須のようです。うっかり指定した型以外で渡ってしまっていたらエラーにしてくれるのでバグにも気づきやすくなります。

Vue.component('child-component', {
  props: {
    val: String // 文字列型のデータのみ許可する
  },
  mounted() {
    // props で受け取ったら自分のデータと同じように this で使用できるようになる
    console.log(this.val)
    this.val = '勝手に変更してやるぜ' // ※親に怒られるよ!
  }
})

この時 val は親から借りているだけなので子側で内容を書き換えようとすると怒られてしまう。以下の $emit を通して親に変更する事を伝えます。

子から親

親の持っているデータを子の都合で変更したい場合や、元々子が持っているデータを親が参照したり場合は カスタムイベント$emit を使用します。カスタムイベントは @click と同じようなイベントを自作でき jQuery の on と似たように作用します。

コンポーネントのタグを記述する時に v-on ディレクティブ(@で省略可能)で子のイベントへの処理を登録しておきます。子が適当なタイミングで $emit を使ってイベントを発火すると親側で登録してある処理が実行されます。

以下の例は childs-event という名前のイベントに parentsMethod という名前のメソッドを登録しています。値の部分は式でもOK。

親側のテンプレート内

<child-component @childs-event="parentsMethod"></child-component>

子側でイベントを発火する

親にデータを渡したい場合はパラメータをもって発火することも出来ます。

this.$emit('childs-event', 'hello!')

親側で受け取る

new Vue({
  el: '#app',
  methods: {
    parentsMethod: function (message) {
      alert(message) // 子から受け取ったメッセージを使用
    }
  }
})

ちなみに関数を括弧付きにしてインラインで実行する場合持たせるパラメータは親のスコープのデータになる。

<child-component @childs-event="parentsMethod(parentsData)"></child-component>

インラインで実行した場合に子が渡しておいたデータは $event で使用できるので、コンポーネントに v-for を使っていて親側からもインデックス番号を添付したい時に便利。

親子間でデータを送受信する

上記のポイントを踏まえてコンポーネントを作ってみましょう。

<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 を使って実行したりとか何度もいろんな書き方しまくってハマってて理解するのに結構時間を使いました。初めてだとわりとしんどい気がしますこのセクション。疎結合を意識するなら $parent$children はなるべく使わない方が良いようです。

インタラクティブなコンポーネント

もう少し実用的っぽい感じの例でフレンズ用のリストをコンポーネントを使って作ってみます。「イイネ」を押すとカウンターが増えて算出プロパティでカウンターの数値順にソートされるようにします。

テンプレートの書き方は inline-template で書く方法と セレクタを指定する2種類を使いました。

<div id="app">
  <friends-list inline-template>
    <div>
      <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>
<script type="text/x-template" id="tpl-friends-profile">
  <li>
    name:<span :style="{color: val.color}">{{ val.name }}</span> [{{ val.count }}]
    <span @click="$emit('count-trigger')" class="iine">イイネ</span>
  </li>
</script>

マニュアルからコピペしただけの組み込みコンポーネントの transition-group も使ってみました。トランジションは基本 CSS でアニメーションをしているのですが、終わったタイミングで非表示を入れてくれるので display:none; を気にせず簡単に使えてとってもたのしい!

/* プロフィール詳細用のコンポーネント */
var friendsProfile = Vue.extend({
  props: ['val'],
  template: '#tpl-friends-profile'
})
/* プロフィールリスト用のコンポーネント */
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 使って 保存&取得したりすると割りとそれなりな感じになりますね。

ちなみにこれはプログラムによる発火ではなく単純にマウスイベントのクリックで発火するので、イベント名は count-trigger じゃなくて click で伝搬してもいいのですね~。コンポーネント自体についたイベントは .native 修飾子を付けなければ発火しません。それから props をオブジェクトで受け取るならバグを見つけにくくなるので、横着しないで validator を使って中身の型チェックもやった方がよさそうです。なんか簡単に出来ないですかねこれ。(TypeScriptを使えと…?(´•ω•`))

コンポーネントに v-model を使う

上ではカスタムイベントを作成していますが、コンポーネントに v-model で値を紐付けてあると通常の input イベントを $emit で使えます。コンポーネントをフォームアイテムのように扱う事ができる。

<my-select v-model.number="current"></my-select>

おおむねちょっと凝った感じのフォームを自作するときに使います。

例えば上の例の my-select コンポーネントに v-modelcurrent を紐付けていたら、my-select コンポーネントから input イベントを発火させると親側で current に対して input された事になります。

this.$emit('input', newid)

リアルタイムに反映させたい場合はこっちの方がシンプルに書けそうです。

Vue.extend について

クラス拡張の Vue.extend は結構色々な使いみちがありそうです。

Vue.extend の返り値は new でインスタンスを作成して独自の要素にマウントする事も出来ます。主にユニットテストに使用される事が多いですが、この特性を使ってメインアプリから飛び地になったコンポーネントエリアを作成出来ます。普段はあまり使う方法じゃないですが Vue を使ったライブラリを作る時のモーダルウィンドウとかで役に立ちます。オブジェクトを使いまわせる点以外は new Vue でも違いは殆ど無いです。

<h2>#app</h2>
<div id="app">
  <button @click="increment('sub1')">sub1</button>
  <button @click="increment('sub2')">sub2</button>
</div>
<h2>#sub1</h2>
<div id="sub1"></div>
<h2>#sub2</h2>
<div id="sub2"></div>
var store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment: function(state) {
      state.count++
    }
  }
})
/* サブエリア用コンポーネント */
var ChildComp = Vue.extend({
  data: function() {
    return {
      count: 0
    }
  },
  template: '<div :id="$el.id"> Store:{{ $store.state.count }} Local:{{ count }}</div>',
  created: function() {
    this.$on('increment', function() {
      this.count++
    })
  }
})

/* 親のルートコンストラクタ */
var app = new Vue({
  el: '#app',
  store: store,
  data: {
    sub1: null,
    sub2: null
  },
  methods: {
    increment: function(key) {
      this[key].$emit('increment')
      this.$store.commit('increment')
    }
  },
  created: function() {
    // store をそのまま使うために parent を設定するか sotre を渡す
    this.sub1 = new ChildComp({ el: '#sub1', parent: this })
    this.sub2 = new ChildComp({ el: '#sub2', store: store })
  }
})

独自にマウントさせた場合デフォルトのルートは new した時のコンポーネントになるため、子コンポーネントとして扱いたい場合は parent オプションで親インスタンスを設定しておくとストアなどそのまま利用出来ます。

親が子のメソッドを実行したい場合

適当なデータを props で渡しておいて、親がそのデータを変更する事で子がする処理のタイミングを操作出来ます。あとの記事で書いているウォッチャを使うといいでしょう。

また、ref属性 で子のインスタンスを使えるようにしておくと親側から子のイベントを $emit 出来ます。

子コンポーネントは created 時などに $on を使って自分で処理を登録しておきます。

Vue.compotent('child-component', {
  created: function () {
    this.$on('childs-event', this.childsMethod)
  },
  methods: {
    childsMethod: function () {
      // 処理
    }
  }
})

親が子のイベントを発火させると、流れとして子の childsMethod を実行出来ます。

<child-component ref="child">
this.$refs.child.$emit('childs-event')

$refs を使えるなら直接メソッドをコールする事も出来るので、私はおうちゃくして $refs.child.childsMethod() とかたまにやってますが、大きなプロジェクトの場合はあんまりよろしくないかも。なるべく汎用的な名前でイベント名を固定させておいてメソッドを自由に登録する方が拡張性が高くなりそうです。

テンプレートの種類

Vue ではテンプレートの書き方が複数あり状況に応じて選択出来ます。実行時にテンプレートをコンパイルする必要が無ければややコンパクトサイズなランタイム限定ビルドの Vue.js を使用出来ますが、Webpack などを使わない序盤では完全ビルドを使用します。

基本のテンプレートオプション

オプションのオブジェクトで指定します。

Vue.extend({
  template: '<p>テンプレート</p>'
})

もし ES6 の記述が可能ならバッククォートのテンプレートリテラルを使うと楽。

Vue.extend({
  template: `<div>
    <p>テンプレート</p>
    <p>テンプレート</p>
  </div>`
})

Vue.jsのモード:完全ビルド

text/x-template + セレクタ

script タグに text/x-template を使うとブラウザに DOM と認識されずにテンプレートを記述する事ができる。

<script type="text/x-template" id="tpl">
  <p>テンプレート</p>
</script>

コンポーネントのオプションで要素のセレクタを指定。サンプルなどコードをシェアする時に便利だけど運用で使用するのは推奨されていない。

Vue.extend({
  template: '#tpl'
})

Vue.jsのモード:完全ビルド

inline-template

コンポーネントの内側にある HTML をテンプレートとして使用します。記述されている場合 render 関数より優先度が高い。

<my-component inline-template>
  <p>テンプレート</p>
</my-component>

Vue.jsのモード:完全ビルド

描画関数 render

テキストベースではなく JavaScript ベースで DOM 構造を作成します。テンプレートをスクリプトで動的に作成したい場合など少し複雑になる時に使用します。記述されている場合 template オプションより優先度が高い。これは序盤で使うことは殆ど無いと思います。

Vue.extend({
  render: function (createElement) {
    return createElement('element', { options })
  }
})

Vue.jsのモード:ランタイム限定ビルド可

単一ファイルコンポーネント

Vue でガシガシ書いていく場合これがメインになってくる。.vue 拡張子のファイルにコンポーネント単位で HTML や CSS や JavaScript を分離するのだけど、このファイルは今までのように <script> で読み込むだけだと使用できないので Webpack などのバンドルツールを使う必要がある。これは長くなるので次回の記事で書いています。

Vue.jsのモード:ランタイム限定ビルド可

テンプレート内はタグで囲もう

少したった頃に私はハマったんですけどテンプレート直下の要素はひとつだけじゃないといけません。直下に複数の要素は記述できないので注意。

ようするにこれは NG

Vue.extend({
  template: '<p>テンプレート</p><p>テンプレート</p>'
})

何かしらのタグで囲んであげましょう(•ө•)ノ リストなら <li>内容</li> とかでもOK。

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 や ルートの HTML に直接書かれているテンプレートでキャメルケースを使うと意図したように動かないなどハマる事があるので注意が必要。2017/10にベータ版で公開された公式スタイルガイドでは詳しく書かれている。私はややこしいので基本ケバブケースで統一して書いています。

ハマりポイント&Tips

props と $emit のバケツリレーがつらい

単純なやりとりなら $emit で十分ですが、コンポーネントのネストが深くなったり送受信するデータの量が増えると面倒だしミスも増えてきます。そう感じ始めたら Vuex での状態管理を導入するのがオススメです。

なぜか受け取れない属性名のハマりポイント

変数名に大文字を使いたい場合、DOM 側に記述する属性名はケバブケースにする必要があります。

<child-component :parentMessage="message"> × </child-component>
<child-component :parent-message="message"> ○ </child-component>

スクリプト側で使用する場合はキャメルケース parentMessage です。

スコープのハマりポイント

ちなみに初めてだと若干ハマりポイントになりそうなのが、子コンポーネントの記述タグにバインドしている値のデータやメソッドは親のスコープです。

<child-component :子に渡った時の変数名="ここは親のスコープ">

コンポーネントにイベント処理が登録されている場合、親が子に登録したイベントを発火するのではなくて子側で自分のイベントを発火させます。

<child-component @子が発火させる為のイベント名="ここは親のスコープ">

v-if と組み合わせた時にデータが残るハマりポイント

例えばタブ表示などを実装する際、以下のように同じコンポーネントを複数配置して v-if で切り替えた場合キャッシュの効果によって状態が残ってしまいます。

これを回避するには v-show を使うようにするか、リストで使用する時と同じようにコンポーネントにユニークな key を設定すると良い。

<child-component v-if="current=='a'" key="a">A</child-component>
<child-component v-if="current=='b'" key="b">B</child-component>

ちなみに key を設定すると切り替えの度にライフサイクルが発生するようになるけど keep-alive を使うと key ごとに状態をキャッシュするようになります。

おわりに

コンポーネントは Vue の大きな特徴で小規模なアプリでも重要になってくるのでどんどん使って早めに慣れるとこれから先も凄くスムーズになると思います。そのうち体が勝手にコンポーネント化してしまうようになるはず!

Webpack など使わない場合だとグローバルをガンガン汚してしまうので即時関数で囲った方がよさそうです。

しかし、だんだん長くなってきたテンプレートをスクリプト内に書くのは結構大変です。次回はちょっと気が早いかもですが、もっと快適に Vue を書くために単一ファイルコンポーネントを使える環境を作りたいと思います。