Skip to main content

script setup とコンポーネントの契約 — props と emits を型で宣言する

コンポーネントを分けると、その間に境界ができます。親から何を受け取り、親へ何を伝えるか。この境界を型として書けるのが script setup の利点です。

宣言に使うのは definePropsdefineEmits、それに defineExpose です。どれも import せずに使えます。コンパイル時に処理されるマクロで、実行時に存在する関数ではありません。

この章で学ぶこと

  • defineProps を型で宣言し、デフォルト値を分割代入で書ける
  • 分割代入した prop を外部の関数へ渡すときの注意が分かる
  • defineEmits でイベントの名前と引数を型として宣言できる
  • 親から子の中身に触る手段と、その使いどころを判断できる
  • 親から渡した属性がどこに付くかを説明できる

マクロが動く仕組み

トップレベルで宣言したものがテンプレートから使え、return もコンポーネントの登録も要らないことは 1 章で触れました。

省略が成り立つのは、コンパイラがテンプレートとスクリプトを同時に見ているからです。テンプレートが何を参照しているか分かるので、公開する対象を自動で決められます。この章で扱う 3 つのマクロも同じ仕組みの上に乗っています。実行時に存在する関数を呼んでいるのではなく、コンパイル時にコードへ書き換えられているので、条件分岐の中に置いたり変数に代入したりはできません。

defineProps — 親から受け取る

型で宣言すると、そのまま型チェックの対象になります。

components/UserCard.vue
<script setup lang="ts">
type Props = {
name: string
age?: number
role?: 'admin' | 'member'
}

// Vue 3.5 以降は分割代入してもリアクティブ性が保たれる
const { name, age = 0, role = 'member' } = defineProps<Props>()
</script>

<template>
<p>{{ name }} ({{ age }}歳 / {{ role }})</p>
</template>

age = 0 のようにデフォルト値をそのまま書けます。role: userRole の形で名前を変えることもできます。

3.4 以前との違い

分割代入がリアクティブになったのは Vue 3.5 からです。それ以前は分割代入した時点で値のコピーになり、prop が変わっても追従しませんでした。デフォルト値も withDefaults で別に渡す必要がありました。

// 3.4 以前の書き方
const props = withDefaults(defineProps<Props>(), {
age: 0,
role: 'member',
})

既存のコードでこの形を見たら、3.5 以降なら分割代入に寄せられます。ただし props オブジェクト全体を別の関数に渡している箇所があるなら、そのままでも動きます。

分割代入した prop を外へ渡すとき

ここが注意点です。分割代入した変数は、テンプレートやコンポーネント内の式では追従しますが、関数の引数として渡すと値のコピーになります

watch() に直接渡した場合は、コンパイラが検出して止めてくれます。

const { userId } = defineProps<{ userId: number }>()

// コンパイルエラーになる
watch(userId, () => { /* ... */ })
[@vue/compiler-sfc] "userId" is a destructured prop and should not be passed
directly to watch(). Pass a getter () => userId instead.

getter で包めば通ります。

watch(() => userId, () => { /* ... */ })

ただし getter が返す値の型で挙動が変わります。プリミティブなら変更を拾えますが、オブジェクトを返す getter は参照が変わらないので入れ子の変更では発火しません (5 章deep の話と同じです)。オブジェクトの中身に反応したいなら deep を付けるか、必要なプロパティまで getter で降ります。

自作の関数へ渡す場合は、その関数が何を受け取るかで結果が変わります。

// 値を受け取る関数: 型としては通り、コピーが渡って追従しない
const useUserData = (id: number) => { /* ... */ }
useUserData(userId)

// getter を受け取る関数: 値を渡した時点で型が合わない
const useUserFeed = (getId: () => number) => { /* ... */ }
useUserFeed(() => userId)

コンパイラが名前で検出するのは watch()toRef() の 2 つです。それ以外は型の一致で気づくしかないので、Composable は getter を受け取る形で設計します。そうしておけば、値を渡した呼び出しが型検査で落ちます。

理由は 2 章の分割代入と同じです。関数の引数として渡るのは値そのもので、Vue は取り出された値を追えません。getter で包むと「読むタイミングを遅らせる」ことになり、呼ばれた時点の値が取れます。

props は読み取り専用

親から受け取った値を子で書き換えることはできません。ただし止まり方が経路で違います

分割代入した変数に代入すると、コンパイルエラーになります。

[@vue/compiler-sfc] Cannot assign to destructured props as they are readonly.

props オブジェクト経由で書き換えた場合は、開発ビルドでのみ警告が出ます。本番ビルドでは警告なしに素通しします。 さらにこの保護は浅いので、props.user.name = 'x' のように入れ子のプロパティを書き換えると開発ビルドでも通り、親のオブジェクトを直接変更します。

値を変えたいなら、次の defineEmits で親に依頼します。

defineEmits — 親へ通知する

イベント名と引数を型で宣言します。

components/CountButton.vue
<script setup lang="ts">
// 3.3 以降の named tuple 構文
const emit = defineEmits<{
increment: []
update: [value: number]
submit: [name: string, force: boolean]
}>()

const handleClick = () => {
emit('increment')
emit('update', 42)
emit('submit', 'name', true)
}
</script>

<template>
<button @click="handleClick">送る</button>
</template>

角括弧の中が引数の型です。引数がないイベントは空の配列にします。この宣言があると、emit('update', 'text') のような型の違う呼び出しがコンパイル時に落ちます。

親側では通常のイベントとして受け取ります。

