Component

コンポーネントという機能

Vue.jsのコンポーネントとは、機能(役割)ごとにテンプレートとJavaScriptを1つにまとめて管理するという仕組みのこと。
公式によると「全てのVueコンポーネントはVueインスタンスであり、同じオプションオブジェクトを受け入れる(いくつかのルート特有のオプションを除く)」とある。
つまりnew Vue()で生成されたルートインスタンスもコンポーネントであり、これを基盤に再利用可能な複数のコンポーネントを部品のように組み合わせるとメンテナンス性の高いサイトやアプリケーションを作ることができる! というのがVue.jsのウリのよう。

コンポーネントの定義

再利用可能なコンポーネント(カスタムコンポーネント)には、すべてのVueインスタンスで利用可能なグローバルコンポーネントと、特定のコンポーネントの領域内でのみ使用するローカルコンポーネントがあり、どちらも任意の名前で作成したタグ(カスタムタグ)で呼び出すことができる。

コンポーネントを定義するポイントは、必ずルートインスタンスが生成される前に定義すること。コンポーネントが記述されたスクリプトファイルを読み込む際は、new Vue()を記述したファイルより前になるよう順番に気をつける。

グローバルコンポーネント

グローバルコンポーネントはvue.componentメソッドを使って登録する。第一引数にはコンポーネント名、第二引数にはコンポーネントのオプションを持たせる。

// 子となるグローバルコンポーネント
Vue.component('global-component', {
  template: '<div><p>{{ message }}</p><p>グローバルコンポーネントの使い方</p></div>',
  data: function(){
    return {
      message: 'Hello Vue!'
    }
  }
});
---------------------------
// 親のテンプレートに子コンポーネントのカスタムタグを設置
<div id="App">
  <global-component></global-component>
</div>
---------------------------
// 親となるコンポーネント
const vm = new Vue({
  el: '#App'
});

ローカルコンポーネント

ローカルコンポーネントはコンポーネント名を変数名として、オブジェクトリテラルで定義。
特定のコンポーネントのcomponentsオプションに登録することでそのコンポーネントの範囲内でのみ使用が可能なローカルスコープとなる。

// 子となるローカルコンポーネント
const LocalComponent = {
  template: '<div><p>{{ message }}</p><p>ローカルコンポーネントの使い方</p></div>',
  data: function(){
    return {
      message: 'Hello Vue!'
    }
  }
}
---------------------------
// 親のテンプレートに子コンポーネントのカスタムタグを設置
<div id="App">
  <local-component></local-component>
</div>
---------------------------
// 親となるコンポーネントに子コンポーネントを登録
const vm = new Vue({
  el: '#App',
  components: {
    'local-component': LocalComponent
  }
});

コンポーネント名とカスタムタグの命名規則

コンポーネントの名前は、ケバブケース(kebab-case)か、パスカルケース(PascalCase)のどちらかを選択することができるが、コンポーネントを呼び出す場所がhtmlに記述されたテンプレート内(ルートインスタンスのelオプションで定義した領域内)である場合は、カスタムタグはケバブケースにしなければならない(パスカルケースは正しくブラウザに認識されないため)。

ローカルコンポーネントは変数名がコンポーネント名であり、必然的にパスカルケースとなるため、親となるコンポーネントにはカスタムタグ名(kebab-case)を、keyとして値にコンポーネント名(PascalCase)を登録する。

コンポーネントの名前はそのコンポーネントの機能や役割がわかる名前が好ましく、単語ひとつよりふたつ以上でできるだけ簡潔な名前をつけるとよい。

コンポーネントのオプション

コンポーネントはVueインスタンスなので、ルートインスタンスのそれと同じようにdata、methods、computed、watchなどのオプションが定義できるが、dataオプションだけはオブジェクト形式ではなく、オブジェクトを返す関数リテラルで記述する。またelオプションはルート固有のものなのでグローバル、ローカルコンポーネントでは使用できない。

