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

更新日
2017.09.14
作成日
2017.02.16

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

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

例えば私のサイトのブログだと、ヘッダー、フッター、メニュー、記事一覧、お問い合わせフォームというようなまとまりがあり、さらに記事一覧の中には一つの記事・タグなどネストされた部品もあります。これをひとつひとつ独立させる事が出来ます。

細かくコンポーネント化させることで機能やデータ、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 を通すので子コンポーネントに使う場合どっちを渡しても同じみたいです。下のVue.extendについてで少し続きを書いています。

テンプレートの中でタグを書いて使用できる

コンポーネントは HTML 上の描画したい場所にカスタムタグで記述して使用します。

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

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

<my-component class="p"></my-component>

実際に表示される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>`
})

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

text/x-template + セレクタ

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

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

コンポーネントのオプションでは要素の ID を指定します。

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

通常の要素をテンプレートとして使うことも出来ますが、これは DOM と認識されるのでケバブケースにするなど少し注意する必要があります。

<div id="tpl">
  <p>テンプレート</p>
</div>

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

描画関数 render

テンプレートをスクリプトで動的に作成したい場合や外部から HTML を取得する場合など少し複雑になる時に使用します。記述されている場合 template オプションより優先度が高いです。序盤で使うことは殆ど無いと思います。

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

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

inline-template

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

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

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 に直接書かれている部分はブラウザには通常の DOM と認識されます。ややこしいので基本ケバブケースで統一して書くのが良いと思います。

スコープの存在

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

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

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

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

親から子

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

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

ソースコード

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

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

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

結果

message
hello!

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

デバッグがしやすくなるのでなるべく受け取り型を指定しておくのが推奨されています。運用の際のバリデーションではなくて開発中にうっかり文字列以外で渡ってしまっていたらエラーにしてくれるのでバグにも気づきやすくなります。

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

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

子から親

子が持っているデータを親が参照したり子が親の持っているデータを変更したい場合はカスタムイベント$emit を使用します。これは addEventListener や jQuery の on などのイベントリスナーと似たように作用します。

親は子コンポーネントのタグを配置する際に 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)"></child-comp>

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

<div id="app">
 <child-comp @childs-event="parentsMethod($event, 'Dog')"></child-comp>
</div>
<script>
Vue.component('child-comp', {
  template: '<button @click="$emit(\'childs-event\', message)">click!</button>',
  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 を使って実行したりとか何度もいろんな書き方しまくってハマってて理解するのに結構時間を使いました…。初めての人は結構分かりにくいと思います。

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

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

テンプレートの書き方は 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; を気にせず簡単に使えてとってもたのしい!

/* プロフィール詳細用のコンポーネント */
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 で値を紐付けてあると通常のフォームイベントを $emit で使えます。コンポーネントをフォームアイテムのように扱う事ができます。

<friends-profile
  v-for="(friend, idx) in sortedFriends"
  :val="friend"
  :key="friend.id"
  v-model="friend.count"></friends-profile>

例えば上の例の friends-profile コンポーネントに v-modelfriend.count を紐付けていたら、friends-profile コンポーネントから input イベントを発火させると親側で friend.count に対して input された事になります。カスタムイベントと同じで引数も使えます。

this.$emit('input', val.count + 1)

@input="friend.count = $event" が省略出来るわけですね。

リアルタイムに反映させたい場合はこっちの方がシンプルに書けそうです。ボタンを押した時に反映するとか送信を遅延させたい場合は、親データとは分離した内部データで持っておいて送信したいタイミングになったら input やカスタムイベントでその値を送信するのが Good みたいです。

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-comp', {
  created: function () {
    this.$on('childs-event', this.childsMethod)
  },
  methods: {
    childsMethod: function () {
      // 処理
    }
  }
})

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

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

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

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

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

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

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

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

スクリプト側で使用する場合は parentMessage です。

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

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

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

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

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

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

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

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

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

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

簡単に言えばコンポーネントは関数コール

JavaScript でコードを書く時長くなったり使いまわすコードはクラスや関数に分けますが、コンポーネントを分けるというのはそれと似てます。props は引数で、イベント・カスタムイベントはコールバックと考えると分かりやすい気がするですけどどうでしょう。

// comp1 の内容
function comp1(props, callbacks) {
  var count = 0 // コンポーネントのデータ
  // comp2 を配置
  comp2(
    { count: count },
    { countTrigger: function(newCount) { count = newCount }}
  )
}
// comp2 の内容
function comp2(props, callbacks) {
  callbacks.countTrigger(props.count + 1) // イベントを実行
}
// ルートで comp1 を配置
comp1()

余計わからなくなる場合は無視してください。

まとめ

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

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