<template>
<!-- 第 1 引数が emit で渡された値になる -->
<CountButton @update="onUpdate" @submit="onSubmit" />
</template>

4 章で触れたとおり、コンポーネントのイベントでは第 1 引数に emit の引数が入ります。DOM の要素と違ってイベントオブジェクトは渡りません。

defineExpose — 外へ公開する

script setup を使ったコンポーネントは既定で閉じています。親がテンプレート参照で子を掴んでも、子の中の変数や関数には触れません。

公開したいものだけを defineExpose に渡します。

components/VideoPlayer.vue
<script setup lang="ts">
import { ref } from 'vue'

const isPlaying = ref(false)

const play = () => {
isPlaying.value = true
}

// 内部でしか使わない関数は渡さない
const logState = () => console.log(isPlaying.value)

defineExpose({ isPlaying, play })
</script>

公開されたオブジェクトでは、ref が自動でアンラップされます。親側は .value を書かずに読めます。

// 親側
playerRef.value?.play()
console.log(playerRef.value?.isPlaying) // .value は不要

公開する範囲を絞るのは、子の内部構造への依存を減らすためです。公開したものは親から使われる前提になるので、変更するときに親も見る必要が出てきます。

テンプレート参照

DOM の要素や子コンポーネントを掴みたいときは useTemplateRef() を使います。Vue 3.5 で入った関数で、script setup ではこれが推奨です。

components/SearchForm.vue
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'

// テンプレートの ref="input" と結びつく
const inputRef = useTemplateRef('input')

onMounted(() => {
inputRef.value?.focus()
})
</script>

<template>
<input ref="input" />
</template>

戻り値は読み取り専用の shallow ref で、マウント前は null です。onMounted より前に触ると null なので、?. か存在チェックを挟みます。

型の扱い

静的に置いたコンポーネントの ref なら、3.5 以降は型が推論されます。手動でキャストが要るのは、動的コンポーネントを掴む場合です。

import Foo from './Foo.vue'
import Bar from './Bar.vue'

// どちらが入るか静的に決まらないので型引数で示す
const compRef = useTemplateRef<InstanceType<typeof Foo> | InstanceType<typeof Bar>>('comp')

ジェネリックなコンポーネントには InstanceType が使えません。vue-component-type-helpersComponentExposed を使います。

使いどころを絞る

公式ドキュメントは、子のインスタンスへの直接アクセスを控えめにし、props と emits を優先するよう求めています。参照で掴むと親が子の内部を知ることになるので、境界が曖昧になります。

focus()play() のように、DOM の命令的な操作を呼ぶ用途に限るのが安全です。状態のやり取りは props と emits に寄せます。

親から渡した属性はどこに付くか

宣言していない属性を親から渡すと、子のルート要素に付きます。1 章で扱った scoped の属性とは別の仕組みで、あちらは Vue が内部で振る識別子、こちらは親が明示的に渡した属性の行き先の話です。

<!-- 親: class と data-mark は宣言していない属性 -->
<UserCard name="田中" class="highlight" data-mark="a" />

子が単一のルート要素を返すなら、その要素に class="highlight" data-mark="a" が付きます。宣言していない属性を素通しさせる仕組みで、ラッパーコンポーネントを書くときに効きます。

子が単一の要素をルートに持たない場合は、どの要素にも付きません。 複数のルート要素だけでなく、テキストだけを返す場合や Teleport をルートに置く場合も同じです。開発ビルドの警告はこの 3 つを条件として挙げます。

components/MultiRoot.vue
<template>
<!-- 親から渡された属性はどちらにも付かない -->
<div class="first">a</div>
<div class="second">b</div>
</template>

複数のルート要素を持つコンポーネントで属性を受けたいなら、$attrs を明示的にバインドします。

<template>
<div class="first" v-bind="$attrs">a</div>
<div class="second">b</div>
</template>

複数ルート要素そのものは Vue 3 で書けるようになった形です (Vue 2 では単一のルート要素が必須でした)。ただし属性の行き先が決まらなくなるので、外側を div で包むか $attrs を明示するかを選びます。

まとめ

  • defineProps / defineEmits / defineExpose はコンパイラマクロで、import せずに使えます
  • Vue 3.5 以降は props を分割代入してもリアクティブ性が保たれます。デフォルト値もエイリアスも分割代入の中に書けます
  • 分割代入した prop を関数へ渡すときは getter で包みます。 コンパイラが名前で検出するのは watch()toRef() だけなので、Composable は getter を受け取る形で設計します
  • getter が返す型で挙動が変わります。オブジェクトを返す getter は入れ子の変更で発火しません
  • props の書き換えは経路で止まり方が違います。分割代入した変数への代入はコンパイルエラーprops 経由は開発ビルドだけの警告で、入れ子のプロパティは開発ビルドでも通ります
  • withDefaults は 3.4 以下向けの書き方です
  • defineEmits3.3 以降の named tuple 構文で書きます。引数がないイベントは空の配列にします
  • script setup のコンポーネントは既定で閉じています。 defineExpose に渡したものだけが親から見えます。公開されたオブジェクトでは ref がアンラップされます
  • テンプレート参照は useTemplateRef() で取ります。戻り値は読み取り専用の shallow ref で、マウント前は null です
  • 静的なコンポーネント ref の型は 3.5 以降推論されます。動的コンポーネントとジェネリックなコンポーネントは手動で型を与えます
  • 宣言していない属性は子のルート要素に付きます。単一の要素をルートに持たない場合 (複数ルート / テキストのみ / Teleport) はどこにも付かず警告が出るので、$attrs を明示的にバインドします
関連リファレンス

次に読む