環境
vueバージョン: 3.0.0
※ Composition API の形式で記述しています。
検証ブラウザ: Google Chrome
やりたいこと
Todoアイテムを表示する親コンポーネント(Todo.vue)と、Todoアプリを追加するフォームの子コンポーネント(AddForm.vue)に分かれたTodoアプリで、
・ページの初回読み込み時
・Todoの追加、削除後
に子コンポーネントのinputに自動的にフォーカスが行くようにしたい
コンポーネントの構造
Todo.vue
削除ボタン付きのTodoアイテムの一覧表示と、Todoアイテム追加用の子コンポーネントAddForm.vueからなる。
<template>
<div class="todo">
<AddForm
@add-item="addItem"/>
<ul>
<li
v-for="item, i in itemList"
:key="i">
{{ item.name }}
<button
@click="itemList.splice(i, 1)">
delete
</button>
</li>
</ul>
</div>
</template>
<script setup>
import AddForm from './AddForm.vue'
import { ref } from 'vue'
const itemList = ref([
{ name: "aaa"},
{ name: "bbb"}
])
const addItem = (newItemName) => {
itemList.value.push({
name: newItemName
})
}
</script>
AddForm.vue
Todo.vueの子コンポーネントで、新しいTodoアイテム名を入力するinput要素と、新しいTodoを追加するためのbutton要素がセットになっている。
<template>
<div class="add-form">
<form action="">
<input v-model="newItemName" type="text">
<button @click.prevent="$emit('addItem', newItemName)">add</button>
</form>
</div>
</template>
<script setup>
import { ref, defineProps } from 'vue'
defineProps(['addItem'])
const newItemName = ref("")
</script>
試したこと1: カスタムディレクティブを使う
アプリケーションのマウント後に呼び出されるmounted()
と、画面に変更があり仮想DOMの再レンダリングがされたときに呼び出されるupdated()
のライフサイクルフックを使って、input にいい感じにフォーカス当てたかったけど...
<script setup>
(...略)
// script setup 内ではカスタムディレクティブは
// v 始まりのキャメルケースでないとだめ
const vFocus = {
mounted(el){
el.focus()
},
updated(el){ // 親コンポーネントであるTodo.vueの変更は検知できない...
el.focus()
}
}
</script>
<template>
<div class="add-form">
<form action="">
<input v-focus v-model="newItemName" type="text">
<button @click.prevent="$emit('addItem', newItemName)">add</button>
</form>
</div>
</template>
mounted()
でel.focus()
を実行することにより、ページ読み込み時にinput
にフォーカスを当てることはできた。
ただ、updated()
ライフサイクルでは、自分と子コンポーネントの変更は検知できるが、親コンポーネントの変更は検知できないため、Todoの追加や削除をした後はinput
にフォーカスが行かない。
試したこと2: テンプレート参照
Todo.vue で AddForm.vue へのテンプレート参照を定義し、Todo.vue の画面の変更をupdated()
で補足し、 AddForm.vue 側で定義したフォーカスイベントを発火させる。
<script setup>
import AddForm from './AddForm.vue'
import { ref, onMounted, onUpdated } from 'vue'
const addForm = ref(null) // AddFormコンポーネントへのテンプレート参照の追加
onMounted(() => {
addForm.value.focusInput()
})
onUpdated(() => {
addForm.value.focusInput()
})
(...略)
</script>
<template>
<div class="todo">
<!-- テンプレート参照の追加 -->
<AddForm
ref="addForm"
@add-item="addItem"/>
<ul>
<li
v-for="item, i in itemList"
:key="i">
{{ item.name }}
<button
@click="itemList.splice(i, 1)">
delete
</button>
</li>
</ul>
</div>
</template>
<script setup>
(...略)
const input = ref(null) // input へのテンプレート参照
const focusInput = () => {
input.value.focus() // Todo.vue で変更があったときに呼び出される
}
(...略)
</script>
<template>
<div class="add-form">
<form action="">
<!-- inputへのテンプレート参照を定義 -->
<input ref="input" v-model="newItemName" type="text">
<button @click.prevent="$emit('addItem', newItemName)">add</button>
</form>
</div>
</template>
すると下記エラー
Todo.vue?ebdb:8 Uncaught (in promise) TypeError: addForm.value.focusInput is not a function
AddForm.vue でfocusInput()
は定義したはずなのに、、、、
調べてみると、<script setup>
で定義したプロパティはデフォルトでは他のコンポーネントに対して公開されていないため、defineExpose()
を使ってプロパティを明示的に公開しないといけないらしい。
Components using
<script setup>
are closed by default - i.e. the public instance of the component, which is retrieved via template refs or $parent chains, will not expose any of the bindings declared inside<script setup>
.To explicitly expose properties in a<script setup>
component, use the defineExpose compiler macro:
(vue.js 3 公式ドキュメントより引用: https://v3.ja.vuejs.org/api/sfc-script-setup.html#defineexpose)
この場合、defineExpose()
を使って、親コンポーネントに対してプロパティを明示的に公開する必要がある。
import { defineExpose } from 'vue'
(...略)
defineExpose({
focusInput // 親の Todo.vue に向けて明示的に公開
})
(...略)
これで、Todo を追加・削除した後に自動的に input にフォーカスが行くようになりました!
補足: ref、テンプレート参照について
ref
(Vue 3 公式ドキュメント参考: https://v3.ja.vuejs.org/api/refs-api.html)
composition API では、リアクティブな値を取り扱うとき、ref
によって値をリアクティブにする必要がある。
ref
を用いると値はvalue
プロパティを持つオブジェクトでラップされるので、scriptタグ内で値にアクセスする際は**.value
が必要**
(ただし、テンプレート内で参照する場合自動でアンラップされるので.value
は必要ない)
<script setup>
import { ref } from 'vue'
const count = ref(1)
const increment = () => {
count.value ++ // <script>内では .value が必要
}
</script>
<template>
{{ count }} <!-- 自動でunwrapされるので.value は不要-->
<button @click="increment">increment</button>
</template>
テンプレート参照
(Vue 3 公式ドキュメント参考: https://v3.ja.vuejs.org/guide/composition-api-template-refs.html)
ref
を用いて、DOM 要素に対して直接アクセスすることもできる。
<script setup>
内でconst root = ref(null)
を定義すると、アプリケーションが初期描画された後のタイミングで、<template>
内でref="root"
とした要素が代入される。
<script setup>
import { ref, onMounted } from 'vue'
const root = ref(null)
onMounted(()=>{
// mounted() のタイミング、つまり初期描画の後に、
// root に div 要素が代入される。
console.log(root.value); // <div>This is a root element.</div>
root.value.focus() // .value でアクセスして操作などが可能
})
</script>
<template>
<div ref="root">This is a root element.</div>
</template>
Todoアプリの例のように、子コンポーネントに対してref
を定義することも可能
<script setup>
import { ref } from 'vue'
const addForm = ref(null)
</script>
<template>
<AddForm
ref="addForm"/>
</template>