【Vue3】<script setup>の学び
・Vue3出たらしいけど何が変わったん?
・<script setup>とsetup()って何が違うん?
・provide/injectってなんぞ?
・suspenseってなんぞ?
という方々は是非ご一読ください。
本記事のテーマ
【Vue3】<script setup>を用いてTodoアプリを作ってみたので学んだことを共有
公式:SFC <script setup>
目次
・refやreactive
・propsとemit
・provide / inject
・suspense
・その他の変更点
筆者について
・未経験(花屋見習い)独学でフロントエンドエンジニアへ転職
・VueとTypeScriptのモダンフロントエンド環境でWebアプリ開発
前置きメッセージ
Vue3の<script setup>構文を用いて実際にTodoアプリを作ってみたのでそこから得た学びを共有します。
<script setup>はsetup関数のシンタックスシュガーです。
通常のsetup関数よりも簡潔に書けて可読性が向上します。
大きな変更点でいうと、returnが必要ないです。
また、propsやemitなどのオプションはdefinePropsやdefineEmitsといった新しい構文も存在します。
refやreactive
setup関数から使えた構文は同じように使用できます。
computedや、onMountedといったライフサイクルフックも同様です。
異なる点としてreturnが必要ないです。
script内で宣言するだけでtemplateに適用されます。
ただし、スコープがトップレベルのものだけです。
ライフサイクルフック、try、catchなどでブロックを作ると、適用されない。
👇 OK
<script lang="ts" setup>
import { reactive, ref } from 'vue'
const todos = ref<Todo[]>()
const todo = reactive<Todo>({
title: todo.title,
description: todo.description,
status: todo.status
})
</script>
👇 NG
<script lang="ts" setup>
import { onMounted } from 'vue'
onMouted(async () => {
const todos = await fetchTodos();
}
</script>
👇 ライフサイクルフックから初期値を取得したい時。
<script lang="ts" setup>
import { onMounted } from 'vue'
const todos = ref<Todo[]>()
onMouted(async () => {
todos.value = await fetchTodos();
}
</script>
propsとemit
変更点として大きく2点あります。
・TypeScriptの純粋なサポート
・新構文
👇 今までのprops
import { Proptype } from 'vue'
...
props: {
todos: Array as Proptype<Array<Todo>>
}
👇 defineProps
type Props = {
todo: Todo;
}
const props = defineProps<Props>();
// script内参照
props.todo
// template内参照
<div>{{ todo.name }}</div>
Propsのタイプを作ってdefinePropsにジェネリクスとして入れると自動で補完が効くようになります。
型定義で明示的に宣言して、definePropsで初期化のイメージです。
👇 プロパティをreiquired: falseにしたい場合は、オプショナルにすればOK
type Props = {
todo?: Todo;
}
👇 デフォルト値はwithDefaults
const props = withDefaults(defineProps<Props>({
todo: {
id: 1
name: '明日やること',
}
}));
👇 setup関数までのemit
<script lang="ts">
setup(props, ctx) {
const clickDelete = (id: number) => {
// setupじゃなければctxがthis
ctx.emit('click-delete', id)
}
}
</script>
....
<button @click="emit('click-delete', todo.id)">削除</button>
👇 defineEmits
type Emits = {
(e: 'click-delete', id: number): void
};
const emit = defineEmits<Emits>()
...
<button @click="emit('click-delete', todo.id)">削除</button>
templateで使用する際に型が効くようになります。
definePropsとdefineEmitsどちらもimportなしでデフォルトで使えるようになってます。
provide / inject
これはsetup関数の時からありますが、今回使用したので復習がてら書きます。provide / inject
とはなんぞやという方はこちら。
簡単に説明すると、親コンポーネントから孫コンポーネントへ子コンポーネントを経由せずにpropsを渡すことができます。
emitも同様です。
今回はtodoの状態を管理するためのファイル(store)を作り、そこからtodoの状態や状態を変更するための関数を呼び出しています。
それをApp.vueでprovideして各コンポーネントでinjectします。
storeと命名しておりますが今回はVuexは使用しておりません。
👇 provide / inject
// App.vue
import { provide } from 'vue'
import todoStore, { todoKey } from './store/todo'
provide(todoKey, todoStore)
// AsyncTodo.vue
import { inject } from 'vue'
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
<template>
<ul>
<todo-item
v-for="todo in todoStore.state.todos"
:key="todo.id"
:todo="todo"
@click-delete="todoStore.deleteTodo"
@click-title="router.push(`/edit/${todo.id}`)"
/>
</ul>
</template>
補足:todoStore
// store/todo/index.ts
const todoStore: TodoStore = {
state: readonly(state),
fetchTodos,
fetchTodo,
getTodo,
addTodo,
updateTodo,
deleteTodo
}
export default todoStore
export const todoKey: InjectionKey<TodoStore> = Symbol('todo')
InjectionKey
Vue は Symbol を拡張したジェネリック型の InjectionKey インターフェイスを提供しています。これは Provider(プロバイダ)と Consumer(コンシューマ)の間で注入された値の型を同期するために使用できます:
vue公式
InjectionKeyを使うと注入側(inject)で型検査が効くようになります。
suspense
※現段階では実験的な新機能とされているので、本番のアプリケーションでは使用しないでください。
個人的に一番気に入ってる機能です。
<script setup>構文ではトップレベルのawaitが使用できます。
<script lang="ts" setup>
const todos = await fetchTodos();
</script>
問題点
として、setupがPromiseでラップされます。
👇 こんなイメージ
async setup() {
...
return {
...
}
}
つまり宣言値がPromiseでラップされます。
そうなると、template側がそれを値として解決できず描画されません。
そんな時に、suspense
を使いましょう。
suspenseの使用
👇 子コンポーネントでトップレベルのawait
// AsyncTodo.vue
<script lang="ts" setup>
import { inject } from 'vue'
import { todoKey } from '@/store/todo'
import TodoItem from '../components/TodoItem.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
// トップレベルのawait
await todoStore.fetchTodos()
</script>
<template>
<ul>
<todo-item
v-for="todo in todoStore.state.todos"
:key="todo.id"
:todo="todo"
@click-delete="todoStore.deleteTodo"
@click-title="router.push(`/edit/${todo.id}`)"
/>
</ul>
</template>
👇 親コンポーネントでsuspense
を使用して、Promiseでラップされた値を描画
// Todos.vue
<script setup lang="ts">
import AsyncTodosVue from '@/components/AsyncTodos.vue'
</script>
<template>
<h2>Todo一覧です。</h2>
<suspense>
<template #default>
<AsyncTodosVue />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</suspense>
</template>
suspense
は二つのスロットをとります。default
とfallback
です。default
には非同期コンポーネント、fallback
にはdefaultの値が解決するまでの値
を入れましょう。suspense
は一つの要素しかラップできません。
しかし、非同期コンポーネントが直径の子要素である必要はありません。
コンポーネントをラップした場合、そのコンポーネント配下の要素にもsuspense
が適用されます。
その他変更点
componentsオプションの廃止
書き忘れ常習犯の汚名返上。
importするだけで使えるようになります。
👇 これだけでOK
<script setup lang="ts">
import AsyncTodosVue from '@/components/AsyncTodos.vue'
</script>
<template>
<h2>Todo一覧です。</h2>
<suspense>
<template #default>
<AsyncTodosVue />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</suspense>
</template>
template側でのTypeScript補完
refやreactiveはもちろん、propsやemitも。
型にないことするとしっかり怒られます。
Vue-Router / Vuex
routerやstoreはuse関数で呼び出せるようになりました。
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
const router = useRouter()
const route = useRoute()
const store = useStore()
まとめ
Vue3 Compsition APIを使ってハンズオン形式でTODOアプリを作成
こちらのサイトを参考にTodoアプリを作ってみました。
リポジトリーパターンを採用されていて、知らない概念だったので勉強になりました。
Vueの新機能をふんだんに使い、体系的にまとめられている素晴らしい記事ですので、興味がある方は是非。
時々動かないコードがありますが、少し調べれば解決する程度でした。
同じ方のこちらの記事も参考にさせていただきました。
とてもわかりやすくまとめられています。
【Vue.js 3.2】<script setup>
文がすごくすごい