6.1 子コンポーネントの利用
6.1.1 コンポーネントとは
コンポーネントの基礎(App.vue)
コンポーネント1個
ひとつのコンポーネント(OneSection.vue)
コンポーネントとは、…
コンポーネントが複数
ひとつのコンポーネント(OneSection.vue)
コンポーネントとは、…
ひとつのコンポーネント(OneSection.vue)
コンポーネントとは、…
ひとつのコンポーネント(OneSection.vue)
コンポーネントとは、…
繰り返しに登場する部分
ひとつのコンポーネント(OneSection.vue)
コンポーネントとは、…
Web 画面にはこのように繰り返し登場する部分が多いので、これらを1個の部品として作成して何度も再利用できれば実装の手間が省ける。
さらに、HTML + CSS という見た目の部分だけでなく、そこに動作(スクリプト)部分が加わった部品であればいっそう再利用の価値が高まる。
このHTML、CSS、スクリプトをワンセットとして再利用可能な部品としたものがコンポーネントである。
VueではこのようなHTML + CSS + スクリプトのセットを .vue 拡張子を持つひとつのファイルにまとめて記述できる。これをシングルファイルコンポーネント(SFC)という。
App.vue もシングルファイルコンポーネントのひとつであり、Vue プロジェクトではこの App.vue が全ての画面表示(処理)の始点となる。
6.1.2 コンポーネントの作り方
App.vue から外面の部品となる別のコンポーネントを読み込んで利用するには、利用する先のコンポーネント(子コンポーネント)を作成する必要がある。
Vue プロジェクトではひとつのコンポーネントがひとつの .vue ファイルとなる。
App.vue 以外の SFC (.vueファイル)は src/components フォルダに作成することになっていて、 .vue ファイルを作成すればそれだけでそれがひとつのコンポーネントとなる。
サンプルで App.vue から利用するコンポーネントを src/components/OneSection.vue とする。
表示だけなのでスプリットブロックは無い。
<template>
<section class="box">
<h4>ひとつのコンポーネント</h4>
<p>コンポーネントとは、・・・</p>
</section>
</template>
<style>
.box {
border: green 1px dashed;
margin: 10px;
}
</style>6.1.3 子コンポーネントの利用方法
<script setup lang="ts">
import OneSection from './components/OneSection.vue' // 1.
</script>
<template>
<h1>コンポーネント基礎</h1>
<section>
<h2>コンポーネント1個</h2>
<OneSection /> // 2.
</section>
<section>
<h2>コンポーネントが複数</h2>
<OneSection /> // 2.
<OneSection /> // 2.
<OneSection /> // 2.
</section>
</template>
<style>
section {
border: blue 1px solid;
margin: 10px;
}
</style>
子コンポーネントを利用するときの手順
- 子コンポーネントの .vue ファイルをインポートする
.js ファイルや .ts ファイルのモジュールをインポートする場合は拡張子を記述しないことになっているが .vue ファイルの場合は拡張子が必要な点に注意 - テンプレートブロックにタグを記述
インポートしたときのインポート名と同じ名称のタグを記述すると、そのタグの位置に該当コンポーネントの内容が自動的にレンダリングされる
6.2 コンポーネントの独立性と CSS の扱い
6.2.1 処理が含まれたコンポーネントを埋め込む
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('名無し') // 1-1
</script>
<template>
<section>
<p>{{ name }}さんですね!</p> // 1-2
<input type="text" v-model="name" /> // 1-3
</section>
</template>
<style scoped> /* 1-4 */
section {
border: green 1px dashed; /* 1-5 */
margin: 10px;
}
</style><script setup lang="ts">
import WithMode from './components/WithModel.vue'
</script>
<template>
<h1>コンポーネントの独立性</h1>
<section>
<h2>v-modelを含むコンポーネント</h2>
<WithMode /> <!-- 2-1 -->
<WithMode /> <!-- 2-1 -->
</section>
</template>
<style>
section {
border: blue 1px solid; /* 2-2 */
margin: 10px;
}
</style>
コンポーネントが2つ表示される。
入力欄を変更すると表示が変わるのはそれぞれの子コンポーネントの内部のみである。
これがコンポーネントの独立性である。
6.2.2 コンポーネント内の処理はコンポーネント内で完結
WithModel.vue
(1-2)のマッシュ構文と(1-3)の入力コントロールが v-model ディレクティブと(1-1)のリアクティブ変数 name を介して連動している。
そのため入力欄の内容を変更するとそれに合わせて(1-2)の p タグ内のマスタッシュ構文の表示が変化する。
子コンポーネントである WithModel.vue の中で見た場合、特に新しいことはない。
App.vue
App.vue では WithModel コンポーネントを2個埋め込んでいる。
結果としてはひとつの画面に表示されるが、処理としてはひとつひとつの WithModel コンポーネント内で完結した別の処理として動作する。
6.2.3 スタイルブロックを独立させる scoped 属性
コンポーネントは独立しているが、それは処理に関してのみで、スタイルブロックに記述した CSS に関してはコンポーネントごとに独立しておらず、他のコンポーネントのスタイルブロックの影響を受ける。
これをコンポーネントごとに独立させるための記述が、WithModel.vue の(1-4)の style タグの scoped 属性である。
App.vue の「v-modelを含むコンポーネント」と表示された section タグの枠線は実線であり、これに該当するスタイル記述は(2-2)である。
WithModelコンポーネントの section タグの枠線は破線であり、これに該当するスタイル記述は(1-5)である。
この状態で WithModel (1-4)の scoped 属性を削除すると、全ての枠線が破線になる。
これは、App.vue の section タグセレクタと WithModel.vue の section タグセレクタの両方が読み込まれ、後から読み込まれた WithModel.vue の section タグセレクタが採用されたためである。
この現象を避けるために使われるのが style タグに記述した scoped 属性であり、この仕組みを Scoped CSS という。
6.2.4 Scoped CSS のカラクリ
<div id="app" data-v-app="">
<h1>コンポーネントの独立性</h1>
<section>
<h2>v-modelを含むコンポーネント</h2>
<section data-v-eee87bea="">
<p data-v-eee87bea="">名無しさんですね!</p>
<input data-v-eee87bea="" type="text">
</section>
<section data-v-eee87bea="">
<p data-v-eee87bea="">名無しさんですね!</p>
<input data-v-eee87bea="" type="text">
</section>
</section>
</div>レンダリング結果を見ると WithModel.vue 由来のタグには全て「data-v-eee87bea」という属性が記述されている。
section タグのスタイルを確認すると下記のようになっている。
section[data-v-eee878bea] {
border: green 1px dashed;
margin: 10px;
}セレクタが単なる section タグセレクタではなく「section[data-v-eee878bea]」という属性セレクタになっており、絞り込み対象がレンダリング結果で WithModel.vue 由来のタグに付与されていた属性と一致する。
Scoped CSS が設定されたコンポーネント由来のタグには全て「data-v-####」という属性が自動で付与され、スタイルブロックに記述したセレクタには同じ属性セレクタが自動で付与される仕組みになっている。
プロジェクト全体のCSS設計
- グローバルに適用したい CSS 記述は App.vue のスタイルブロックに記述する。
- App.vue 以外のコンポーネントのスタイルブロックには原則 scoped 属性を記述して Scoped CSS とする。
6.3 親から子へのコンポーネント間通信
6.3.1 親からデータをもらう Props
親コンポーネントから子コンポーネントへの通信、親コンポーネントからのデータを子コンポーネントで受け取る仕組みを Props(プロップス)という。
<script setup lang="ts">
interface Props { // (1)
title: string
content: string
}
defineProps<Props>() // (2)
</script>
<template>
<section class="box">
<h4>{{ title }}</h4>
<p>{{ content }}</p>
</section>
</template>
<style scoped>
.box {
border: green 1px dashed;
margin: 10px;
}
</style>
<script setup lang="ts">
import OneInfo from './components/OneInfo.vue'
</script>
<template>
<h1>Props基礎</h1>
<section>
<h2>属性に直接記述</h2>
<OneInfo title="Propsの利用" content="子コンポーネントにデータを渡すにはPropsを利用する。" />
</section>
</template>
<style>
section {
border: blue 1px solid;
margin: 10px;
}
</style>
OneInfo.vue
(1)個々のProp を記述したインターフェースを定義する
interface Props {
title: string
content: string
}インターフェース名を Props としているが、この名前は何でもかまわない。
ただし、特段の理由がない限り Props としておくのが良い。
親コンポーネントから受け取りたいデータ(各 Prop)は Props インターフェースのメンバとして定義する。
(2)defineProps() 関数を実行する
インターフェースを Props として利用するためには defineProps() 関数を実行する。
その際 <> 内に該当のインタフェースを記述したジェネリクス型指定(動的型指定)を行う。
これによって指定したインタフェースが Props として機能するようになる。
Props 定義の構文
interface Props {
各Prop名: データ型
:
}
defineProps<Props>()6.3.2 親から Props にデータを渡す方法
子コンポーネントを表示するタグに 子コンポーネントの個々の Prop 名を属性として記述することによって属性値が子コンポーネントの該当する Prop 名のテンプレート変数の値として格納される。
<OneInfo title="Propsの利用" content="子コンポーネントにデータを渡すにはPropsを利用する。" />Props へのデータ渡しの構文
<子コンポーネント名
各Prop名="このPropsに渡す値"
:
/>6.3.3 親のテンプレート変数を Props に渡す方法
<script setup lang="ts">
interface Props {
title: string
content: number
}
defineProps<Props>()
</script>
<template>
<section class="box">
<h4>{{ title }}</h4>
<p>{{ content }}</p>
</section>
</template><script setup lang="ts">
import { ref } from 'vue'
import OneInfo from './components/OneInfo.vue'
const propsTitle = ref('発生した乱数')
const rand = Math.round(Math.random() * 100)
const propsContent = ref(rand)
</script>
<template>
<h1>Props基礎</h1>
<section>
<h2>テンプレート変数を利用</h2>
<OneInfo v-bind:title="propsTitle" v-bind:content="propsContent" />
</section>
</template>
<style>
section {
border: blue 1px solid;
margin: 10px;
}
</style>スクリプトブロックで用意したテンプレート変数を割り当てる場合は v-bind ディレクティブを利用する。
v-bind の引数部分(コロンの次の記述)が Porp 名そのものとなる。
こうすることでその Prop にテンプレート変数の値が格納される。
しかも、テンプレート変数のリアクティブシステムがそのまま働くので親コンポーネント内でテンプレート変数の値を変更すると子コンポーネント内に表示された Props の値も連動して変化する。
テンプレート変数の Props へのデータ渡し構文
<子コンポーネント名
v-bind:Prop名="テンプレート変数名"
:
/>6.3.4 v-for と Props との組み合わせ
<script setup lang="ts">
interface Props {
title: string
content: string
}
defineProps<Props>()
</script>
<template>
<section class="box">
<h4>{{ title }}</h4>
<p>{{ content }}</p>
</section>
</template>
<style scoped>
.box {
border: green 1px dashed;
margin: 10px;
}
</style><script setup lang="ts">
import { ref } from 'vue'
import OneInfo from './components/OneInfo.vue'
const weatherListInit = new Map<number, Weather>()
weatherListInit.set(1, { id: 1, title: '今日の天気', content: '今日は一日中、晴れでしょう。' })
weatherListInit.set(2, { id: 2, title: '明日の天気', content: '明日は一日中、雨れでしょう。' })
weatherListInit.set(3, { id: 3, title: '明後日の天気', content: '明後日は一日中、雪でしょう。' })
const weatherList = ref(weatherListInit)
interface Weather {
id: number
title: string
content: string
}
</script>
<template>
<h1>Props基礎</h1>
<section>
<h2>ループでコンポーネントを生成</h2>
<OneInfo
v-for="[id, weather] in weatherList"
v-bind:key="id"
v-bind:title="weather.title"
v-bind:content="weather.content"
/>
</section>
</template>
ループするタグは通常の HTML タグや template タグではなく、子コンポーネント OneInfoである。
ループ対象のタグ内ではエイリアスに記述した変数がそのまま利用できる。
そこで、idを v-bind:key に指定するのに加え Pros にデータを渡す v-bind の値にループで取り出した各要素を指定することでこれらを子コンポーネントに渡すことができる。
この結果、リストデータの要素の数だけそれぞれのデータに基づいた子コンポーネントがレンダリングされる。
6.4 Props の応用
親コンポーネントから渡された Props のデータはそのまま表示するだけではなくスクリプトブロックで利用することができる。
<script setup lang="ts">
import { ref, computed } from 'vue'
// Propsインターフェースの定義
interface Props { // (1)
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
// Propsオブジェクトの設定
const props = defineProps<Props>() // (2)
// このコンポーネント内で利用するポイント数のテンプレート変数
const localPoints = ref(props.points) // (3)
// Propsのnoteを加工する算出プロパティ
const localNote = computed((): string => { // (4)
let localNote = props.note // (5)
if (localNote == undefined) {
localNote = '--'
}
return localNote
})
// [ポイント加算]ボタンをクリックしたときのメソッド
const pointUp = (): void => { // (6)
localPoints.value++
}
</script>
<template>
<section class="box">
<h4>{{ name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ email }}</dd>
<dt>保有ポイント</dt>
<dd>{{ localPoints }}</dd>
<dt>備考</dt>
<dd>{{ localNote }}</dd>
</dl>
<button v-on:click="pointUp">ポイント加算</button>
</section>
</template>
<style>
.box {
border: green 1px solid;
margin: 10px;
}
</style><script setup lang="ts">
import { ref, computed } from 'vue'
import OneMember from './components/OneMember.vue'
// 会員リストデータを用意
const memberListInit = new Map<number, Member>() // (1)
memberListInit.set(33456, {
id: 33456,
name: '田中太郎',
email: 'bou@example.com',
points: 35,
note: '初回入会特典あり',
})
memberListInit.set(47783, {
id: 47783,
name: '鈴木二郎',
email: 'mue@example.com',
points: 53,
})
const memberList = ref(memberListInit)
// 会員リスト内の全会員のポイントの合計算出プロパティ
const totalPoints = computed((): number => { // (2)
let total = 0
for (const member of memberList.value.values()) {
total += member.points
}
return total
})
// 会員情報インターフェース
interface Member { // (3)
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
</script>
<template>
<section>
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints }}</p>
<!-- OneMemberをv-forでループ-->
<OneMember <!-- (4) -->
v-for="[id, member] in memberList"
v-bind:key="id"
v-bind:id="id"
v-bind:name="member.name"
v-bind:email="member.email"
v-bind:points="member.points"
v-bind:note="member.note"
/>
</section>
</template>各会員情報ボックス内の[ポイント加算]ボタンをクリックすると会員保有ポイントが増加する。
一方で、全会員の保有ポイントの合計は変化しない。
App.vue
- (1)会員リストデータを用意。
インターフェースは(3)で定義。 - (2)会員リスト内の全会員のポイント合計を求める算出プロパティ。
- (3)インターフェース。
- (4)リストデータを元に子コンポーネント OneMember を v-for でループして表示。
インターフェース(3)で定義したデータの各 Prop データを渡している。
子コンポーネント OneMember には同様の Props 定義がある。
OneMember.vue
- (1)インターフェース Props。
App.vue の Member インターフェースと同じ内容。 - (2)defineProps() 関数のジェネリクス型指定として Props インターフェースを渡している。
関数の戻り値を変数 props として受け取っている。
defineProps() 関数の戻り値
非必須項目の note はデータがある場合はそのまま表示し、無い場合は「–」と表示している。
その為にはスクリプトブロックで Props の値の有無によって処理を変えるコードが必要である。
このような場合は、defineProps() 関数の戻り値を変数(この例では props変数)で受け取る。
こうすることで、このスクリプトブロック内では Props のプロパティとして個々の Prop データが取り出せるようになる。
const props = defineProps<Props>() // (2)
:
const localNote = computed((): string => { // (4)
let localNote = props.note // (5)
if (localNote == undefined) {
localNote = '--'
}
return localNote
})6.4.2 Props の値の利用の注意点
Props の値は子コンポーネントでは直接変更できない。
Props の値はリアクティブシステムの管理対象であり親コンポーネントで値を変更するとそれが反映されるため、子コンポーネントで独自に値を変更すると不整合が起こりかねないので readonly とされている。
子コンポーネントで値を変更する可能性がある Props はいったんコンポーネント内で別のリアクティブ変数に値をコピーして利用する。
const localPoints = ref(props.points)
:
// [ポイント加算]ボタンをクリックしたときのメソッド
const pointUp = (): void => {
localPoints.value++
}こうすることで localPoints は子コンポーネントである OneMember 独自のリアクティブ変数となり OneMember 内で自由に変更できるようになる。
この仕組みのおかげで [ポイント加算] ボタンをクリックすると子コンポーネント内で独立して各会員のポイント加算が行われる。
一方で、全会員の保有ポイントの合計値は親コンポーネントである App の管理課にある為、変更されない。
コンポーネント内のデータはコンポーネント内で閉じており、親コンポーネント管理下のデータであるポイント合計は子コンポーネントからは直接変更できないようになっている。
そのためこの値を変更するには子コンポーネント OneMember から親コンポーネント App に対してデータ変更の依頼「子から親へのコンポーネント間通信」を行う必要がある。
6.4.3 Props のデフォルト値
非必須項目の note のデータがない場合、算出プロパティを利用して「–」を表示していので、この「–」は非必須 Props のデフォルト値ということになる。
Props には算出プロパティを使わなくても、このようなデフォルト値を設定する仕組みがある。
<script setup lang="ts">
:
// Propsオブジェクトの設定
const props = defineProps<Props>()
// Propsのnoteを加工する算出プロパティ
const localNote = computed((): string => { // (4)
let localNote = props.note // (5)
if (localNote == undefined) {
localNote = '--'
}
return localNote
})
:
</script>
<template>
:
<dd>{{ localNote }}</dd>
:
</template><script setup lang="ts">
:
// Propsオブジェクトの設定
const props = withDefaults(
defineProps<Props>(),
{note: "--"}
)
:
</script>
<template>
:
<dd>{{ note }}</dd>
:
</template>Props にデフォルト値を設定するには withDefaults() 関数を利用する。
note に対して「–」を指定しているので note のデータが存在しないときは「–」と表示される。
ただし、より複雑な条件分岐による表示の加工が必要な場合などはデフォルト値を使うこの方法では対応が難しくやはり算出プロパティを利用することいになる。
withDefaults(
defineProps<Props>(),
{
非必須Prop名: デフォルト値,
:
}
)6.5 子から親へのコンポーネント通信
子から親へのコンポーネント間通信は Emit(エミット)という仕組みで実現できる。
6.5.1 子から親への通信はイベント処理
<script setup lang="ts">
interface Props {
rand: number
}
interface Emits {
(event: 'createNewRand'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const onNewRandButtonClick = (): void => {
emit('createNewRand')
}
</script>
<template>
<section class="box">
<p>子コンポーネントで乱数を表示: {{ rand }}</p>
<button v-on:click="onNewRandButtonClick">新たな乱数を発生</button>
</section>
</template>
<style scoped>
.box {
border: green 1px solid;
margin: 10px;
}
</style><script setup lang="ts">
import { ref } from 'vue'
import OneSection from './components/OneSection.vue'
const randInit = Math.round(Math.random() * 10)
const rand = ref(randInit)
const onCreateNewRand = (): void => {
rand.value = Math.round(Math.random() * 10)
}
</script>
<template>
<section>
<p>親コンポーネントで乱数を表示: {{ rand }}</p>
<OneSection v-bind:rand="rand" v-on:createNewRand="onCreateNewRand" />
</section>
</template>子コンポーネントの [新たな乱数を発生] ボタンをクリックすると、表示された乱数の値が親コンポネント側、子コンポーネント側で同じように変化する。
子コンポーネントで emit() が実行されると、その引数の文字列(createNewRand)を使って次のような処理が行われる
- 子コンポーネントのタグ内の emit() の引数(createNewRand)に合致する親コンポーネント側の v-on ディレクティブの引数を探す。
- 合致した v-on ディレクティブの属性(onCreateNewRand)に注目する。
- 親コンポーネントのスクリプトブロックで、2.と合致するメソッドを実行する。
以上より emit() が実行されたタイミングで親コンポーネントのメソッドが実行されるようになる。
以上の処理の流れを踏まえてコードの記述手順をまとめると次のようになる。
- 親コンポーネントの記述
1-1.処理メソッドの用意
1-2.v-on ディレクティブの記述 - 子コンポーネントの記述
2-1.Emit 定義
2-2.Emitの実行
1-1.親コンポーネントの記述 ー 処理メソッドの用意
親コンポーネントに、子コンポーネントから通知を受けた際に実行する処理メソッドを用意する。
const onCreateNewRand = (): void => {
rand.value = Math.round(Math.random() * 10)
}1-2.親コンポーネントの記述 ー v-on ディレクティブの記述
親コンポーネントの子コンポーネントタグに v-on ディレクティブを記述し、引数としてイベント名を表す任意の文字列、属性値として 1-1.のメソッド名を記述する。
<OneSection v-bind:rand="rand" v-on:createNewRand="onCreateNewRand" />2-1.子コンポーネントの記述 ー Emit の定義
子コンポーネント内で Emit を定義する。
:
interface Emits { // 1.
(event: 'createNewRand'): void // 2.
}
:
:
const emit = defineEmits<Emits>() // 3.
:- Emit をインターフェースで定義。
この例ではインターフェース名を Emits にしている。 - シグネチャ
シグネチャは Props のようなプロパティシグネチャではなくコールシグネチャとなり、引数定義として ( ) の中に引数名を記述する。
この引数名は何でも良いがイベントを表すため event あるいは e 記述することが多い。
この引数 event の型として、1-2.の v-on ディレクティブの引数として指定したイベント名(この例では createNewRand)を文字列で記述する。
戻り値は Void である。 - defineEmits() 関数を実行
定義した Emits インターフェースをジェネリクスとして型指定しながら defineEmits() 関数を実行する。
この仕組みは Props と同じだが Emit の場合は必ずその戻り値を変数 emit で受け取る。
interface Emits {
(event: "イベント名"): void
:
}
const emit = defineEmits<Emits>()※(event: “イベント名”): void は、Vue3.3以降 イベント名:[] でもOK。
※引数がある場合は、イベント名:[引数名:型]
2-2.子コンポーネントの記述 ー Emit の実行
子コンポーネントで親側のメソッドを実行したいタイミングで emit() を実行する。
const onNewRandButtonClick = (): void => {
emit('createNewRand')
}ここで実行する emit() は defineEmits() の戻り値である。
戻り値を関数として実行することで、その引数に該当するイベント名と関連付けられた親コンポーネントのメソッドが実行される仕組みとなっている。
この例ではメソッド onNewRandButtonClick の内部で emit() を実行しているので、[新たな乱数を発生] ボタンをクリックした時にこの処理が実行される。
以上のような手順で子コンポーネントから親コンポーネントのメソッドを実行した場合、そのメソッド内でリアクティブな変数の値を変更すると、これまでと同じようにそれをテンプレート変数として利用している部分の表示も変更される。
この例では、App.vue のテンプレート変数 rand がこれに該当する。
さらに、その変数を Props として子コンポーネントに渡している場合、子コンポーネントでもリアクティブシステムのおかげで値の変更に連動して表示が変更される。
6.5.2 親コンポーネントにデータを渡す方法
メソッドの実行とともにデータを受け渡したい場合は emit() の第2引数にそのデータを指定する。
<script setup lang="ts">
// Propsインターフェースの定義
interface Props {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
// Emit インターフェースの定義
interface Emits {
(event: 'incrementPoint', id: number): void // 1.
}
// Propsオブジェクトの設定
const props = withDefaults(
defineProps<Props>(),
{note: "--"}
)
// Emit の設定
const emit = defineEmits<Emits>()
// [ポイント加算]ボタンをクリックしたときのメソッド
const pointUp = (): void => {
emit('incrementPoint', props.id) // 2.
}
</script>
<template>
<section class="box">
<h4>{{ name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ email }}</dd>
<dt>保有ポイント</dt>
<dd>{{ points }}</dd> // 3.
<dt>備考</dt>
<dd>{{ note }}</dd>
</dl>
<button v-on:click="pointUp">ポイント加算</button>
</section>
</template>
<style>
.box {
border: green 1px solid;
margin: 10px;
}
</style>
<script setup lang="ts">
import { ref, computed } from 'vue'
import OneMember from './components/OneMember.vue'
// 会員リストデータを用意
const memberListInit = new Map<number, Member>()
memberListInit.set(33456, {
id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35, note: '初回入会特典あり',})
memberListInit.set(47783, {
id: 47783, name: '鈴木二郎', email: 'mue@example.com', points: 53,})
const memberList = ref(memberListInit)
// 会員リスト内の全会員のポイントの合計算出プロパティ
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.value.values()) {
total += member.points
}
return total
})
// Emit により実行されるメソッド
const onIncrementPoint = (id: number): void => { // 1.
// 処理関数の id に該当する会員情報オブジェクトを取得
const member = memberList.value.get(id)
// 会員情報オブジェクトが存在するならポイントインクリメント // 2.
if (member != undefined) {
member.points++
}
}
// 会員情報インターフェース
interface Member {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
</script>
<template>
<section>
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints }}</p>
<!-- OneMemberをv-forでループ-->
<OneMember
v-for="[id, member] in memberList"
v-bind:key="id"
v-bind:id="id"
v-bind:name="member.name"
v-bind:email="member.email"
v-bind:points="member.points"
v-bind:note="member.note"
v-on:incrementPoint="onIncrementPoint" // 3.
/>
</section>
</template>
OneMember.vue
- event 引数に加え第2引数として number 型の id を定義
- [ポイント加算]ボタンが押されたタイミングで emit() が実行され親コンポーネントの onIncrementPointメソッドが実行される
- リアクティブシステムにより子コンポーネントにも反映されているので Propsの points をそのまま表示するだけで済む
App.vue
- [ポイント加算]ボタンクリックのタイミングで emit() が実行され親コンポーネントの onIncremdntPoint メソッドが実行される。
onIncrementPoint は引数 id を受け取り、会員リストのMapオブジェクトから該当会員の会員情報オブジェクトを member として取得。 - 会員オブジェクトが存在するならポイントをインクリメント。
この member 内の points への変更はリアクティブシステムにより合計ポイントを表す算出プロパティ totalPoints だけでなく子コンポーネントにも反映される。 - 子コンポーネントタグに v-on ディレクティブを記述。イベント名を登録している。
6.5.3 v-model による子から親への通信
子コンポーネントから親コンポーネントへの通信は v-model を利用するとさらに簡単に実現できる。
<script setup lang="ts">
// Propsインターフェースの定義
interface Props {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
// Emit インターフェースの定義
interface Emits {
(event: 'incrementPoint', id: number): void
}
// Propsオブジェクトの設定
const props = withDefaults(
defineProps<Props>(),
{note: "--"}
}
// Emit の設定
const emit = defineEmits<Emits>()
// [ポイント加算]ボタンをクリックしたときのメソッド
const pointUp = (): void => {
emit('incrementPoint', props.id)
}
</script>
<template>
<section class="box">
<h4>{{ name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ email }}</dd>
<dt>保有ポイント</dt>
<dd>{{ points }}</dd>
<dt>備考</dt>
<dd>{{ note }}</dd>
</dl>
<button v-on:click="pointUp">ポイント加算</button>
</section>
</template>
<style>
.box {
border: green 1px solid;
margin: 10px;
}
</style>
<script setup lang="ts">
// Propsインターフェースの定義
interface Props {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
// Emit インターフェースの定義
interface Emits {
(event: 'update:points', points: number): void
}
// Propsオブジェクトの設定
const props = withDefaults(
defineProps<Props>(),
{note: "--"}
}
// Emit の設定
const emit = defineEmits<Emits>()
const onInput = (event: Event): void => {
const element = event.target as HTMLInputElement
const inputPoints = Number(element.value)
emit('update:points', inputPoints)
}
</script>
<template>
<section class="box">
<h4>{{ name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ email }}</dd>
<dt>保有ポイント</dt>
<dd>
<input
type="number"
v-bind:value="points"
v-on:input="onInput" />
</dd>
<dt>備考</dt>
<dd>{{ note }}</dd>
</dl>
</section>
</template>
<style>
.box {
border: green 1px solid;
margin: 10px;
}
</style>
<script setup lang="ts">
import { ref, computed } from 'vue'
import OneMember from './components/OneMember.vue'
// 会員リストデータを用意
const memberListInit = new Map<number, Member>()
memberListInit.set(33456, {id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35, note: '初回入会特典あり',})
memberListInit.set(47783, {id: 47783, name: '鈴木二郎', email: 'mue@example.com', points: 53,})
const memberList = ref(memberListInit)
// 会員リスト内の全会員のポイントの合計算出プロパティ
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.value.values()) {
total += member.points
}
return total
})
// Emit により実行されるメソッド
const onIncrementPoint = (id: number): void => { // 1.
// 処理関数の id に該当する会員情報オブジェクトを取得
const member = memberList.value.get(id)
// 会員情報オブジェクトが存在するならポイントインクリメント // 2.
if (member != undefined) {
member.points++
}
}
// 会員情報インターフェース
interface Member {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
</script>
<template>
<section>
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints }}</p>
<!-- OneMemberをv-forでループ-->
<OneMember
v-for="[id, member] in memberList"
v-bind:key="id"
v-bind:id="id"
v-bind:name="member.name"
v-bind:email="member.email"
v-bind:points="member.points"
v-bind:note="member.note"
v-on:incrementPoint="onIncrementPoint" // 3.
/>
</section>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import OneMember from './components/OneMember.vue'
// 会員リストデータを用意
const memberListInit = new Map<number, Member>()
memberListInit.set(33456, {id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35, note: '初回入会特典あり',})
memberListInit.set(47783, {id: 47783, name: '鈴木二郎', email: 'mue@example.com', points: 53,})
const memberList = ref(memberListInit)
// 会員リスト内の全会員のポイントの合計算出プロパティ
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.value.values()) {
total += member.points
}
return total
})
// 会員情報インターフェース
interface Member {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
</script>
<template>
<section>
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints }}</p>
<!-- OneMemberをv-forでループ-->
<OneMember
v-for="[id, member] in memberList"
v-bind:key="id"
v-bind:id="id"
v-bind:name="member.name"
v-bind:email="member.email"
v-model:points="member.points" // 1.
v-bind:note="member.note"
/>
</section>
</template>
v-modelの働き
これまでは子コンポーネントタグの属性として Props にデータを渡すディレクティブとして v-bind を使用していた。
これを v-model とするとそれだけで Emit による変更対象となる。
v-modelによってProps のデータを更新する際は以下の構文で記述する。
emit("update.Prop名", 値)<script setup lang="ts">
// Propsインターフェースの定義
interface Props {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
// Emit インターフェースの定義
interface Emits {
(event: 'update:points', points: number): void // (1)
}
// Propsオブジェクトの設定
const props = withDefaults(
defineProps<Props>(),
{note: "--"}
}
// Emit の設定
const emit = defineEmits<Emits>() // (2)
const onInput = (event: Event): void => { // (3)
const element = event.target as HTMLInputElement // (4)
const inputPoints = Number(element.value)
emit('update:points', inputPoints) // (5)
}
</script>
<template>
<section class="box">
<h4>{{ name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ email }}</dd>
<dt>保有ポイント</dt>
<dd>
<input type="number" v-bind:value="points" v-on:input="onInput" /> // (6)
</dd>
<dt>備考</dt>
<dd>{{ note }}</dd>
</dl>
</section>
</template>
<style>
.box {
border: green 1px solid;
margin: 10px;
}
</style><script setup lang="ts">
import { ref, computed } from 'vue'
import OneMember from './components/OneMember.vue'
// 会員リストデータを用意
const memberListInit = new Map<number, Member>()
memberListInit.set(33456, {id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35, note: '初回入会特典あり',})
memberListInit.set(47783, {id: 47783, name: '鈴木二郎', email: 'mue@example.com', points: 53,})
const memberList = ref(memberListInit)
// 会員リスト内の全会員のポイントの合計算出プロパティ
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.value.values()) {
total += member.points
}
return total
})
// 会員情報インターフェース
interface Member {
id: number
name: string
email: string
points: number
note?: string // ?はオプションパラメーター(引数省略可能)
}
</script>
<template>
<section>
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints }}</p>
<!-- OneMemberをv-forでループ-->
<OneMember
v-for="[id, member] in memberList"
v-bind:key="id"
v-bind:id="id"
v-bind:name="member.name"
v-bind:email="member.email"
v-model:points="member.points" // 1.
v-bind:note="member.note"
/>
</section>
</template>
子コンポーネントの準備
- インターフェース定義 (1)
- defineEmits実行 (2)
親コンポーネントの準備
- 子コンポーネントタグの属性としてPropsにデータを渡すディレクティブとしてv-modelにする
<oneMember
:
v-model:points="member.points"
:
/>動作
- 親から points という名前の Propsで値が渡る
- 子コンポーネントの(6)で数値入力コントロールで内容が変更
- inputイベントが発生
- (3)のonInputメソッドが実行される
- (4)で変更された値を取得
- (5)でemit()実行
- 第1引数は、親コンポーネントで v-model の対象を points にしているので update:points となる
この「update:プロパティ名」という命名ルールを守ることで親子のデータ同期が出来る - 第2引数は取得した入力値
- 第1引数は、親コンポーネントで v-model の対象を points にしているので update:points となる
- 以上により子コンポーネントに入力された値が親コンポーネントにイベントメソッドが記述されていなくても反映される
- リアクティブシステムにより合計算出プロパティである totalPoints にも変更が反映される
6.6 Provide と Inject
6.6.1 コンポーネント間通信のまとめと問題提起

