🍣

Vue3 Compsition APIを使ってハンズオン形式でTODOアプリを作成

commits45 min read

Vue3 Composition APIを使って、ハンズオン形式でTODOアプリを作成していきます。

前提として、Node.jsが必要ですので下記URLからダウンロードをしておいてください。

ダウンロード

また、すべてのコードは以下から参照できます。

https://github.com/azukiazusa1/vue3-todo-app

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

スクリーンショット 20201213 21.03.31.png

不要なコンポーネントの削除

プロジェクトを作成した際にはじめから存在するHelloWorld.vueなどのコンポーネントは使わないので削除してしまいます。

rm src/components/HelloWorld.vue 
rm src/views/Home.vue
rm src/views/About.vue

App.vueも、以下のように空っぽにします。

src/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も同様にします。

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が、今回のアプリで作成するものになります。

src/store/todo/types.ts
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

実装は次の通りです。

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に次のように追記します。

src/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を修正します。

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にアクセスしてください。次のように表示されているはずです。

スクリーンショット 20201213 23.14.29.png

ストアのTODO一覧を表示させる

ストアをインジェクトする

ここからTODOアプリっぽい見た目にしていきます。先ほど作成したストアの内容を表示させてみましょう。まずはprovideしたストアをこのコンポーネントから使用できるようにします。

src/views/todo.vue
<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を使うと、テンプレート内で配列の要素を描画することができます。

src/views/todo.vue
<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>

次のように描画されるはずです。確認してみましょう。

スクリーンショット 20201213 23.25.25.png

確かに、ストア内で定義していたTODO一覧が表示されています。

TODOを作成する

TODOを作成するためのページを作っていきます。
src/views/AddTodo.vueファイルを作成します。

touch src/views/AddTodo.vue

ルーティングに追加します。src/router/index.tsを修正します。

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に遷移先を指定します。

src/views/todo.vue
 <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作成ページの実装

実装は以下のようになりました。

src/views/AddTodo.vue
<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一覧ページへ遷移させています。

動作確認

それでは実際に試してみます。
適当にフォームに値を入力します。

スクリーンショット 20201214 23.46.07.png

作成するボタンを押してみてください。TODO一覧ページへ遷移し、今しがた作成したTODOが追加されているのが確認できるはずです。

スクリーンショット 20201214 23.47.18.png

TODO一覧を詳しく実装する

TODOを新たに作成できるところまで進めました。次に更新・削除処理の実装をしていきたおところですがその前にTODO一覧ページを改良してそこから詳細ページへの遷移、削除をできるようにしましょう。

TodoItemコンポーネントの作成

TODO一覧ページを実装するにあたって、TODOの情報を表示する構成要素をコンポーネントとして切り出して作成しましょう。Vue.jsはこのように表示する構成要素を部品として細かく分割していくのが特徴です。コンポーネントによって一つの画面を小さく分割していくことによって、各コンポーネントがひとつの表示の責務に集中できたり、再利用のしやすい構成になります。

src/componentsフォルダ内に、TodoItem.vueファイルを作成します。

touch src/components/TodoItem.vue

次のように実装しました。

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で使用できるStringNumberObjectなどで、TypeScriptで使用できる型と一致しない点に注意してください。
Objectという型情報を指定されても余り意味はないので、PropTypeを使ってジェネリクスで詳細な型情報をさらに指定することができます。
requiredtrueに指定されていると、このコンポーネントを使用する際にpropsとしてデータが渡されたなかったとき警告を発します。(コンパイルエラーにはなりません)
requiredを指定する代わりに、defaultでpropsが渡されなかったときのデフォルトの値を設定することができます。

③ 逆に子コンポーネントから親コンポーネントへデータを受け渡すのがemitです。emitは、親コンポーネントから以下のように受け取ります。

<todo-item @clickDelete="clickDelete" />

以前までは、emitは指定することなく使えましたが、Vue3からはemitする可能性があるものを明示的に列挙するようになりました。

④ setup関数の第一引数からpropsが、第二引数のcontextオブジェクトからemitが受け取れます。

⑤ 日付をフォーマットする関数としてをcomputed関数として定義しています。
computed関数は、中で利用されているリアクティブな値が更新されたときのみ、計算結果が再評価されます。

次のような見た目になりました。

スクリーンショット 20201216 0.29.26.png

