Vue3の基本を再確認: コンポーネント間のやり取り、引数名、よくある疑問など

2023-10-02
2023-10-02

最近ブログをNuxt3とNuxt Content v2で作り替えました。その際にVue3でいろいろわからないことやなかなか覚えられないことがあったので学習および備忘録として残しておきます。

情報は可能な限り公式ドキュメントを参考にしていますが、認識ミス等があるかもです。

Nuxt Content関連の記事

リアクティブな変数とは?refとは

リアクティブ変数は単純にいうと、定義された変数を監視し、変更を検知したら再描画する変数のことです。

ユーザーの動的入力があった場合にいちいち関数を定義してDOMを操作しなくても表示が切り替わるので便利です。また値の変化に応じた細かい処理も実現しやすくなります。

ref() 関数はそんなリアクティブ変数を定義するvueの関数です。

Computedの書き方

基本的な書き方は以下です。またcomputedで定義された変数もリアクティブな変数となります。

import {ref, computed} from 'vue'
const fuga = ref(0)
const hoge = computed(()=>{
    return fuga.value * 2
})

使い所としては単一のリアクティブな変数で表現が難しい計算や条件式を使いたい場合に使用しています。

例えば、表示切り替え用のフラグなどは他のいくつかの変数の値の組み合わせて切り替えたい時があります。

そういった時にcomputedは参照している値が変わったことを検知して計算を実行してくれるので非常に便利です。

なのでリアクティブな変数とセットで使うことが多い気がします。

また、先ほどの書き方は単純に値を返すだけですがsetすることもできます。setをなぜ使いたいのかというと、後で書きますが子コンポーネントに渡ったpropsは基本的にリアクティブじゃないので変更しても親コンポーネント側に反映できません。

反映させるにはemitを使用するのですが、この時にcomputedを使うと定義された変数はリアクティブなので動的な表示とemitを利用した変更の受け渡しができるのです。

const inVal = computed({
  get: () => inputValue.value,
  set: (val) => emit('hogeVal', val)
})

ドキュメントを見るとこれはcomputedを使ったv-modelの実装方法と書かれています。v-modelを使わずにv-modelと同等のやり取りを親子間のコンポーネントで実現したい時に使います。

子コンポーネントと親コンポーネントのやり取り

vueではコンポーネントからコンポーネントを呼び出すことができます。一般的に呼び出し元を親コンポーネント、呼び出される側を子コンポーネントと呼びます。

コンポーネントはcomponentsディレクトリに*.vueのファイル名でおきます。

使用する際は親コンポーネント側でimportして使用します。(Nuxt3ではimportを書く必要はないです)

親は子コンポーネントを呼び出す際に引数(props)を与えることができます。この渡し方をいつも忘れるので書いておきます。

子コンポーネントでの引数の受け取り方

子コンポーネント側で引数を定義するにはdefineProps()を使います。

defineProps()の書き方は調べた限りinterfaceで型を定義する方法とinterfaceと同様の内容をdefinePropsの引数に渡す、引数名だけ渡す3種類があります。

<script setup lang="ts">
//intefaceを使う方法
interface Props {
    title: String
}
const props = defineProps<Props>()
//defineProps<Props>()
//intefaceを使わない方法
//defineProps<{title: string}>()
//defineProps(['title'])
//const props = defineProps(['title'])
</script>
<template>
    <div>
        {{ title }}
    </div>
    <div>
        propsから参照しても同じ {{ props.title }}
    </div>
</template>

defineProps()と書くだけでも引数に渡ってきた値をtemplateで使うことができます。ただ、一般的にconst propsに代入します。

実用上もinterfaceで型を定義してconst propsに入れた方がわかりやすくて気に入ってます。

子コンポーネントに渡す引数の中には必須じゃないものがあり、それらにはデフォルト値を定義したい場合が結構あります。

デフォルト値を定義したい場合は以下のように書きます。

<script setup lang="ts">
//propsのdefault値設定方法 1
interface Props {
    title: string
}
const props = withDefaults(defineProps<Props>(), {
    title: 'default値だよ'
})
//propsのdefault値設定方法 2
// const props = defineProps({
//     title: {
//         type: String,
//         default: 'default値だよ'
//     }
// })
</script>
<template>
    <div>
        {{ title }}
    </div>
    <div>
        propsから参照しても同じ {{ props.title }}
    </div>
</template>

上記のようにdefault値の定義の仕方も2通りあります。ドキュメントを見た感じ前者はTypeScript使用時向けで後者は普通に使うひと向けっぽいです。 前者が後者にコンパイルされるようです。