親コンポーネントから子コンポーネントへデータを渡すには Props を利用する。
子コンポーネントから親コンポーネントへデータを渡すには Emit によるイベント処理を利用する。
これらの仕組みは二つ合わせて 「Props ダウン、イベントアップ」と呼ばれる。
しかしコンポーネント関係が複雑になると問題が発生する

Provide と Inject
Provide と Inject を利用すると、Props と Enit を複雑に組み合わせる必要がなくなる。
概念としては「App.vue にてアプリケーション全体で参照されるデータを提供(Provide)しておくと、配下のコンポーネントではどれだけ階層が深くなってもデータの注入(Inject)という方法が利用できる」という仕組みである。

6.6.2 サンプルプログラムの作成
※実はこのサンプルプログラム、あまり良くないことがわかりました。
最後に作り直したものを掲載しました。(2026.04.10)
会員リスト ← App.vue
会員の保有ポイントの合計: 88 ← BaseSection.vue
田中太郎さんの情報 ← OneSection.vue
ID
33456
メールアドレス
bow@example.com
保有ポイント
[35 ]
備考
初回入会特典あり。
鈴木二郎さんの情報 ← OneSection.vue
ID
47783
メールアドレス
bow@example.com
保有ポイント
[53 ]
備考
–
export interface Member {
id: number
name: string
email: string
points: number
note?: string
}<script setup lang="ts">
import { reactive, provide } from 'vue'
import BaseSection from './components/BaseSection.vue'
import type { Member } from './interfaces'
// 会員リストの用意 // 1.
const memberList = new Map<number, Member>()
memberList.set(33456, {
id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35, note: '初回入会特典あり',
})
memberList.set(47783, {
id: 47783, name: '鈴木二郎', email: 'mue@example.com', points: 53,
})
// 会員リストをProvide
provide('memberList', reactive(memberList)) // 2.
</script>
<template>
<BaseSection /> // 3.
</template><script setup lang="ts">
import { computed, inject } from 'vue'
import OneMember from './OneMember.vue'
import type { Member } from '../interfaces'
// 会員情報リストをInject
const memberList = inject('memberList') as Map<number, Member> // 1.
// 保有ポイントの合計の算出プロパティ
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.values()) { // 2.
total += member.points
}
return total
})
</script>
<template>
<section>
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints }}</p>
<OneMember v-for="id in memberList.keys()" <!-- 3. -->
v-bind:key="id"
v-bind:id="id" /> <!-- 4. -->
</section>
</template>
<style scoped>
section {
border: orange 1px dashed;
margin: 10px;
}
</style><script setup lang="ts">
import { computed, inject } from 'vue'
import type { Member } from '../interfaces' // 1.
// Propsインターフェースの提議
interface Props {
id: number
}
// Props オブジェクトの設定
const props = defineProps<Props>()
// 会員情報リストをInject
const memberList = inject('memberList') as Map<number, Member> // 2.
// 該当する会員情報の取得
const member = computed((): Member => { // 4.
return memberList.get(props.id) as Member // 3.
})
// Propsであるnoteを加工する算出プロパティ
const localNote = computed((): string => {
let localNote = member.value.note // 5.
if (localNote == undefined) {
localNote = '--'
}
return localNote
})
</script>
<template>
<section class="box">
<h4>{{ member.name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ member.email }}</dd>
<dt>保有ポイント</dt>
<dd>
<input type="number" v-model.number="member.points" /> <!-- 6. -->
</dd>
<dt>備考</dt>
<dd>{{ localNote }}</dd>
</dl>
</section>
</template>
<style>
.box {
border: green 1px solid;
margin: 10px;
}
</style>6.6.3 Provide の利用方法
プロジェクトでのデータの関係