これでも十分なのですが、少しリファクタリングをしましょう。

setup()メソッド内にあるformatDate関数に注目してください。機能自体は、「日付をフォーマットして返す」という一点に集中していますが、props.todo.createdAtという外部の値に依存しており扱いにくくなっています。使い回しがしにくいですし、テストもしづらくなっています。この関数をもっと汎用的に、引数としてリアクティブなDateオブジェクトを受け取りフォーマットして返すようにしましょう。

props.todo.createdAtを引数として渡せるように、computed()を更に関数で包み込みます。

src/components/TodoItem.vue
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
// 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()}`
  })
}
src/components/TodoItem.vue
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一覧を修正します。

src/views/todos.vue
<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の削除は、そのためのページを必要とせず一覧上で完結するので実はこれだけで実装は完了です。実際に削除ボタンをクリックしてみて試してみてください。

deletetodo.gif

編集ページの作成

タイトルをクリックしたときに遷移するようになりましたが、まだ編集画面を作っていないので真っ白な画面へ遷移されます。
EditTodo.vueファイルを作成しましょう。

touch src/views/EditTodo.vue

さらに、ルーティングに追加します。

src/router/index.ts
 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の実装は以下の通りです。

src/views/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のタイトルをクリックして編集画面へ遷移します。タイトルを編集してみましょう。

スクリーンショット 20201220 0.35.15.png

更新するをクリックしましょう。一覧画面へ遷移して、タイトルが更新されているのが確認できます。

スクリーンショット 20201220 0.36.04.png

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にインターフェースを定義します。

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に保存する処理として実装したいと思います。

src/clients/TodoClient/index.ts
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を作成します。

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に置き換えたとしてストアの実装は変更する必要がありません。

src/store/index.ts
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も追加しました。ストアのインターフェースも更新しておきましょう。

src/store/types.ts
 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を用いて呼び出したいところです。まず思いつくのは次のように書けるでしょう。

src/views/todos.vue
  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,
    }

しかし、これはうまくいきません。表示されるのは真っ白な画面です。

スクリーンショット 20201223 22.27.33.png

実は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

以下のように切り出しました。

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の処理はinjectuseRouterの後で呼び出す必要があるところです。(さもなくば、[Vue warn]: inject() can only be used inside setup() or functional components. というWaringが出力されます)

親コンポーネントからSuspenseでラップする

次に、先程作成した非同期コンポーネントを利用する側の親コンポーネントの処理を書いていきます。次のように、<Suspense>で非同期コンポーネントをラップします。

src/views/todos.vue
<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>

これで、表示ができるようになるはずです。

スクリーンショット 20201223 22.45.41.png

ローディング時の処理を追加する

Suspenseの役割はこれだけではありません。実際の非同期処理では解決するまで時間がかかりその間はローディング中であることを表す描画をするはずです。<Suspense>コンポーネントのfallbackスロットを利用すれば、そのような実装を簡単に処理することができます。

src/views/todos.vue
<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秒送らせて取得させてみましょう。

src/todoClient/index.ts
 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...と描画されます。

suspensevue.gif

エラーを処理する

非同期処理といえば、ローディングと並んでエラーハンドリングもつきものです。これはonErrorCapturedライフサイクルフックで完結に処理することができます。
onErrorCaputerdは、子コンポーネントでエラーが発生した際にそれを捕捉します。

src/views/todos.vue
<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()でエラー発生させてみましょう。

suspenseerorr.gif

レポジトリをモックに置き換える

最後に、レポジトリの実装をモックに置き換えた状態で起動させてみたいと思います。実際の現場でもバックエンドとフロントエンドで別れて開発を行っておりバックエンドの実装が完了するまではモックモードで動かしたいという欲求があるでしょう。

まずは、モックモードで起動するコマンドをpackage.jsonに追加します。

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

実装は以下の通りです。

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
  }
}

ファクトリーを修正する

ファクトリーの処理を修正して、実行環境によってインポートするレポジトリを変更します。

src/clients/RepositoryFactory.ts
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

モックレポジトリが使われていることがわかります。

スクリーンショット 20201224 0.11.50.png

レポジトリパターンを採用したおかげで、このように簡単に置き換えることが可能でした。

以上で終了となります。お疲れ様でした!

GitHubで編集を提案

Discussion

ログインするとコメントできます