Vue3 Compsition APIを使ってハンズオン形式でTODOアプリを作成
Vue3 Composition API を使って、ハンズオン形式で TODO アプリを作成します。
前提として、Node.js が必要ですので下記 URL からダウンロードをしておいてください。
またすべてのコードは以下から参照できます。
Vue CLI のインストール
Vue.js の開発環境の構築には、Vue CLI を使います。これは、Vue Command Line Interface の略で、対話式で開発環境のセットアップを行ってくれる公式のコマンドラインツールです。
下記コマンドからインストールします。
npm install -g @vue/cli
Vue プロジェクトの作成
インストールが完了したら、早速プロジェクトを作成します。
vue create vue3-todo-app
vue3-todo-app
の箇所には、自分に好きなプロジェクト名を入力してください。
次のような選択肢が表示されるので、Manually select features
を選択します。
? Please pick a preset:
Default ([Vue 2] babel, eslint)
Default (Vue 3 Preview) ([Vue 3] babel, eslint)
❯ Manually select features
続く選択肢は次のように回答しました。
? Check the features needed for your project:
◉ Choose Vue version
◉ Babel
◉ TypeScript
◯ Progressive Web App (PWA) Support
❯◉ Router
◯ Vuex
◯ CSS Pre-processors
◉ Linter / Formatter
◯ Unit Testing
◯ E2E Testing
? Choose a version of Vue.js that you want to start the project with
2.x
❯ 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config:
ESLint with error prevention only
ESLint + Airbnb config
❯ ESLint + Standard config
ESLint + Prettier
TSLint (deprecated)
? Pick additional lint features:
◉ Lint on save
❯◉ Lint and fix on commit
? Where do you prefer placing config for Babel, ESLint, etc.?
In dedicated config files
❯ In package.json
? Save this as a preset for future projects? No
しばらくすると、インストールが完了して下記のように表示されます。
🎉 Successfully created project vue3-todo-app.
👉 Get started with the following commands:
$ cd vue3-todo-app
$ npm run serve
表示されているコマンドを実行してみてください。localhost:8080にアクセスして、以下の画面が表示されたら OK です。
cd vue3-todo-app
npm run serve
不要なコンポーネントの削除
プロジェクトを作成した際にはじめから存在する HelloWorld.vue などのコンポーネントは使わないので削除してしまいます。
rm src/components/HelloWorld.vue
rm src/views/Home.vue
rm src/views/About.vue
App.vue も、以下のように空っぽにします。
<template>
<router-view></router-view>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'App',
})
</script>
src/router/index.ts
も同様にします。
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const routes: Array<RouteRecordRaw> = []
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
})
export default router
ストアを作成する
それではコーディングを始めていきましょう。まずは、状態管理をするためのストアを作成します。これは、現在ストレージに保存されている TODO の一覧を保存して取得する、TODO を作成、削除、更新などの処理を行うモジュールです。
src/store/todo
フォルダを作成します。
mkdir sre/store/todo
TODOの型定義を作成
まずは TODO の型定義から作成します。src/store/todo/types.ts
を作成してそこで型を管理します。
touch src/store/todo/types.ts
型を次のように作成します。この TODO が、今回のアプリで作成するものになります。
import { DeepReadonly } from 'vue'
export type Status = 'waiting' | 'working' | 'completed' | 'pending'
export interface Todo {
id: number
title: string
description: string
status: Status
createdAt: Date
updatedAt: Date
}
export interface TodoState {
todos: Todo[]
}
export type Params = Pick<Todo, 'title' | 'description' | 'status'>
export interface TodoStore {
state: DeepReadonly<TodoState>
getTodo: (id: number) => void
addTodo: (todo: Params) => void
updateTodo: (id: number, todo: Todo) => void
deleteTodo: (id: number) => void
}
TodoStore インターフェースはこの後作成するストアの型となります。抽象的なインターフェースを定義しておくことによって、ストアをコンポーネントに注入した際に実装を取り替えやすくなります。
DeepReadonly
は、Composition API の readonly
を適用した際のインターフェースです。state は直接操作させずに、メソッドを利用して変更させることを意図しています。
ストアの実装
型が決まりましたので、ストアを作り始めましょう。
src/store/todo/index.ts
ファイルを作成します。
touch src/store/todo/index.ts
実装は次のとおりです。
import { InjectionKey, reactive, readonly } from 'vue'
import { Todo, TodoState, TodoStore } from '@/store/todo/types'
// ①
const mockTodo: Todo[] = [
{
id: 1,
title: 'todo1',
description: '1つ目',
status: 'waiting',
createdAt: new Date('2020-12-01'),
updatedAt: new Date('2020-12-01'),
},
{
id: 2,
title: 'todo2',
description: '2つ目',
status: 'waiting',
createdAt: new Date('2020-12-02'),
updatedAt: new Date('2020-12-02'),
},
{
id: 3,
title: 'todo3',
description: '3つ目',
status: 'working',
createdAt: new Date('2020-12-03'),
updatedAt: new Date('2020-12-04'),
},
]
// ②
const state = reactive<TodoState>({
todos: mockTodo,
})
// ③
const intitializeTodo = (todo: Params) => {
const date = new Date()
return {
id: date.getTime(),
title: todo.title,
description: todo.description,
status: todo.status,
createdAt: date,
updatedAt: date,
} as Todo
}
// ④
const getTodo = (id: number) => {
const todo = state.todos.find((todo) => todo.id === id)
if (!todo) {
throw new Error(`cannot find todo by id:${id}`)
}
return todo
}
// ⑤
const addTodo = (todo: Params) => {
state.todos.push(intitializeTodo(todo))
}
// ⑥
const updateTodo = (id: number, todo: Todo) => {
const index = state.todos.findIndex((todo) => todo.id === id)
if (index === -1) {
throw new Error(`cannot find todo by id:${id}`)
}
state.todos[index] = todo
}
// ⑦
const deleteTodo = (id: number) => {
state.todos = state.todos.filter((todo) => todo.id !== id)
}
const todoStore: TodoStore = {
state: readonly(state),
getTodo,
addTodo,
updateTodo,
deleteTodo,
}
export default todoStore
// ⑧
export const todoKey: InjectionKey<TodoStore> = Symbol('todo')
①まだ永続化の処理を実装していないので、とりあえずモックとしてデータを用意しています。これは後ほど削除して本来の実装と取り替えます。
②reactive
は、リアクティブなデータを宣言します。宣言をした時点では、まだ readonly
にはせず、このストア内のメソッドにのみ状態の変更を許可します。
③initializeTodo は、新たに作成された TODO の初期化処理を行います。これも後ほど本来の実装と取り替えます。
④getTodo は、id を指定して一致する TODO を取得します。
⑤addTodo は、新たに TODO を作成します。
⑥updateTodo は、id を指定して一致する TODO を更新します。
⑦deleteTodo は、id を指定して一致 TODO を削除します。
⑧ストアを provide/inject するために必要なキーを宣言します。InjectionKey をジェネリクスで型指定をすると、provide/injection をした際に型検査が効くようになります。
ストアprovideする
作成したストアをグローバルに利用できるように、ルートコンポーネントに provide します。
App.vue に次のように追記します。
<template>
<router-view></router-view>
</template>
<script lang="ts">
- import { defineComponent } from 'vue'
+ import { defineComponent, provide } from 'vue'
+ import TodoStore, { todoKey } from '@/store/todo'
export default defineComponent({
name: 'App',
+ setup() {
+ provide(todoKey, TodoStore)
+ },
})
</script>
さきほど作成したストアをグローバルモジュールから、キーとストアをインポートします。setup()
関数内で、provide()
メソッドを呼び出します。このメソッドは第一引数にストアのキーを、第 2 引数に注入したいバリューを受け取ります。
provide()
したコンポーネント以下のコンポーネントにおいて、inject()
で適切なキーを用いて呼び出せば、注入したバリューが呼び出せるようになります。
TODO一覧の実装
それでは、とりあえずストアは準備ができたのでようやく画面を実装していきましょう。まずは一覧画面からです。src/views/todos.vue
を作成します。
touch src/views/todos.vue
ルーティングに追加
ルーティングに追加しましょう。src/router/index.ts
を修正します。
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
+ import Todos from '@/views/todos.vue'
const routes: Array<RouteRecordRaw> = [
+ {
+ path: '/',
+ name: 'Todos',
+ component: Todos,
+ },
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
})
export default router
ここまでうまくいっているか確認してみます。src/views/todos.vue
を以下のように編集します。
<template>TODO一覧です。</template>
localhost:8080にアクセスしてください。次のように表示されているはずです。
ストアのTODO一覧を表示させる
ストアをインジェクトする
ここから TODO アプリっぽい見た目にします。先ほど作成したストアの内容を表示させてみましょう。まずは provide したストアをこのコンポーネントから使用できるようにします。
<template>TODO一覧です。</template>
<script lang="ts">
import { defineComponent, inject } from 'vue'
import { todoKey } from '@/store/todo'
export default defineComponent({
setup() {
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
return {
todoStore,
}
},
})
</script>
provide したときと同じキーを用いて setup()
関数内で inject します。
取り出した値の型は Store(InjectionKey のジェネリクスで指定した型) | undefined ですから、型ガードを用いて確認する必要があります。(誤ったキーを渡した場合や、自身より上の階層で provide されていなかった場合に undefined が返されます)
またsetup
関数内で定義した変数や関数をテンプレート内で利用できるようにするためには、オブジェクトの形式で return する必要があります。
v-forでTODO一覧を表示
これで問題なくストアが利用できるはずです。テンプレート内からストアの値を参照してみます。v-for を使うと、テンプレート内で配列の要素を描画できます。
<template>
<h2>TODO一覧</h2>
<ul>
<li v-for="todo in todoStore.state.todos" :key="todo.id">
{{ todo.title }}
</li>
</ul>
</template>
<script lang="ts">
import { defineComponent, inject } from 'vue'
import { todoKey } from '@/store/todo'
export default defineComponent({
setup() {
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
return {
todoStore,
}
},
})
</script>
次のように描画されるはずです。確認してみましょう。
確かに、ストア内で定義していた TODO 一覧が表示されています。
TODOを作成する
TODO を作成するためのページを作っていきます。
src/views/AddTodo.vue
ファイルを作成します。
touch src/views/AddTodo.vue
ルーティングに追加します。src/router/index.ts
を修正します。
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Todos from '@/views/todos.vue'
+ import AddTodo from '@/views/AddTodo.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Todos',
component: Todos,
},
+ {
+ path: '/new',
+ name: 'AddTodo',
+ component: AddTodo,
+ },
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
})
export default router
トップページから新規作成ページへ遷移できるようにしておきましょう。
リンクに使うコンポーネントは router-link
です。to
に遷移先を指定します。
<template>
<h2>TODO一覧</h2>
<ul>
<li v-for="todo in todoStore.state.todos" :key="todo.id">
{{ todo.title }}
</li>
</ul>
+ <router-link to="/new">新規作成</router-link>
</template>
<script lang="ts">
import { defineComponent, inject } from 'vue'
import { todoKey } from '@/store/todo'
export default defineComponent({
setup() {
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
return {
todoStore,
}
},
})
</script>
TODO作成ページの実装
実装は以下のようになりました。
<template>
<h2>TODOを作成する</h2>
<form @submit.prevent="onSubmit"> // ①
<div>
<label for="title">タイトル</label>
<input type="text" id="title" v-model="data.title" /> // ②
</div>
<div>
<label for="description">説明</label>
<textarea id="description" v-model="data.description" /> // ②
</div>
<div>
<label for="status">ステータス</label>
<select id="status" v-model="data.status"> // ②
<option value="waiting">waiting</option>
<option value="working">working</option>
<option value="completed">completed</option>
<option value="pending">pending</option>
</select>
</div>
<button @click="onSubmit">作成する</button> // ①
</form>
</template>
<script lang="ts">
import { defineComponent, inject, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { Params } from '@/store/todo/types'
import { todoKey } from '@/store/todo'
export default defineComponent({
setup() {
const todoStore = inject(todoKey) // ③
if (!todoStore) {
throw new Error('todoStore is not provided')
}
const router = useRouter() // ④
const data = reactive<Params>({ // ⑤
title: '',
description: '',
status: 'waiting',
})
const onSubmit = () => { // ⑥
const { title, description, status } = data
todoStore.addTodo({
title,
description,
status,
})
router.push('/')
}
return {
data,
onSubmit,
}
},
})
</script>
① フォームがサブミットされたとき、onSubmit
イベントを呼び出します。onSubmit
イベントは、コンポーネントの setup
関数内で定義されています。
② 各入力フォームに、v-model
で入力された値とコンポーネント上のリアクティブな値をバインディングさせます。フォームへの入力は即座に反映されます。
③ TODO 一覧ページと同様に、ストアを inject します。ここでのストアの利用用途は新たに TODO を新規作成することにとどまるので、**setup 関数内で定義されているものの、return されていないことに注意してください。**コンポーネントは、テンプレートが関心のあるものだけを返すようにします。
④ router オブジェクトを利用するために、useRouter()
をインポートして呼び出しています。以前までの Vue.js では、router オブジェクトを使うために this.$router
のように呼び出していました。Composition API ではもはや this
へアクセスすることはできないので、代わりに useRouter()
関数を呼び出して使用します。
⑤ このコンポーネント内でのみ利用するリアクティブに値を定義します。これらの値はフォーム入力の値とバインディングされます。
⑥ onSubmit 関数で、フォームがサブミットされた際の処理を記述します。ここでは、ストアの addTodo()
関数を呼び出し TODO を新規作成しています。それが完了したら、router.push()
を呼び出し TODO 一覧ページへ遷移させています。
動作確認
それでは実際に試してみます。
適当にフォームに値を入力します。
作成するボタンを押してみてください。TODO 一覧ページへ遷移し、今しがた作成した TODO が追加されているのが確認できるはずです。
TODO一覧を詳しく実装する
TODO を新たに作成できるところまで進めました。次に更新・削除処理の実装をしていきたおところですがその前に TODO 一覧ページを改良してそこから詳細ページへの遷移、削除をできるようにしましょう。
TodoItemコンポーネントの作成
TODO 一覧ページを実装するにあたって、TODO の情報を表示する構成要素をコンポーネントとして切り出して作成しましょう。Vue.js はこのように表示する構成要素を部品として細かく分割していくのが特徴です。コンポーネントによって 1 つの画面を小さく分割していくことによって、各コンポーネントがひとつの表示の責務に集中できたり、再利用のしやすい構成になります。
src/components
フォルダ内に、TodoItem.vue
ファイルを作成します。
touch src/components/TodoItem.vue
次のように実装しました。
<template>
<div class="card">
<div>
<span class="title" @click="clickTitle">{{ todo.title }}</span>
<span class="status" :class="todo.status">{{ todo.status }}</span> // ①
</div>
<div class="body">作成日:{{ formatDate }}</div>
<hr />
<div class="action">
<button @click="clickDelete">削除</button>
</div>
</div>
</template>
<script lang="ts">
import { Todo } from '@/store/todo/types'
import { computed, defineComponent, PropType } from 'vue'
export default defineComponent({
props: { // ②
todo: {
type: Object as PropType<Todo>,
required: true,
},
},
emits: ['clickDelete', 'clickTitle'], // ③
setup(props, { emit }) { // ④
const clickDelete = () => {
emit('clickDelete', props.todo.id)
}
const clickTitle = () => {
emit('clickTitle', props.todo.id)
}
const formatDate = computed(() => { // ⑤
return `${props.todo.createdAt.getFullYear()}/${
props.todo.createdAt.getMonth() + 1
}/${props.todo.createdAt.getDate()}`
})
return {
clickDelete,
clickTitle,
formatDate,
}
},
})
</script>
<style scoped>
.card {
margin-bottom: 20px;
border: 1px solid;
box-shadow: 2px 2px 4px gray;
width: 250px;
}
.title {
font-weight: 400;
font-size: 25px;
padding: 5px;
}
.status {
padding: 3px;
}
.waiting {
background-color: #e53935;
}
.working {
background-color: #80cbc4;
}
.completed {
background-color: #42a5f5;
}
.pending {
background-color: #ffee58;
}
.body {
margin: 5px;
}
.action {
margin: 5px;
}
</style>
① 動的にクラスをバインディングしています。例えば、todo.status
の値が wating
だった場合には、次のように描画されます。
<div class="status wating">
② props は、親コンポーネントから子コンポーネントに渡されるデータです。あるコンポーネントから、この TodoItem コンポーネントが利用されるとき、次のように TODO オブジェクトを渡します。
<todo-item :todo="todo" />
親から渡されたデータは、読み取り専用です。子コンポーネントから親のデータを直接書き換えることはできません。
さらに、props にオプションで型なので制約を追加できます。
props の型に指定できるのは、JavaScript で使用できる String
や Number
、Object
などで、TypeScript で使用できる型と一致しない点に注意してください。
Object
という型情報を指定されても余り意味はないので、PropType
を使ってジェネリクスで詳細な型情報をさらに指定できます。
required
が true
に指定されていると、このコンポーネントを使用する際に props としてデータが渡されたなかったとき警告を発します。(コンパイルエラーにはなりません)
required
を指定する代わりに、default
で props が渡されなかったときのデフォルトの値を設定できます。
③ 逆に子コンポーネントから親コンポーネントへデータを受け渡すのが emit です。emit は、親コンポーネントから以下のように受け取ります。
<todo-item @clickDelete="clickDelete" />
以前までは、emit は指定することなく使えましたが、Vue3 からは emit する可能性があるものを明示的に列挙するようになりました。
④ setup 関数の第一引数から props が、第二引数の context オブジェクトから emit が受け取れます。
⑤ 日付をフォーマットする関数としてを computed 関数として定義しています。
computed 関数は、中で利用されているリアクティブな値が更新されたときのみ、計算結果が再評価されます。
次のような見た目になりました。
これでも十分なのですが、少しリファクタリングをしましょう。
setup()
メソッド内にある formatDate
関数に注目してください。機能自体は「日付をフォーマットして返す」という一点に集中していますが、props.todo.createdAt
という外部の値に依存しており扱いにくくなっています。使い回しがしにくいですし、テストもしづらくなっています。この関数をもっと汎用的に、引数としてリアクティブな Date
オブジェクトを受け取りフォーマットして返すようにしましょう。
props.todo.createdAt
を引数として渡せるように、computed()
をさらに関数で包み込みます。
import { computed, defineComponent, isRef, PropType, Ref, ref } from 'vue'
const useFormatDate = (date: Date | Ref<Date>) => {
const dateRef = isRef(date) ? date : ref(date) // ①
return computed(() => {
return `${dateRef.value.getFullYear()}/${ // ②
dateRef.value.getMonth() + 1
}/${dateRef.value.getDate()}`
})
}
const formatDate = useFormatDate(props.todo.createdAt) // ③
① useFormatDate
関数は、リアクティブな要素とそうでない要素どちらも受け取ります。受け取った要素がリアクティブかどうかは isRef()
関数で判断します。リアクティブな様相でなかった場合、ref()
関数でリアクティブ化してから使用します。
② 今まで使っていた reactive()
はオブジェクトをリアクティブ化するのに対し、ref()
は値をオブジェクト化します。ref()
でリアクティブ化した値にアクセスするためには refValue.value
のようにアクセスします。
③ useFormatDate
関数に引数として props.todo.createdAt
を渡したものは、今までの formatDate
関数と変わらず利用できます。
これで外部に値への依存はなくなりました。フォーマットする対象は外部から注入することになります。さて、ここまで機能を分割してあげればこのロジック単体を setup()
関数内から切り出してモジュールとして定義させてあげることができます。そうすれば、どこからでもこの関数を呼び出して使用できます。
src/composables
フォルダを作成し、その中に use-formate-date.ts
ファイルを作成しましょう。
このように切り出された関数は composables
フォルダに配置し、ファイル名は use-
を付けつのが慣習のようです。
mkdir src/composables
touch src/composables/use-formate-date.ts
さきほどの関数をそのまま移植しましょう。
// src/composables/use-formate-date.ts
import { Ref, isRef, ref, computed } from 'vue'
export const useFormatDate = (date: Date | Ref<Date>) => {
const dateRef = isRef(date) ? date : ref(date)
return computed(() => {
return `${dateRef.value.getFullYear()}/${
dateRef.value.getMonth() + 1
}/${dateRef.value.getDate()}`
})
}
import { Todo } from '@/store/todo/types'
import { defineComponent, PropType } from 'vue'
import { useFormatDate } from '@/composables/use-formate-date'
export default defineComponent({
props: {
todo: {
type: Object as PropType<Todo>,
required: true,
},
},
emits: ['clickDelete', 'clickTitle'],
setup(props, { emit }) {
const clickDelete = () => {
emit('clickDelete', props.todo.id)
}
const clickTitle = () => {
emit('clickTitle', props.todo.id)
}
const formatDate = useFormatDate(props.todo.createdAt)
return {
clickDelete,
clickTitle,
formatDate,
}
},
})
Todo一覧をTodoItemコンポーネントを利用するように修正する
それでは実装のほうに戻っていきましょう。作成した TodoItem コンポーネントを使うように Todo 一覧を修正します。
<template>
<h2>TODO一覧</h2>
<ul>
<todo-item
v-for="todo in todoStore.state.todos"
:key="todo.id"
:todo="todo" // ①
@click-title="clickTitle" // ②
@click-delete="clickDelete"
>
</todo-item>
</ul>
<router-link to="/new">新規作成</router-link>
</template>
<script lang="ts">
import { defineComponent, inject } from 'vue'
import { useRouter } from 'vue-router'
import TodoItem from '@/components/TodoItem.vue'
import { todoKey } from '@/store/todo'
export default defineComponent({
components: {
TodoItem,
},
setup() {
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
const router = useRouter()
const clickDelete = (id: number) => { // ③
todoStore.deleteTodo(id)
}
const clickTitle = (id: number) => { // ④
router.push(`/edit/${id}`)
}
return {
todoStore,
clickDelete,
clickTitle,
}
},
})
</script>
① props として、親コンポーネントから子コンポーネントにデータを渡しています。
② 子コンポーネントから emit されたイベントをハンドリングします。キャメルケース(titleClick)はケパブケース(title-click)に変換する必要があることに注意してください。
③ 子コンポーネントの emit の引数として渡された値 props.todo.item
はイベント引数として受け取ることができます。clickDelete
関数では引数として受け取った id をそのまま todoStore の deleteTodo()
メソッドに渡しています。
④ clickTitle
関数は、引数として渡された id をもとに編集ページへと遷移します。
TODO の削除は、そのためのページを必要とせず一覧上で完結するので実はこれだけで実装は完了です。実際に削除ボタンをクリックしてみて試してみてください。
編集ページの作成
タイトルをクリックしたときに遷移するようになりましたが、まだ編集画面を作っていないので真っ白な画面へ遷移されます。
EditTodo.vue
ファイルを作成しましょう。
touch src/views/EditTodo.vue
さらに、ルーティングに追加します。
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Todos from '@/views/todos.vue'
import AddTodo from '@/views/AddTodo.vue'
+ import EditTodo from '@/views/EditTodo.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Todos',
component: Todos,
},
{
path: '/new',
name: 'AddTodo',
component: AddTodo,
},
+ {
+ path: '/edit/:id',
+ name: 'EtidTodo',
+ component: EditTodo,
+ },
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
})
export default router
コロン :
を用いてルートを動的にマッチングさせることができます。例えば、/edit/1
・/edit/3
のようなルートにマッチングします。
これらの値は route
オブジェクトの params
から取得できます。
EditTodo.vue の実装は以下のとおりです。
<template>
<h2>TODOを編集する</h2>
<div v-if="error"> // ①
ID:{{ $route.params.id }}のTODOが見つかりませんでした。
</div>
<form v-else @submit.prevent="onSubmit">
<div>
<label for="title">タイトル</label>
<input type="text" id="title" v-model="data.title" />
</div>
<div>
<label for="description">説明</label>
<textarea id="description" v-model="data.description" />
</div>
<div>
<label for="status">ステータス</label>
<select id="status" v-model="data.status">
<option value="waiting">waiting</option>
<option value="working">working</option>
<option value="completed">completed</option>
<option value="pending">pending</option>
</select>
</div>
<button @click="onSubmit">更新する</button>
</form>
</template>
<script lang="ts">
import { defineComponent, inject, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Params } from '@/store/todo/types'
import { todoKey } from '@/store/todo'
export default defineComponent({
setup() {
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
const router = useRouter()
const route = useRoute() // ②
const id = Number(route.params.id) // ②
try {
const todo = todoStore.getTodo(id) // ③
const data = reactive<Params>({ // ④
title: todo.title,
description: todo.description,
status: todo.status,
})
const onSubmit = () => {
const { title, description, status } = data
todoStore.updateTodo(id, { // ⑤
...todo,
title,
description,
status,
})
router.push('/')
}
return {
error: false,
data,
onSubmit,
}
} catch (e) {
console.error(e)
return {
error: true,
}
}
},
})
</script>
① id から TODO が見つからない可能性があるので、そのような場合には更新画面ではなくその旨を伝えるようにします。
② setup()
メソッド内で $route
オブジェクトにアクセスするために、useRoute()
関数を呼び出しています。route.params.id
から更新対象の TODO の id を取得します。
③ id からストアの TODO を取得します。
④ フォームの初期値にはストアから取得した TODO の値を代入します。
⑤ フォームのサブミットときに、todoStore の updateTodo
を呼び出して更新します。
それでは実際に試してみましょう。一覧から id が 1 の TODO のタイトルをクリックして編集画面へ遷移します。タイトルを編集してみましょう。
更新するをクリックしましょう。一覧画面へ遷移して、タイトルが更新されているのが確認できます。
TODO に対して基本的な CRUD 操作を実装しましたが、ブラウザをリロードするだけで追加や更新した TODO が元に戻ってしまいます。これでは「ブラウザをリロードしないこと」という TODO を追加しなければなりません。
それでは使い勝手はわるいので、ストアを永続化して保存されるようにしましょう。
データ永続化を操作するためのモジュールを作成する
永続化処理については、ストア内に直接記述するのではなく、専用のインターフェースを作成してそこを通して操作するようにします。永続化処理を抽象化することによって将来の変更に強くなります。またコードもテストしやすいものになるでしょう。
もしあなたがレポジトリパターンを採用したことがあるのなら、理解しやすいのではないでしょうか。
src/clients/TodoClient
ディレクトリを作成して、さらにその中に index.ts
ファイルと types.ts
ファイルを作成します。
mkdir src/clients/TodoClient
toucn src/clients/TodoClient/index.ts
toucn src/clients/TodoClient/types.ts
インターフェースを作成
src/clients/TodoClient/types.ts
にインターフェースを定義します。
import { Todo, Params } from '@/store/todo/types'
export interface TodoClientInterface {
getAll(): Promise<Todo[]>
get(id: number): Promise<Todo>
create(params: Params): Promise<Todo>
update(id: number, todo: Todo): Promise<Todo>
delete(id: number): Promise<void>
}
実装の作成
インターフェースを実装したクラスを src/clients/TodoClient/index.ts
に作成します。レポジトリの実装としては本来 API を介してバックエンドに保存するのが一般的だと思われますが、今回は簡単なアプリケーションですのでlocalStorageに保存する処理として実装したいと思います。
import { Todo } from '@/store/todo/types'
import { TodoClientInterface } from './types'
export class TodoClient implements TodoClientInterface {
getAll() {
return Promise.resolve(
Object.keys(localStorage)
.filter((key) => !isNaN(Number(key)))
.map((key) => {
const todo = JSON.parse(localStorage.getItem(key) as string) as Todo
todo.createdAt = new Date(todo.createdAt)
todo.updatedAt = new Date(todo.updatedAt)
return todo
})
)
}
get(id: number) {
const item = localStorage.getItem(String(id))
if (item === null) {
return Promise.reject(new Error(`id: ${id} is not found`))
}
return Promise.resolve(JSON.parse(item) as Todo)
}
create(params: Params) {
const todo = this.intitializeTodo(params)
localStorage.setItem(String(todo.id), JSON.stringify(todo))
return Promise.resolve(todo)
}
update(id: number, todo: Todo) {
localStorage.removeItem(String(id))
todo.updatedAt = new Date()
localStorage.setItem(String(id), JSON.stringify(todo))
return Promise.resolve(todo)
}
delete(id: number) {
localStorage.removeItem(String(id))
return Promise.resolve()
}
intitializeTodo(todo: Params) {
const date = new Date()
return {
id: date.getTime(),
title: todo.title,
description: todo.description,
status: todo.status,
createdAt: date,
updatedAt: date,
} as Todo
}
}
ローカルストレージには文字列しか保存できないので、オブジェクトを保存する際には JSON.stringify
でオブジェクトの一度文字列に変換します。ローカルストレージに保存した値を取得する際には JSON.parse
で文字列をオブジェクトに変換します。
またローカルストレージ自体は非同期に実行する処理ではないですが、インターフェースを満たすために、Promise
を返すようにしています。
ファクトリーの作成
永続化処理のモジュールは、ファクトリーを介して取得します。ファクトリーを介して取得することによって、モックの処理に置き換えやすくなります。
src/clients/RepositoryFactory.ts
を作成します。
import { TodoClient } from '@/clients/TodoClient'
import { TodoClientInterface } from './TodoClient/types'
export const TODOS = 'todos'
export interface Repositories {
[TODOS]: TodoClientInterface
}
export default {
[TODOS]: new TodoClient(),
} as Repositories
ストアからレポジトリを利用する
それでは、ストアの実装にレポジトリを組み込んでいきます。
レポジトリに詳細な実装は隠蔽しているので、ストアはデータの単純な操作に集中できます。またレポジトリは抽象的な操作のみを提供しているので永続化処理を API に置き換えたとしてストアの実装は変更する必要がありません。
import { InjectionKey, reactive, readonly } from 'vue'
import { Todo, Params, TodoState, TodoStore } from '@/store/todo/types'
import Repository, { TODOS } from '@/clients/RepositoryFactory'
const TodoRepository = Repository[TODOS]
const state = reactive<TodoState>({
todos: [],
})
const fetchTodos = async () => {
state.todos = await TodoRepository.getAll()
}
const fetchTodo = async (id: number) => {
const todo = await TodoRepository.get(id)
state.todos.push(todo)
}
const getTodo = (id: number) => {
const todo = state.todos.find((todo) => todo.id === id)
if (!todo) {
throw new Error(`cannot find todo by id:${id}`)
}
return todo
}
const addTodo = async (todo: Params) => {
const result = await TodoRepository.create(todo)
state.todos.push(result)
}
const updateTodo = async (id: number, todo: Todo) => {
const result = await TodoRepository.update(id, todo)
const index = state.todos.findIndex((todo) => todo.id === id)
if (index === -1) {
throw new Error(`cannot find todo by id:${id}`)
}
state.todos[index] = result
}
const deleteTodo = (id: number) => {
TodoRepository.delete(id)
state.todos = state.todos.filter((todo) => todo.id !== id)
}
const todoStore: TodoStore = {
state: readonly(state),
fetchTodos,
fetchTodo,
getTodo,
addTodo,
updateTodo,
deleteTodo,
}
export default todoStore
export const todoKey: InjectionKey<TodoStore> = Symbol('todoKey')
レポジトリからすべての TODO を取得する fetchTodos
と、id から TODO を取得する fetchTodo
も追加しました。ストアのインターフェースも更新しておきましょう。
import { DeepReadonly } from 'vue'
export type Status = 'waiting' | 'working' | 'completed' | 'pending'
export interface Todo {
id: number
title: string
description: string
status: Status
createdAt: Date
updatedAt: Date
}
export type Params = Pick<Todo, 'title' | 'description' | 'status'>
export interface TodoState {
todos: Todo[]
}
export interface TodoStore {
state: DeepReadonly<TodoState>
+ fetchTodos: () => void
+ fetchTodo: (id: number) => void
getTodo: (id: number) => Todo
addTodo: (todo: Partial<Todo>) => void
updateTodo: (id: number, todo: Todo) => void
deleteTodo: (id: number) => void
}
TODO をレポジトリに保存して取得する処理までやりました。
早速、fetchTodos()
を TODO 一覧ページで呼び出して表示させたいと思います。fetchTodos()
は Promise を返す非同期処理ですので、async/await
を用いて呼び出したいところです。まず思いつくのは次のように書けるでしょう。
async setup() { // setup関数をasync関数に変更
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
const router = useRouter()
const clickDelete = (id: number) => {
todoStore.deleteTodo(id)
}
const clickTitle = (id: number) => {
router.push(`/edit/${id}`)
}
await todoStore.fetchTodos() // 非同期処理なのでawaitで呼び出す
return {
todoStore,
clickDelete,
clickTitle,
}
しかし、これはうまくいきません。表示されるのは真っ白な画面です。
実は setup
関数は、Promise を返すことができません。(async/await
は Promise を返す関数です)このような setup()
関数が Promise を返す非同期コンポーネントを扱うには、Suspense
と呼ばれる特別なコンポーネントを利用する必要があります。
Suspenseコンポーネントを扱う
非同期処理を行う箇所をコンポーネントに切り分ける
まずは、非同期処理を行う部分だけを切り出したコンポーネントを作成します。このアプリケーションの例では、TODO 一覧を描画する箇所が非同期処理を行っています。具体的には
<ul>
<todo-item
v-for="todo in todoStore.state.todos"
:key="todo.id"
:todo="todo"
@click-title="clickTitle"
@click-delete="clickDelete"
>
</todo-item>
</ul>
の箇所ですね。<h2>TODO一覧</h2>
のような箇所は非同期処理に関係なく表示されるのでそのままにしておきます。
src/components/AsyncTodos.vue
を作成しましょう。
touch src/components/AsyncTodos.vue
以下のように切り出しました。
<template>
<ul>
<todo-item
v-for="todo in todoStore.state.todos"
:key="todo.id"
:todo="todo"
@click-title="clickTitle"
@click-delete="clickDelete"
>
</todo-item>
</ul>
</template>
<script lang="ts">
import { defineComponent, inject } from 'vue'
import { useRouter } from 'vue-router'
import TodoItem from '@/components/TodoItem.vue'
import { todoKey } from '@/store/todo'
export default defineComponent({
components: {
TodoItem,
},
async setup() {
const todoStore = inject(todoKey)
if (!todoStore) {
throw new Error('todoStore is not provided')
}
const router = useRouter()
const clickDelete = (id: number) => {
todoStore.deleteTodo(id)
}
const clickTitle = (id: number) => {
router.push(`/edit/${id}`)
}
await todoStore.fetchTodos()
return {
todoStore,
clickDelete,
clickTitle,
}
},
})
</script>
ロジック自体には変更はありません。一点注意点として await
の処理は inject
や useRouter
の後で呼び出す必要があるところです。(さもなくば、[Vue warn]: inject() can only be used inside setup() or functional components. という Waring が出力されます)
親コンポーネントからSuspenseでラップする
次に、さきほど作成した非同期コンポーネントを利用する側の親コンポーネントの処理を書いていきます。次のように、<Suspense>
で非同期コンポーネントをラップします。
<template>
<h2>TODO一覧</h2>
<Suspense>
<AsyncTodos />
</Suspense>
<router-link to="/new">新規作成</router-link>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import AsyncTodos from '@/components/AsyncTodos.vue'
export default defineComponent({
components: {
AsyncTodos,
},
})
</script>
これで、表示ができるようになるはずです。
ローディング時の処理を追加する
Suspense
の役割はこれだけではありません。実際の非同期処理では解決するまで時間がかかりその間はローディング中であることを表す描画をするはずです。<Suspense
>コンポーネントの fallback
スロットを利用すれば、そのような実装を簡単に処理できます。
<template>
<h2>TODO一覧</h2>
<Suspense>
<template #default>
<AsyncTodos />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
<router-link to="/new">新規作成</router-link>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import AsyncTodos from '@/components/AsyncTodos.vue'
export default defineComponent({
components: {
AsyncTodos,
},
})
</script>
実際にはローカルストレージから取得する処理はすぐに終わってしますので、fallback
スロットが表示されることを確認したい場合には todoClient
の処理を少し修正してあえて 3 秒送らせて取得させてみましょう。
import { Todo } from '@/store/todo/types'
import { TodoClientInterface } from './types'
export class TodoClient implements TodoClientInterface {
- getAll()
+ async getAll() {
+ await new Promise((resolve) => setTimeout(resolve, 3000))
return Promise.resolve(
Object.keys(localStorage)
.filter((key) => !isNaN(Number(key)))
.map((key) => {
const todo = JSON.parse(localStorage.getItem(key) as string) as Todo
todo.createdAt = new Date(todo.createdAt)
todo.updatedAt = new Date(todo.updatedAt)
return todo
})
)
}
Promise が解決するまでの間、Loading..。と描画されます。
エラーを処理する
非同期処理といえば、ローディングと並んでエラーハンドリングもつきものです。これは onErrorCaptured
ライフサイクルフックで完結に処理できます。
onErrorCaputerd
は、子コンポーネントでエラーが発生した際にそれを捕捉します。
<template>
<h2>TODO一覧</h2>
<div v-if="error">
{{ error.message }}
</div>
<Suspense v-else>
<template #default>
<AsyncTodos />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
<router-link to="/new">新規作成</router-link>
</template>
<script lang="ts">
import { defineComponent, ref, onErrorCaptured } from 'vue'
import AsyncTodos from '@/components/AsyncTodos.vue'
export default defineComponent({
components: {
AsyncTodos,
},
setup() {
const error = ref<unknown>(null)
onErrorCaptured((e) => {
error.value = e
return true
})
return {
error,
}
},
})
</script>
あえて fetchTodos()
でエラー発生させてみましょう。
レポジトリをモックに置き換える
最後に、レポジトリの実装をモックに置き換えた状態で起動させてみたいと思います。実際の現場でもバックエンドとフロントエンドで別れて開発を行っておりバックエンドの実装が完了するまではモックモードで動かしたいという欲求があるでしょう。
まずは、モックモードで起動するコマンドを package.json
に追加します。
{
"name": "vue3-todo-app",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
++ "mock": "NODE_ENV=mock vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
Vue CLI は vue-cli-service serve
コマンドを呼び出したときデフォルトでは NODE_ENV
は development に設定されますが、ここでは mock に置き換えています。
すでに起動している場合にはいったん Control + c でストップさせてから、モックモードで起動するようにします。
npm run mock
モックレポジトリの作成
ローカルストレージの代わりにモックを利用するモックレポジトリを作成しましょう。ここで、通常のレポジトリと同じく TodoClientInterface
を実装することがポイントです。同じインターフェースを実装しているので、使用側は具体的な実装の違いに関わらず利用できます。
src/clients/TodoClient/mock.ts
を作成します。
touch src/clients/TodoClient/mock.ts
実装は以下のとおりです。
import { Todo, Params } from '@/store/todo/types'
import { TodoClientInterface } from './types'
const mockTodo: Todo[] = [
{
id: 1,
title: 'todo1',
description: '1つ目',
status: 'waiting',
createdAt: new Date('2020-12-01'),
updatedAt: new Date('2020-12-01'),
},
{
id: 2,
title: 'todo2',
description: '2つ目',
status: 'waiting',
createdAt: new Date('2020-12-02'),
updatedAt: new Date('2020-12-02'),
},
{
id: 3,
title: 'todo3',
description: '3つ目',
status: 'working',
createdAt: new Date('2020-12-03'),
updatedAt: new Date('2020-12-04'),
},
]
export class MockTodoClient implements TodoClientInterface {
async getAll() {
return Promise.resolve(mockTodo)
}
get(id: number) {
const todo = mockTodo.find((todo) => todo.id === id)
if (todo === undefined) {
return Promise.reject(new Error(`id: ${id} is not found`))
}
return Promise.resolve(todo)
}
create(params: Params) {
const todo = this.intitializeTodo(params)
return Promise.resolve(todo)
}
update(id: number, todo: Todo) {
todo.updatedAt = new Date()
return Promise.resolve(todo)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
delete(id: number) {
return Promise.resolve()
}
intitializeTodo(todo: Params) {
const date = new Date()
return {
id: date.getTime(),
title: todo.title,
description: todo.description,
status: todo.status,
createdAt: date,
updatedAt: date,
} as Todo
}
}
ファクトリーを修正する
ファクトリーの処理を修正して、実行環境によってインポートするレポジトリを変更します。
import { TodoClientInterface } from './TodoClient/types'
import { TodoCgiient } from '@/clients/TodoClient'
import { MockTodoClient } from '@/clients/TodoClient/mock'
export const TODOS = 'todos'
export interface Repositories {
[TODOS]: TodoClientInterface
}
export default {
[TODOS]:
process.env.NODE_ENV === 'mock' ? new MockTodoClient() : new TodoClient(),
} as Repositories
モックレポジトリが使われていることがわかります。
レポジトリパターンを採用したおかげで、このように簡単に置き換えることが可能でした。
以上で終了となります。お疲れ様でした!
Discussion