For example, defineProps<{ msg: string }> will be compiled into { msg: { type: String, required: true }}.

参考:

親コンポーネントから子コンポーネントへの変数、関数の渡し方

子コンポーネント側からpropsでもらった親コンポーネントの値はemitしないと変更できません。

親側が子コンポーネントに変数や関数を発火するための要素を渡す方法についてです。

子コンポーネントで使用する変数を渡すだけなら<ComponentName :変数名 />でいけます。静的な値の場合はコロンがなくてもいいです。

子コンポーネント側から親側の関数を呼びたい(親の持つ変数を変更したい)場合にはemitを使用します。

以下は子コンポーネント側のemitの定義方法です。コードはコンポーネントのイベントからの引用です。

<script setup lang="ts">
// 実行時の宣言
const emit = defineEmits(['change', 'update'])
// 型ベースの宣言
const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()
// 3.3+: より簡潔な代替の構文
const emit = defineEmits<{
  change: [id: number]
  update: [value: string]
}>()
</script>

真ん中の書き方が明示的で好みなのでよく使います。

以下は簡単な例です。まずは子コンポーネント側のEventTest.vueです。

<script setup lang="ts">
interface Props {
    title: string
}
const props = defineProps<Props>()
const emits = defineEmits<{
    (e: 'change-name', val: string): void
}>()
const testEmit = () => {
    emits('change-name', "変更後")
}
</script>
<template>
    <button @click="testEmit" type="button">{{ props.title }}</button>
</template>

次は呼び出し側の親コンポーネントです。

<script setup lang="ts">
import { ref } from 'vue';
import EventTest from './components/EventTest.vue'
const name = ref('test')
const test = 'button名'
const changeName = (val: string) => {
  name.value = val
}
</script>
<template>
  <main>    
    {{ name }}
    <EventTest :title="test" @change-name="changeName" />
  </main>
</template>

これを実行するとボタンを押すとnameの中身が変更後になります。

ポイントは親側でpropsの他に子側で発火する関数@change-name="changeName"を渡していることです。

ただの変数の引数は:hogeで渡して、子コンポーネントでemitで呼び出す関数は@関数名で書きます。

v-modelを使うとどうなる?

「v-modelを使うともっと簡単に親側の変数を変更できたはず・・・」といつも忘れて検索してしまいます。

v-modelを使って子コンポーネントに値を渡すとemitによる書き換え処理が楽になります。

また何にでもv-modelが使えるのではなく、v-modelは基本的にを使うシーンはユーザー入力がある場合(<input><select>などを使うとき)です。

具体例は以下です。まずは親側です。

<script setup lang="ts">
import { ref } from 'vue';
import VmodelTest from './components/VmodelTest.vue'
const name = ref('test')
</script>
<template>
  <main>
    {{ name }}
    <VmodelTest v-model="name"></VmodelTest>
  </main>
</template>

これまでの子コンポーネントと親コンポーネントの書き方と違うのはv-modelで変数を渡していること、nameを書き換えるための関数がないことです。

次に子側です。いくつかコメントをつけてます

<script setup lang="ts">
interface Props {
    //v-modelのデフォルトの変数名
    modelValue: string
}
const props = defineProps<Props>()
const emits = defineEmits<{
    //update:をつける
    (e: 'update:modelValue', val: string): void
}>()
const changeFn = (event: Event) => {
    //TypeScriptで書く場合はキャストしないとvalueにアクセスできない
    const element = event.target as HTMLInputElement
    emits('update:modelValue', element.value)
}
</script>
<template>
    <input :value="props.modelValue" @input="changeFn">
</template>

ポイントはemitsupdate:の部分です。このように書くことで、親側で変更用の関数を用意せずとも変数を変更してくれます。 また、親側でprops名を指定しない場合、子側のv-modelに対応するprops名はmodelValueです。

Vue2ではたしかできなかったのですが、Vue3ではv-modelを複数子側に渡せます。以下は親側です。

<script setup lang="ts">
import { ref } from 'vue';
import MultiVmodelTest from './components/MultiVmodelTest.vue';
const name = ref('test')
const num = ref(0)
const hoge = ref('hoge')
</script>
<template>
  <main>
    {{ name }}
    {{ num }}
    {{ hoge }}
    <MultiVmodelTest v-model="name" v-model:val2="num" v-model:val3="hoge"></MultiVmodelTest>
  </main>
</template>