App コンポーネントのスクリプトブロックでは会員情報リストを生成しそれを Provide している。
以降の BasicSection コンポーネントと OneMember コンポーネントでは、ここで Provide された会員情報リストを Inject によって利用している。
App.vue
- 会員情報リストとして Map オブジェクトの memberList を生成。
- memberList を provide() 関数に渡すことで Provide が行われ他のコンポーネントで利用可能になる。
- BaseSection コンポーネントを読み込んで表示
Provide() 関数の構文
provide("Provide名", 値)Provide名として、変数名と同じ memberList を指定している。
これによって他のコンポーネントでは memberList という名称で Inject して利用できる。
値としては、単純に memberList をわたすのではなく reactive() 関数を利用していて、これにより全コンポーネントにおいてリアクティブ変数として利用できるようになる。
通常 Provide を行う provide() 関数は reactive() 関数とセットで利用する。
リアクティブ変数を用意する関数にはもうひとつ ref() があるが、Provide されたデータをリアクティブする場合 ref() では使いずらい面がある。
ref() を利用したリアクティブ変数のデータにアクセスするには .value を利用する。
しかし、同じコンポーネント内ならば特に問題ないが Inject したデータに対して .value でアクセスしようとすると TypeScript では型エラーになる。
エラーを避けるため reactive() を利用する。
6.6.4 Inject の利用方法
BaseSection.vue
- Inject は inject() 関数を利用するだけで実現できる。
引数には Provide 名を渡す。
inject() の戻り値はそのままでは unknown 型となるため as キーワードによる型アサーション(型変換)を行ってもとのデータ型に復元しておく。 - Injectした membaerListのループ記述は、これまでは memberList.value.values() となっていたが今回のコードでは membaerList.values() となる。これは Provide の際 ref() ではなく reactive() を使用しているため。
- memberList をこのままテンプレート変数として使用し v-for でループしながら OneMember コンポーネントを表示している。
6.6.5 孫コンポーネントでも Inject は同様
これまでは OneMember コンポーネントをループする際、必要なデータを全て Props で渡していた。このサンプルでは OneMmember コンポーネント内でもデータを Inject して利用できるので、id のみを渡している。
v-for="[id, member] in memberList"v-for="id in memberList.keys() v-bind:key="id" v-bind:id="id" />OneMember.vue
- 外部モジュールで定義された型(interfaces.ts)をインポート。
- memberList を Injectして Mapオブジェクト memberListを取得。
- 取得したMapオブジェクト memberList に get() メソッドを利用して該当会員情報オブジェクト (Member オブジェクト)を取得。
- 取得した Member オブジェクトは OneMemberコンポーネント内ではリアクティブデータとはならないので、算出プロパティとしている。
算出プロパティとせずにmemberListからget()したMemberオブジェクトを直接テンプレートで表示するとリアクティブシステムが働かくなるので注意する。 - Member オブジェクトを算出プロパティにしたため、localNoteを算出する関数内では .value が必要となる。
- v-model を利用して points と連動。
この points もリアクティブ変数なので、この値を変更すると Provide 元の memberList も変化し、結果合計ポイント数もそれに合わせて変動するようになっている。
※Provide と Inject のサンプルプログラム作り直し
何がよくないのか
ひと言でいえば、親コンポーネントでProvideしているデータを子・孫で直接書き換えができてしまう作りになっていて、実際孫コンポーネントで書き換えてしまっているのが問題でした。
親コンポーネントの問題点 App.vue
- reactive(memberList)をそのままprovideしている
このまま子・孫コンポーネントで inject して v-model を使うと、親のmemberList(Mapの中身)を勝手に直接書き換えることが出来てしまう。
子コンポーネントの問題点 BaseSection.vue
- as Map<number, Member> による型アサーション
inject はデータが見つからない場合に undefined を返す可能性があるため、型引数をつかうか初期値を設定するのが安全。
孫コンポーネントの問題点 OneMember.vue
- v-model.number=”member.points”
injectした親データを孫が直接書き換えている。
Vueの原則「Props down, Events up」(データは下へ、変更通知は上へ)に反していて、アプリが大きくなると何処でデータが書き換わったかの追跡が非常に困難になりうる。 - computed で取得したmemberの内部プロパティを v-model で書き換えている。
computedは本来「読み取り専用」の参照を作るためのものなので、元データを変更するのは推奨されない。
どう修正するか
- injectに初期値を設定して型安全にする
InjectionKeyの設定をインターフェースファイルに書き込んで共有する - 親コンポーネントでデータを更新する関数を提供する
- 孫コンポーネントで v-mode ではなく value と input に分けて、入力時に親の更新関数を呼ぶようにする
修正したソース
Interface.ts
export interface Member {
id: number
name: string
email: string
points: number
note?: string
}import type { InjectionKey } from 'vue'
export interface Member {
id: number
name: string
email: string
points: number
note?: string
}
// 注入する情報のセット(コンテキスト)の型を定義
interface MemberContext {
memberList: Map<number, Member>
updatePoints: (id: number, points: number) => void
}
// コンテキスト用のキーを作成
export const MemberContextKey: InjectionKey<MemberContext> = Symbol('MemberContextKey')
InjectionKeyについて
- InjectionKeyは、Vue3とTypeScriptを組み合わせる際に「何を」「どんな型で」受け渡すかを明確にするための「型情報の付いた合言葉」である。
- Vue.js(特にCompositionAPI+TypeScript)において InjectionKey は型安全な provide / inject を現実するためのシンボルに型を付けるための機能であり、以下の引数を取る
InjectionKey<T> - InjectionKEy の引数・型定義
- InjectonKey<T>は TypeScript のジェネリック(型引数)として注入するデータの型(T)を受け取る
- 型引数(T): provide で提供し Inject で受け取るデータの型
- 特徴
- シンボルキャスト:InjectionKey<T> は通常 Symbol() で作成されたキーをキャストする際に使用される
- 型安全:指定した型 T が provide と inject の間で共有されるため、受け取る値の型を自動的に推論できる
- 文字列キーとの違い:文字列でキーを指定した場合 inject の戻り値は unknown になるが InjectionKey を使用することで型が確定する
- 具体的は使用手順
- import { InjectionKey } form ‘vue’
- const myKey: InjectionKey<データ型> = Symbol(‘キーの説明’) として定義
- 親で provide(myKey, データ)
- 子で inject(myKey)
これにより TypeScript は myKey に紐付いたデータ型を理解し、型推論やエラーチェックを行ってくれる
- 型定義の書き方
複数あるがスタイル(文法の好み)の問題なので、プロジェクト内でのコーディング規約に合わせて統一するのがベスト。- as を使うパターン
const key = Symbol(‘myKey’) as InjectionKey<T> - 変数に型注釈をつけるパターン
const key: InjectionKey<T> = Symbol(‘myKey’)
- as を使うパターン
- Symbol の引数 ‘myKey’ について
- Symbol(‘myKey’)の引数(説明用文字列)はデバッグ用である
- コンソールで console.log(key) をした時 Symbol(myKey) と表示されるため、複数のキーがある場合に判別しやすくなる
受け渡す情報のセット(コンテキスト)の型を定義
// 注入する情報のセット(コンテキスト)の型を定義
interface MemberContext {
memberList: Map<number, Member>
updatePoints: (id: number, points: number) => void
}会員リストデータとポイントを更新する関数の型を定義しています
コンテキスト用のキーを作成
// コンテキスト用のキーを作成
export const MemberContextKey: InjectionKey<MemberContext> = Symbol('MemberContextKey')- なぜ「文字列」ではなく「Symbol」なのか
- 名前の衝突:他のライブラリは別の場所でたまたま同じ ‘data’ という名前を使われると値が上書きされてしまう
- タイポ:渡す側が ‘memberList’ 受け取る側が ‘memberlist’ と1文字間違えるだけで動かなくなり原因追求が大変
- ※Symbol()は実行するたびに必ず一意(ユニーク)な値を作る為、名前が被ることが絶対にない
- なぜ InjectionKey 型が必要なのか
- これを行わないと inject した時の値の型が unknown (不明)になってしまい、毎回 as MemberContext と型アサーション(強制的な型定義)を書く必要が出てくる。
InjectionKey を使うことで、Vueが自動的に型を判別してくれるようになる。
- これを行わないと inject した時の値の型が unknown (不明)になってしまい、毎回 as MemberContext と型アサーション(強制的な型定義)を書く必要が出てくる。
- 具体的なメリット(エディタのサーポート)
- 自動補完:const {…} = inject(MemberContextKey)と書く際、中身に memberList や updatePoints があることをエディタが教えてくれる
- 型エラーの探知:親が間違った型のデータを provide しようとしたり、子が updatePointsの引数を間違えたりするとコンパイル前にエラーをはいてくれる
- 実装のヒント
- interfaces.ts で定義する際は MemberContext 自体も export しておくと、他の場所で型として再利用できるので便利である
- この「型付きの合言葉」を使う手法は、Vue3のプロフェッショナルな現場では標準的な書き方のなっている
App.vue
<script setup lang="ts">
import { reactive, provide } from 'vue'
import BaseSection from './components/BaseSection.vue'
// インターフェース
import type { Member } from './interfaces'
// 会員リストの用意
const memberList = new Map<number, Member>()
memberList.set(33456, {
id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35, note: '初回入会特典あり',
})
memberList.set(47783, {
id: 47783, name: '鈴木二郎', email: 'mue@example.com', points: 53,
})
// 会員リストをProvide --- 1.
provide('memberList', reactive(memberList))
</script>
<template>
<BaseSection />
</template><script setup lang="ts">
import {reactive, provide, readonly} from 'vue'
import BaseSection from './components/BaseSection.vue' // 子コンポーネント
// MemberContextKeyも取得 -- 1.
import { type Member, MemberContextKey } from './interfaces'
// 会員リストの用意 -- 2.
const memberList = reactive(new Map<number, Member>())
memberList.set(33456, {
id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35
})
memberList.set(47783, {
id: 47783, name: '鈴木二郎', email: 'mue@example.com', points: 53, note: '初回入会特典あり'
})
// ポイントを更新する専用の関数 -- 3.
// ☆親で更新用関数を定義し、これをprovideする!!!
const updatePoints = (id: number, points: number) => {
const member = memberList.get(id)
if (member) {
member.points = points
}
}
// 会員リスト、ポイント更新関数をProvide --4.
provide(MemberContextKey, {
memberList: readonly(memberList) as Map<number, Member>,
updatePoints, // ポイント更新
})
</script>
<template>
<BaseSection />
</template>旧App.vueの問題点
- reactive(memberList) をそのまま provideしている
- このまま孫コンポーネントで inject して v-model を使うと、孫が親の memberList (Mapの中身)を勝手にかつ直接書き換えることになり、アプリが大きくなった時に「なぜデータが変わっているか、どの画面(コンポーネント)が犯人かわからない」というデバッグ地獄に陥りやすい。
修正版App.vue
- Interfaceで定義したコンテキスト用のキーをインポート
- 会員用リストをreactive()にしてデータをセット
- 親側で更新用の関数を定義
- 定義した updatePoints 関数をProvideでデータとともに渡す
- リアクティブティは維持するので、親でデータが更新されれば子の合計ポイントも自動で再計算される
- Provideで更新用の関数も一緒に渡す
- memberListは直接書き換えを防ぐために readonly にする
BaseSection.vue
<script setup lang="ts">
import { computed, inject } from 'vue'
import OneMember from './OneMember.vue'
// interface
import type { Member } from '../interfaces'
// 会員情報リストをInject -- 1.
const memberList = inject('memberList') as Map<number, Member>
// 保有ポイントの合計の算出プロパティ
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.values()) {
total += member.points
}
return total
})
</script>
<template>
<section>
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints }}</p>
<OneMember v-for="id in memberList.keys()"
v-bind:key="id"
v-bind:id="id" />
</section>
</template>
<style scoped>
section {
border: orange 1px dashed;
margin: 10px;
}
</style><script setup lang="ts">
import {computed, inject} from 'vue'
import OneMember from './OneMember.vue'
// inyrtface
import { MemberContextKey } from '../interfaces'
// 会員情報リストと更新関数をInject -- 1.
const {memberList} = inject(MemberContextKey, {
memberList: new Map(),
updatePoints: () => {}
})
// 保有ポイントの合計算出プロパティ
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.values()) {
total += member.points
}
return total
})
</script>
<template>
<section class="section-box">
<h1>会員リスト</h1>
<p>全会員の保有ポイントの合計: {{ totalPoints })</p>
<OneMember v-for="id in memberList.keys()"
v-bind:key="id"
v-bind:id="id" />
</section>
</template>
<style scoped>
.section-box {
border: orange 1px dashed;
margin: 10px;
}
</style>旧BaseSection.vueの問題点
- as Map<number, Member>による型アサーション
- inject はデータが見つからない場合に undefined を返す可能性があるため、本来はinject<Map<number, Member>>(‘memberList’)と型引数を使うか、初期値を設定するのが安全である
修正版BaseSection.vue
- InjectionKeyを使った inject
- inject はデータが見つからない場合に undefined を返す可能性があるため、本来はinject<Map<number, member>>(‘memberList’) と型引数を使うか、初期値を設定するのが安全である。
- さらに型安全性を高めたい場合は、文字列キーの代わりに InjectionKey<T> を使用することで、
provide側とinject側で型を完全に同期させることができる。 - デフォルト値
inject の第2引数(デフォルト値)に書く updatePoints: () => {} は「もし親が provide を忘れていてもプログラムがエラーで止まらないようにするための空の関数」です。
membewList: new Map() // データが無い場合は空のMap
updatePoints: () => {} // データが無い場合は「何もしない関数」
もう少し丁寧に買いた場合
updatePoints: (id, oints) => {
console.warn(‘MemberContextが提供されていない為更新できません。’)
}
oneMember.vue
<script setup lang="ts">
import { computed, inject } from 'vue'
// interfase
import type { Member } from '../interfaces'
// Propsインターフェースの提議
interface Props {
id: number
}
// Props オブジェクトの設定
const props = defineProps<Props>()
// 会員情報リストをInject
const memberList = inject('memberList') as Map<number, Member>
// 該当する会員情報の取得 1.
const member = computed((): Member => {
return memberList.get(props.id) as Member
})
// Propsであるnoteを加工する算出プロパティ
const localNote = computed((): string => {
let localNote = member.value.note
if (localNote == undefined) {
localNote = '--'
}
return localNote
})
</script>
<template>
<section class="box">
<h4>{{ member.name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ member.email }}</dd>
<dt>保有ポイント</dt>
<dd> <!-- 保有ポイント入力 2. -->
<input
type="number"
v-model.number="member.points"
/>
</dd>
<dt>備考</dt>
<dd>{{ localNote }}</dd>
</dl>
</section>
</template>
<style>
.box {
border: green 1px solid;
margin: 10px;
}
</style><script setup lang="ts">
import {computed, inject} from 'vue'
// interfase
import { MemberContextKey } from '../interfaces'
// Props定義
interface Props {
id: number
}
// Props オブジェクトの設定
const props = defineProps<Props>()
// 親からコンテキストを注入
const {memberList, updatePoints} = inject(MemberContextKey, {
memberList: new Map(), // データが無い場合は空のMap
updatePoints: () => {}, // ()=>{} は、データがない場合は「なにもしない関数」
})
// 該当する会員情報の取得
const member = computed(() => memberList.get(props.id)!)
// Propsであるnoteを加工する算出プロパティ
const localNote = computed((): string => {
let localNote = member.value.note
if (localNote == undefined) {
localNote = '--'
}
return localNote
})
// 入力イベントハンドラ 1.
// ☆v-modelの代わりに「現在の値の表示」と「変更時の関数実行」を分けて記述!!
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
// 親の関数を呼んで更新を依頼する
updatePoints(props.id, Number(target.value))
}
</script>
<template>
<section class="box">
<h4>{{ member.name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ member.email }}</dd>
<dt>保有ポイント</dt>
<dd> <!-- 保有ポイント入力 2. -->
<input
type="number"
:value="member.points"
@input="onInput"
/>
</dd>
<dt>備考</dt>
<dd>{{ localNote }}</dd>
</dl>
</section>
</template>
<style scoped>
.box {
border: green 1px solid;
margin: 10px;
}
</style>旧OneMember.vueの問題点
- member を computed で取得している
- memberの内部プロパティを v-model で書き換えている。
computed は本来「読み取り専用」の参照を作るものなので、そこに v-model を繋いで元のデータを破壊的に変更するのは推奨されていない
- memberの内部プロパティを v-model で書き換えている。
- v-model.number=”member.points”
- inject した親のデータを子(孫)が勝手に直接書き換えている。
Vueの原則「Props down, Event up」(データは下へ、変更通知は上へ)にはんしており、アプリがおおきくなると「どこでデータが書き換わったのか」の追跡が非常に困難になる
- inject した親のデータを子(孫)が勝手に直接書き換えている。
修正版OneMember.vue
- v-model ではなく value と input (またはchange)イベントに分ける
- <input type=”number” v-bind:value=”member.points” v-on:input=”onInput” />
修正したソース
import type { InjectionKey } from 'vue'
export interface Member {
id: number
name: string
email: string
points: number
note?: string
}
//export const MemberListKey: InjectionKey<Map<number, Member>> = Symbol('MemberListKey')
// updatePoints関数も注入するので
// 注入する情報のセット(コンテキスト)の型を定義
interface MemberContext {
memberList: Map<number, Member>
updatePoints: (id: number, points: number) => void
}
// コンテキスト用のキーを作成
// 「何を」「どんな型で」受け渡すかを明確にするための「型情報の付いた合言葉」
// MemberContext型のInjectionKeyを作成しMemberContextKey という名前を付けている
// ・Symbol('MemberContextKey')でユニークな識別子(合言葉)を作成
// ・InjectionKey<MemberContext>という型定義によりこの合言葉でやり取りするデータはMecberContext型という
// ルールをVueとTypeScriptに教えている
// 頭のconst MemberContextKeyは変数名、Symbol引数の'MemberContextKey'はデバッグ用ラベル
// それぞれ、別の文字列でも良いが、デバッグを楽にするため同じが良い
export const MemberContextKey: InjectionKey<MemberContext> = Symbol('MemberContextKey')
<script setup lang="ts">
import { reactive, provide, readonly } from 'vue'
import BaseSection from './components/BaseSection.vue' // 子コンポーネント
//import type {Member} from './interfaces' // インターフェース
import { type Member, MemberContextKey } from './interfaces'
// 会員リストの用意
const memberList = reactive(new Map<number, Member>())
memberList.set(33456, { id: 33456, name: '田中太郎', email: 'bou@example.com', points: 35 })
memberList.set(47783, {
id: 47783,
name: '鈴木二郎',
email: 'mue@example.com',
points: 53,
note: '初回入会特典あり',
})
// ポイントを更新する専用の関数
// ☆親で更新用関数を定義し、これをprovideする!!!
const updatePoints = (id: number, points: number) => {
const member = memberList.get(id)
if (member) {
member.points = points
}
}
// データ(読み取り専用)と関数をまとめて提供(Provide)
// ☆readonlyで読み取り専用に!!!!
//provide('member-context', {
// memberList: readonly(memberList),
// updatePoints
//})
// MemberContextKeyを使ってリアクティブなデータと更新関数を一つのオブジェクトにまとめてprovideする
provide(MemberContextKey, {
memberList: readonly(memberList) as Map<number, Member>,
updatePoints, // ポイント更新
})
</script>
<template>
<BaseSection />
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import OneMember from './OneMember.vue'
//import type {Member} from '../interfaces'
import { MemberContextKey } from '../interfaces'
// 親から「読み取り専用のリスト」と
// 「更新関数」のセットを受け取る
// 型定義をしっかり行うことで誤った操作を防げる
//const {memberList} = inject('member-context') as {
// memberList: Map<number, Member>
//}
const { memberList } = inject(MemberContextKey, {
memberList: new Map(),
updatePoints: () => {},
})
// 保有ポイントの合計算出プロパティ
// 孫コンポーネントでポイントが更新されると、ここも自動で再計算される
const totalPoints = computed((): number => {
let total = 0
for (const member of memberList.values()) {
total += member.points
}
return total
})
</script>
<template>
<section class="section-box">
<h1>会員リスト</h1>
<p>
全会員の保有ポイントの合計: <strong>{{ totalPoints }}</strong>
</p>
<!-- 孫コンポーネントへは ID だけを渡す -->
<!-- 孫側で自分に必要なデータを inject して取得するためバケツリレーは発生しない -->
<OneMember v-for="id in memberList.keys()" v-bind:key="id" v-bind:id="id" />
</section>
</template>
<style scoped>
.section-box {
border: orange 1px dashed;
margin: 10px;
padding: 10px;
}
strong {
color: #d32f2f;
font-size: 1.2em;
}
</style>
<script setup lang="ts">
import { computed, inject } from 'vue'
//import type {Member} from '../interfaces'
import { MemberContextKey } from '../interfaces'
// Props定義
interface Props {
id: number
}
const props = defineProps<Props>()
// 親からコンテキストを注入
//const {memberList, updatePoints} = inject('member-context') as {
// memberList: Map<number, Member>,
// updatePoints: (id: number, points: number) => void
//}
const { memberList, updatePoints } = inject(MemberContextKey, {
memberList: new Map(), // データが無い場合は空のMap
updatePoints: () => {}, // ()=>{} は、データがない場合は「なにもしない関数」
// 丁寧な書き方
//updatePoints: (id, points) => { console.warn("MemberContextが提供されていないため、更新できません")}
})
// 該当する会員情報の取得
const member = computed(() => memberList.get(props.id)!)
// Propsであるnoteを加工する算出プロパティ
const localNote = computed((): string => {
let localNote = member.value.note
if (localNote == undefined) {
localNote = '--'
}
return localNote
})
// 入力イベントハンドラ
// ☆v-modelの代わりに「現在の値の表示」と「変更時の関数実行」を分けて記述!!
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
// 親の関数を呼んで更新を依頼する
updatePoints(props.id, Number(target.value))
}
</script>
<template>
<section class="box">
<h4>{{ member.name }}さんの情報</h4>
<dl>
<dt>ID</dt>
<dd>{{ id }}</dd>
<dt>メールアドレス</dt>
<dd>{{ member.email }}</dd>
<dt>保有ポイント</dt>
<dd>
<!-- v-model を使わずに値を流し込み、変更を検知する -->
<input type="number" :value="member.points" @input="onInput" />
</dd>
<dt>備考</dt>
<dd>{{ localNote }}</dd>
</dl>
</section>
</template>
<style scoped>
.box {
border: green 1px solid;
margin: 10px;
}
</style>

コメント