コンポーネントオプション
data コンポーネントのdataオプションはオブジェクトリテラルを返す関数で記述する。
template コンポーネントのテンプレート。
ここで指定するテンプレートは必ず単一のタグで終了しなければならい。
NG「template: <span>{{name}}</span> : <span>{{price}}円</span>」
OK「template: <p><span>{{name}}</span> : <span>{{price}}円</span></p>」
props 親要素から子要素に渡されたデータの受け皿。

コンポーネント間のやりとり

コンポーネントは親であるルートインスタンスまたは他のコンポーネントと組み合わせて使用するのが基本となる。しかしそれぞれが独立した存在なので、コンポーネントは自身(this)の外にあるデータやメソッド、テンプレートなどに直接アクセスすることはできない。受け取り側と受け渡す側がそれぞれ態勢を整えることで、データの共有や相手のメソッドを呼び出すなどが可能になるいくつかの方法がある。

親コンポーネントから子コンポーネントにデータを受け渡す方法

まずデータを受け取る側(子)がデータの受け皿となるpropsオプションに、親から受け取る属性(プロパティ)名を定義する。
次にデータを送る側(親)は、子コンポーネントのpropsオプションに定義した属性をそのカスタムタグに追加して、送りたいデータを値として持たせる。これで親の持つデータを子コンポーネントで扱うことができるようになる。

イメージとしては、上層階にいる親が下層に向けて属性という容器に入れたデータを落とし、子がそれを受け皿でキャッチしている感じ。このため親から子へのデータのやり取りは単方向になる。

// 子となるコンポーネント
Vue.component('text-link', {
  template: '<p><a v-bind:href="url" class="c-txtLink" target="_blank">{{ text }}</a></p>'
  props: ['text', 'url'],
});
---------------------------
// 親となるテンプレート
<div id="App">
  <text-link text="Yahoo! JAPAN" url="https://www.yahoo.co.jp"></text-link>
  <text-link text="Google マップ" url="https://www.google.com/maps/@35.681983,139.773973,15z?hl=ja"></text-link>
</div>
---------------------------
// 親となるコンポーネント
const vm = new Vue({
  el: #App
});

送るデータが親コンポーネントが持っているリアクティブなデータの場合、v-bindディレクティブでカスタムタグにバインドすることで子コンポーネントにデータを反映できる。

propsで受け取ったデータを子コンポーネント内で直接書き換えることはできないので、データに何らかの変更を加えたい場合は、算出プロパティで新しいデータを作成してから自身のテンプレートにバインドする。

// 子となるコンポーネント
Vue.component('wish-list', {
  template:'<li>{{name}} : {{filter}}円(税込)</li>',
  props: ['name', 'price', 'tax'],
  computed: {
    filter() {
      const total = Math.round(this.price + this.price * this.tax / 100);
      return total.toLocaleString();
    }
  }
});
---------------------------
// 親のテンプレートに子コンポーネントのカスタムタグを設置
<div id="App">
  <ul>
    <wish-list v-for="item in items" v-bind:key="item.id" v-bind:name="item.name" v-bind:price="item.price" v-bind:tax="tax"></wish-list>
  </ul>
</div>
---------------------------
// 親となるコンポーネント
new Vue({
  el: '#App',
  data: {
    items: [
      { id: 1, name: '人間工学オフィスチェア', price: 21817 },
      { id: 2, name: 'FINAL FANTASY VII REBIRTH', price: 9000 },
      { id: 3, name: 'PINARELLO Prince', price: 465000 },
      { id: 4, name: 'エアウィーヴ 02 シングル', price: 100000 }
    ],
    tax: 10
  }
});

propsで受け取るデータ(値)の型

propsでは受け取るデータの型を指定しておくことが推奨されている。
例えば、数値での計算が必要なとき、propsで受け取ったデータが文字列であったとしても、Vue.jsでは何故かコンソールにエラーが出ない。そのためレンダリングが意図しない結果になってもどこに原因があるのか把握しにくく解決までにムダに時間を取られる可能性がある。それを回避するためpropsは極力下記のように書いておくと、間違った型を渡した場合ブラウザのコンソールでエラーを確認できるようになる。