v-model:<子側のprops名>とすることで複数渡せます。

子側は以下のようになります。

<script setup lang="ts">
interface Props {    
    modelValue: string //v-modelのデフォルトの変数名
    val2: number
    val3: string
}
const props = defineProps<Props>()
const emits = defineEmits<{    
    (e: 'update:modelValue', val: string): void
    (e: 'update:val2', val: number): void
    (e: 'update:val3', val: string): void
}>()
const changeFn1 = (event: Event) => {    
    const element = event.target as HTMLInputElement
    emits('update:modelValue', element.value)
}
const changeFn2 = (event: Event) => {    
    const element = event.target as HTMLInputElement
    emits('update:val2', Number(element.value))
}
const changeFn3 = (event: Event) => {    
    const element = event.target as HTMLInputElement
    emits('update:val3', element.value)
}
</script>
<template>
    <div>
        <input :value="props.modelValue" @input="changeFn1">
        <input :value="props.val2" @input="changeFn2">
        <input :value="props.val3" @input="changeFn3">
    </div>
</template>

参考:コンポーネントの v-model

slotの使い方

次にslotの使い方です。こちらもよく他人のコードを見ていて<slot>というのが出てきた時にパッとどうなるのかわからなくなるのでまとめます。

まずスロット自体は親子間のコンポーネントのやり取りの手段の一つです。propsとの違いはHTMLのタグごと渡せることです。したがってpropsより柔軟な設計が可能となります。

書き方は以下のようになります。まずは親側です。

<script setup lang="ts">
import SlotTest from './components/SlotTest.vue'
</script>
<template>
  <main>
    <SlotTest>
      <span>Hello</span>
    </SlotTest>
  </main>
</template>

親側では子コンポーネントタグの間に挿入したいHTML要素を書きます。

続いて子側です。

<template>
    <div>
        <slot></slot>
    </div>
</template>

単純なパターンだとこれだけです。

子側で定義した<slot></slot>に親側で書いた子側のコンポーネントタグで挟んだ要素が挿入されます。

参考:スロット

名前付きスロット

あとよくわからなくなるものとして、名前付きスロットというのもあります。用途としては子側で複数箇所に挿入したい場合に使います。

書き方は以下のようになります。まずは親側です。

<script setup lang="ts">
import NamedSlots from './components/NamedSlots.vue'
</script>
<template>
  <main>
    <NamedSlots>
      <template v-slot:hello>
        <p>Hello!</p>
      </template>
      <!-- 簡易的にv-slot:hogeを#hogeともかける -->
      <template #hoge>
        <p>Hoge!</p>
      </template>
      <template #fuga>
        <p>Fuga!</p>
      </template>
    </NamedSlots>
  </main>
</template>

子コンポーネントの間いに複数の<template>があります。そこにv-slotで子側で定義した名前を指定します。簡易表記で#名前とすることもできます。

次は子側です。

<template>
    <div>
        <slot name="hello"></slot>
    </div>
    <div>
        <slot name="hoge"></slot>
    </div>
    <div>
        <slot name="fuga"></slot>
    </div>
</template>

子側のslotにname属性をつけて、親側の<template>でv-slotを使って参照します。そうすると子側の名前に対応する場所に親側で指定した要素が入ります。

スコープ付きスロット

さらにややこしいのがスコープ付きスロットです。これは子側の変数を親側で参照できる仕組みです。

まずは子側です。

<script setup lang="ts">
import { ref } from 'vue'
const childName = ref('子側の変数')
</script>
<template>
    <div>
        <slot :child-name="childName"></slot>
    </div>
</template>

変数を定義して、<slot>v-bindの形式で渡してあげます。

続いて親側です。

<script setup lang="ts">
import ScopedSlot from './components/ScopedSlot.vue'
</script>
<template>
  <main>
    <ScopedSlot v-slot="slotProps">
      {{ slotProps.childName }}
    </ScopedSlot>
  </main>
</template>

これまでとの違いは子コンポーネント自体にv-slot=があることです(厳密にはv-slot:default)。

ここではslotPropsとしていますが、この値はなんでも良いです。コードにあるようにこのslotProps経由で子側の変数にアクセスできます。

JavaScriptの分割代入を使うと子側で定義した変数がそのまま扱えるので便利です。これを使うと先ほどの親側のコードは以下のようになります。

<script setup lang="ts">
import ScopedSlot from './components/ScopedSlot.vue'
</script>
<template>
  <main>
    <ScopedSlot v-slot="{ childName }">
      {{ childName }}
    </ScopedSlot>
  </main>
</template>

さらに名前付きスロットでスロットPropsを使うこともできます。

子側は以下です。

<script setup lang="ts">
import { ref } from 'vue'
const hello = ref('hello!')
const hoge = ref('hoge!')
const fuga = ref('fuga!')
</script>
<template>
    <div>
        <slot name="hello" :hello="hello"></slot>
    </div>
    <div>
        <slot name="hoge" :hoge="hoge"></slot>
    </div>
    <div>
        <slot name="fuga" :fuga="fuga"></slot>
    </div>
</template>

親側です。

<script setup lang="ts">
import NamedScopedSlot from './components/NamedScopedSlots.vue'
</script>
<template>
  <main>
    <NamedScopedSlot>
      <template #hello="{ hello }">
        {{ hello }}
      </template>
      <template #hoge="{ hoge }">
        {{ hoge }}
      </template>
      <template #fuga="{ fuga }">
        {{ fuga }}
      </template>
    </NamedScopedSlot>
  </main>
</template>

こんな感じで多少ややこしくはなりますが、スロットを使うとかなり柔軟に色々できます。

コンポーネントの命名規則

いろんなコードを見てるとコンポーネント名やpropsの書き方が微妙に違うことがあって気になったので調べました。

まず以下の用語を押さえます。

  • PascalCase
    • 各単語の最初の文字を大文字にし、スペースやアンダースコアを使わずに単語を連結する
    • 例:MyFirstClass, CarModel, AnimalType
  • camelCase
    • PascalCaseと似ているが、最初の単語の最初の文字は小文字になる
    • 例:myFirstVariable, carModel, animalType
  • kebab-case
    • kebab-caseは、単語をハイフン(-)で区切る
    • 例:my-first-class, car-model, animal-type

Vueのコンポーネント名は基本的にPascalCaseが推奨されています。ただkebab-caseでも問題なく使えるのでそういうパターンも見かけるようです。

参考:コンポーネント名での大文字・小文字の使い方

次にpropsですが、子コンポーネントでpropsを定義する際はcamelCaseを使います。ただ、親コンポーネントで子コンポーネントを使用する際にpropsを渡すときはkebab-caseの利用が推奨されています。

以下は一例です。

<script setup lang="ts">
import { ref } from 'vue'
import NameCase from './components/NameCase.vue'
const titleName = ref('hoge')
</script>
<template>
  <main>
    <!-- コンポーネント名はPascalCase -->
    <!-- 子コンポーネントにpropsを渡す時はkebab-case -->
    <NameCase :title-name="titleName">
    </NameCase>
  </main>
</template>

参考:props 名の大文字・小文字の使い分け

NuxtLinkとaタグどっちを使うべきか

結論Nuxtを使っているならNuxtLinkを使うでOK。

公式のNuxtLinkによると<NuxtLink><a>タグを置き換え可能で、外部リンクか内部リンクを賢く判断して使い分けられると書かれています。

特に内部リンクに関しては<NuxtLink>の方がいいらしいです

$を使った書き方について

vue自身が持っている組み込みの変数?関数っぽいです。Vue2のドキュメントだとComponent Instanceと呼ばれてますが、Vue3のドキュメントが見当たりません。

一方で、Vue3の他のドキュメントで普通に使われてたりするので混乱しました。

参考:Component Instance

CSSの動的変更方法

ある値によって適用するCSSのクラスを変えたい時がよくあります。Vueで動的に変えられることは覚えているのですが、書き方を忘れるのでメモです。

基本は<div :class="{ active: isActive }">のようにclassにバインドして{cssクラス名:フラグ変数}のように書きます。

よくあるパターンがcssのクラスが2つありフラグがtrueとfalseでクラスを使い分けるパターンです。

この場合は以下のようにオブジェクトにまとめるとわかりやすいです。

<script setup lang="ts">
import { ref, computed } from 'vue';
const isActive = ref(true)
const error = ref(false)
const classObject = computed(() => ({
    active: isActive.value && !error.value,
    'text-danger': error.value === true
}))
const changeClass = () => {
    isActive.value = !isActive.value
}
</script>
<template>
    <div :class="classObject" @click="changeClass">クリックで背景色が変わる</div>
</template>

参考:クラスとスタイルのバインディング

まとめ

この記事ではVue3やNuxt3利用時によくわからなくなることをまとめました。

親子間のコンポーネントのデータのやり取りの方法や書き方、slotの使い方、その他細かいところで気になることについて書きました。