props: { '属性(プロパティ)名': 型 }

もっと詳しい指定が必要な場合は公式を参考にする。

複数の属性(プロパティ)をまとめてpropsに渡す

子に渡すデータがプロパティが複数あるオブジェクトの場合、v-bindにオブジェクトを渡すと一括でバインドできるので、テンプレートの可読性が保たれる。

// 子コンポーネント
Vue.component('obj-bundle', {
  template: '<p v-bind:style="styling"></p>',
  props: ['styling']
});
---------------------------
// 親のテンプレートに子コンポーネントのカスタムタグを設置
<obj-bundle v-for="styling in styleObject" v-bind:style="styling"></obj-bundle>
---------------------------
// 親コンポーネント
new Vue({
  el: '#App',
  data: {
  styleObject: [
    { display: 'inline-block', width: '30%', height: '100px', background: '#4d7dc5' },
    { display: 'inline-block', width: '40%', height: '150px', background: '#4fc54d' },
    { display: 'inline-block', width: '30%', height: '200px', background: '#eb4034' }
  ]
});
親から来た不要な属性を DOM に継承させたくない場合

v-bindを使って複数のプロパティを一括でバインドする方法は便利だが、実はちょっとした落とし穴というか、気をつけたい特徴がある。

Vue.component('child-component', {
  template: '<li>{{name}}</li>',
  props: {'name': String}
});
---------------------------
<div id="App">
  <ul>
    <child-component v-for="(person, i) in persons" v-bind:key="person.id" v-bind="person"></child-component>
  </ul>
</div>
---------------------------
new Vue({
  el: '#App',
  data: {
    persons: [
      {id: 1, name: 'Akitaka', age: 17, gender: male},
      {id: 2, name: 'Yuumi', age: 16, gender: female},
    ]
  }
});
---------------------------
// HTMLには以下のように出力される
<ul>
  <li id="1" age="17" gender="male">Akitaka</li>
  <li id="2" age="16" gender="female">Yuumi</li>
</ul>

上記を見ると、テンプレートで出力したい属性はnameだけなのに、実際は、id、age、gender属性がタグに付与された状態で出力されている。
これはv-bind="person"で、personオブジェクトのすべてのプロパティを渡しているが、propsで、きちんと受け取っていない(定義されていない)、こぼれたプロパティが、意図していないところに付着してしまってHTML属性としてそのまま DOM に反映されているような状態。

これを防ぐには、v-bind:name="person.name"のように明示的に必要なものだけを渡すようにするか、子コンポーネント側にinheritAttrs: falseを追加すると、未定義の属性を HTML要素に自動で渡さなくなる。

Vue.component('child-component', {
  inheritAttrs: false, // ← ここに追加
  template: `

子コンポーネントから親コンポーネントにデータを受け渡す方法

子から親にデータを渡す仕組みをざっくり例えると、子は自力で親のいる上階へ荷(データ)を運ぶことができないので、親が荷を受け取るためのリフト(イベントハンドラ)を用意する。子が$emitという装置を使って合図(カスタムイベント)を送ると、親側でv-onが子の合図を検知しリフトが自動可動しデータを受け取るといった感じになる(あくまで個人的な解釈)。

$emitはVueのインスタンスメソッドのひとつで、第一引数にイベント名、第二引数以降にイベントハンドラに渡す任意のデータを必要なだけ持たせることができる。

// 子となるコンポーネント
const ChildProduct = {
  inheritAttrs: false,
  template: `
    <li>
      ◎{{name}}:在庫{{stock}}
      <button v-if="stock" v-on:click="doSaleStock">買う</button>
      <span v-else>完売しました</span>
    </li>
  `,
  props: {
    'name': String,
    'stock': Number,
    'index': Number
  },
  methods: {
    doSaleStock() {
      // カスタムイベント(親に知らせるイベントを発火)
      this.$emit('child-click', this.index);
    }
  }
};
---------------------------
// 親のテンプレートに子コンポーネントのカスタムタグを設置
<div id="App">
  <ul>
    <child-product v-for="(item,i) in product" v-bind:key="item.id" v-bind:index="i" v-bind="item" v-on:child-click="saleStock"></child-product>
  </ul>
</div>
---------------------------
// 親となるコンポーネント
new Vue({
  el: '#App',
  components: {
    'child-product': ChildProduct
  }
  data: {
    product: [
      {id: 1, name: 'フルーツタルト', stock: 3, price: 410},
      {id: 2, name: '極生塩パン', stock: 10, price: 180},
      {id: 3, name: '草餅', stock: 1, price: 80}
    ]
  },
  methods: {
    // イベントハンドラ(子のイベントを検知して発火)
    saleStock(i) {
      if (this.product[i].stock > 0) {
        this.product[i].stock -= 1;
      }
    }
  }
})

送るデータがなくても、子のイベントだけをキャッチさせイベントハンドラを呼び出すこともできる。

// 子コンポーネント
Vue.component('child-btn', {
  template: '<div><button v-on:click="handleClick">子側でクリック</button> {{msg}}</div>',
  props: { 'msg': String },
  methods: {
    handleClick() {
      this.$emit('child-event');
    }
  }
});
---------------------------
// 親テンプレートに子コンポーネントのカスタムタグを設置
<div id="App">
  <child-btn v-on:child-event="parentMethods" v-bind:msg="msg"></child-btn>
</div>
---------------------------
// 親コンポーネント
new Vue({
  el: '#App',
  data: {
    msg: 'イベント待機'
  },
  methods: {
    parentMethods() {
      this.msg = '親側でイベントをキャッチ';
    }
  }
});

親子間で双方向データバインディングする方法

単純な一行テキストボックスをコンポーネント化し、前述した「親から子」「子から親」へのデータ受け取りの仕組みを両方設定する。

// 子コンポーネント
Vue.component('custom-input', {
  template: `<p>
    <input v-bind:value="value" v-on:input="$emit('child-input', $event.target.value)">
    子側:{{value}}
    </p>`,
  props: ['value'],
})
---------------------------
// 親テンプレート
<custom-input v-bind:value="testText" v-on:child-input="testTextEvent"></custom-input>
<p>親側:{{testText}}</p>
---------------------------
// 親コンポーネント
new Vue({
  el: '#App',
  data: {
    testText: '初期値'
  },
  methods: {
    testTextEvent(event) {
      this.testText = event;
    }
  }
});

親側:{{testText}}

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

上記の方法でもフォームの入力とDOMへの反映が自動化されていることが確認できるが、このコンポーネントにv-modelを使うともう少しだけシンプルに「双方向データバインディング」ができる。

<custom-input v-model="testText"></custom-input>

親側:{{testText}}

ただしこれだけではまだこのフォームに入力してもデータは反映されず用を成さない。
現状フォームに入力された値は$emitされたカスタムイベント「child-input」の引数として親のイベントハンドラに渡されることになっている。しかし親テンプレート側ではv-modelを使うことにしたので、子のカスタムイベントを検知する術(v-on:child-input)がなく入力値を受け取れない。これを解消するには単純に子側で$emitするカスタムイベントを「input」とすればよい。

$emit('input', $event.target.value)

v-modelは糖衣構文でありコンポーネントにおけるv-modelは下記と等しいため、

<custom-input v-bind:value="testText" v-on:input="testText = $event"></custom-input>

子から送られるイベントが「input」であれば、親側で発火を検知しイベントハンドラで入力値を受け取れるからである。なお、ここで登場した「$event」は$emitの第2引数に指定したデータを受け入れられる特別な変数であり、ここではそれをtestTextプロパティに代入している。

このような値を代入するだけの単純なケースであればv-modelを使用したほうが、記述量は抑えられるため状況に応じて使い分けていくといいのかと思う。

v-modelのオプション

コンポーネントにおけるv-modelはvalueをプロパティとして、inputをイベントとして使うことがデフォルトで設定されているが、使用するフォーム要素や状況に応じて別のイベントやプロパティを使いたいときはmodelオプションでカスタマイズが可能である。

// 子コンポーネント
Vue.component('custom-input-checkbox', {
  model: {
    prop: 'checked',
    event: 'change'
  },
  template: `
    <label><input type="checkbox" v-bind:checked="checked" v-on:change="$emit('change', $event.target.checked)">{{checked}}</label>
  `,
  props: {
    checked: Boolean
  },
})
---------------------------
// 親テンプレート
<custom-input-checkbox v-model="testCheckbox"></custom-input-checkbox>
↓ 同義 ↓
<custom-input-checkbox v-on:change="testCheckbox = $event" v-bind:checked="testCheckbox"></custom-input-checkbox>
---------------------------
// 親コンポーネント
new Vue({
  el: '#App',
  data: {
    testCheckbox: false
  }
})

.sync修飾子で複数のプロパティをバインド

1つのコンポーネントに1つしか使用できないv-model(親から子へ単一の値の受け渡し)に対して、.sync修飾子は際限なく利用できるので複数の値の受け渡しが可能。基本的な使い方はv-modelと同じだが、$emitの第一引数は「update:propsで受け取る属性名」の形でイベントを発火する。なおVue 3ではv-modelを複数使用することができるようになっているため、この機能は廃止されていている。

// 子コンポーネント
Vue.component('sync-component',{
  template: `
    <div class="u-mb1">
      <label>メール<input type="email" :value="email" @input="$emit('update:email', $event.target.value)"></label>
      <label>パスワード<input type="password" :value="password" @input="$emit('update:password', $event.target.value)" size="10"></label>
      子側props【{{email}} : {{password}}】
    </div>
  `,
  props: {
    email: String,
    password: String
  }
})
---------------------------
// 親テンプレート
<sync-component v-bind:email.sync="login.email" v-bind:password.sync="login.password"></sync-component>
<p>親側dataオプション:{{login}}</p>
---------------------------
// 親コンポーネント
new Vue({
  el: '#App',
  data: {
    login: { email: '', password: '' }
  }
})

親側dataオプション: {{login}}

コンポーネントで双方向バインディング使用例

合計:{{totalPrice}}円(税込)

スロットでコンポーネントをカスタマイズ

スロットは同じコンポーネントを複数の個所で配置したいが、場所によってテンプレートに少し要素を追加したい、またはテンプレートの一部をカスタマイズしたいといった場合に使用する。

デフォルトスロット

デフォルトスロットでは親テンプレートに設置したカスタムタグの開始タグと閉じタグの間に、子コンポーネントに差し込みたい要素(スロットコンテンツ)を記述し、子となるコンポーネントのテンプレートに、<slot>タグを追加すると、親側で定義した内容が埋め込まれる。

// 子コンポーネント
Vue.component('slot-component', {
  template: `
    <div class="slotBox">
      <slot></slot>
    </div>
  `
})
---------------------------
// 親テンプレート
<slot-component>スロットコンテンツ</slot-component>
---------------------------
// css
.slotBox{
  display: inline-flex;
  padding: 10px;
  border: 1px solid #b9c9c9;
}
スロットコンテンツ

<slot>タグの内側には親から差し込まれるスロットコンテンツがなかったときのフォールバック(デフォルト)コンテンツが設定できるので、表示の出し分けも可能。またスロットではコンテンツを定義した親側のデータにアクセスができ、親のスコープで使用できるものであれば他のコンポーネントのカスタムタグを定義することも可能。

// 子コンポーネント
Vue.component('slot-component', {
  template: `
    <div class="slotBox">
      <slot>何もないとき</slot>
    </div>
  `
})
---------------------------
// 親テンプレート
<slot-component></slot-component>
<slot-component>{{parentData}}</slot-component>
<slot-component>
  <custom-input-checkbox v-model="testCheckbox"></custom-input-checkbox>
</slot-component>
---------------------------
// 親コンポーネント
new Vue({
  el: '#App',
  data: {
    parentData: '親の持つデータ'
  }
});
{{parentData}}

名前付きスロット

<slot>タグごとにname属性を使って異なる名前をつけることで、1つのコンポーネントで複数のスロットが使用できるようになる。
親側では差し込みたいスロットコンテンツを囲う<template>タグに、v-slotディレクティブに差し込みたい場所に対応したスロット名を引数として指定すればよい。このとき名前付きの<template>タグの範囲外はすべて名前なしスロットにまとめて引き渡されるので、記述には注意が必要。明示的に名前なしスロットにコンテンツを表示したいのであれば、その内容を<template v-slot:default></template>で囲む。

// 子コンポーネント
Vue.component('name-slot-component', {
  template: `
    <div class="nameSlotBox">
      <p class="noname"><slot>名前なしスロット</slot></p>
      <slot name="slot2">名前ありスロットひとつめ</slot>
      <slot name="slot3">名前ありスロットふたつめ</slot>
      <slot name="slot4"></slot>
      <p>ここは子側のテンプレートに元々記述されているところ。</p>
    </div>
  `,
})
---------------------------
// 親テンプレート
<name-slot-component>
  <template v-slot:slot3>
    <p class="bgC02">こちらはスロット名「slot3」に埋め込まれます。</p>
  </template>
  <template #slot2>
    <p class="bgC01">スロット名「slot2」に差し込まれます。「v-slot:スロット名」は「#スロット名」と省略することも可能。</p>
  </template>
  <template v-slot:default>
  このテキストだけが名前なしスロットに入ります。
  </template>
  templateタグの範囲外のこのテキストは名前なしスロットに入ります。ただしv-slot:defaultがある場合は、無視されます。
</name-slot-component>
templateタグの範囲外のこのテキストは名前なしスロットに入ります。ただしv-slot:defaultがある場合は、無視されます。

スコープ付きスロット

親テンプレート側から子コンポーネントの持つデータにアクセスできるようになる機能。子コンポーネント側では、<slot>タグに属性(スロットプロパティ)として子のデータプロパティをバインドし、親側ではv-slotの値として名前を指定することで、スロットプロパティを受け取る。

// 子コンポーネント
Vue.component('scoped-slot-component', {
  template: `
    <div class="nameSlotBox">
      <slot v-bind:childData="childData">{{ childData.childItem }}</slot>
    </div>
  `,
  data() {
    return{
      childData:{
        childItem: '子の持つデータ',
        childItem2: '子の持つデータ2',
        childItem3: '子の持つデータ3',
      }
    }
  }
});
---------------------------
// 親テンプレート
<scoped-slot-component>
  <template v-slot:default="slotProps">
    <p>{{slotProps}}</p>
    <p class="bgC02">{{slotProps.childData.childItem2}}</p>
    <p class="bgC01">{{slotProps.childData.childItem3}}</p>
  </template>
</scoped-slot-component>

スロットプロパティの分割代入

スロットプロパティの取得はES2015から導入された分割代入というものを使った記述でも可能で、子から渡される変数の名前を{}で括りv-slotの値にすると少しだけコードが短くなる。またリネームや、スロットプロパティが未定義だった場合のフォールバックを定義することもできる。

// 子コンポーネント
Vue.component('scoped-slot-component', {
  template: `
    <div class="nameSlotBox">
      <slot v-bind:childData="childData">{{ childData.childItem }}</slot>
      <slot name="scoped-slot" v-bind:childData="childData"></slot>
      <slot name="scoped-slot2"></slot>
    </div>
  `,
  data() {
    return{
      childData:{
        childItem: '子の持つデータ',
        childItem2: '子の持つデータ2',
        childItem3: '子の持つデータ3',
      }
    }
  }
});
---------------------------
// 親テンプレート
<scoped-slot-component>
  <template v-slot:default="{childData}">
    <p>{{childData.childItem3}}</p>
  </template>
  // リネーム
  <template v-slot:scoped-slot="{childData: child_data}">
    <p>{{child_data.childItem2}}</p>
  </template>
  // フォールバック
  <template v-slot:scoped-slot2="{fallback = { fallbackItem: 'フォールバック設定' } }">
    <p>{{fallback.fallbackItem}}</p>
  </template>
</scoped-slot-component>