🔥

Nuxt3[RC版]を使ってTodoAppを作成  Vuetify3[Beta] VeeValidate4

2022/05/08に公開

なんかリリース予定が5月予定だった気がするのですが、今日みたらJuneになってました。

何かとここ最近は、Reactと比較されて
「Nuxtは所詮… 先の時代の"敗北者"じゃけェ‥!」
みたいな雰囲気になっちゃってた気がするNuxt2ですが
Nuxt3では何もせずともTypeScriptに対応するようになり、Composition APIに変わってVuexが無くなり…
開発がめちゃくちゃ楽でした。。

※「Nuxt3(Composition API)で簡単なTodo Appを作ったらだいたいこんな感じになる」といったものが紹介できれば良いと思って執筆してますので、Nuxtを1から解説などはしてないのでご了承ください。
また、APIサーバーなども簡単に作れるみたいですが今回は対応してません。
後日そちらは別の解説記事を作ろうと思ってます。

前提条件

https://zenn.dev/tempra/articles/a8405c5a5fe448
上記の記事を使って、Nuxt3とVuetifyが使える状態になっていることを想定してます。

Node.js: 16.15.0
yarn: 1.22.18
Nuxt: 3.0.0-rc.1
Vuetify: 3.0.0-beta.1

完成品

全てのソースコードを見たい方はGithubから確認してください。
Github
https://github.com/BestTempuraJP/nuxt3_todo
公開サイト
https://symphonious-frangollo-65ab0f.netlify.app/

タスクを作成する

まずは、簡単にTodoデータを配列に追加していくだけのコードを書いていきます。

app.vue
<template>
  <v-app>
    <v-container>
      <h3 class="subtitle-1 mb-5">
        Home
      </h3>
      <v-card class="pa-5">
        <v-form @submit.prevent="handleSubmit">
          <v-text-field
            v-model="title"
            label="Title"
            variant="outlined"
          />
          <v-text-field
            v-model="body"
            label="Body"
            variant="outlined"
          />
          <v-btn color="primary" type="submit">
            Submit
          </v-btn>
        </v-form>
      </v-card>
    </v-container>
  </v-app>
</template>
<script setup lang="ts">
interface Todo {
  id: number;
  title: string;
  body: string;
  isComplete: boolean;
}
let todoList = reactive<Todo[]>([])
let title = ref('')
let body = ref('')
let nextTodoId = ref(1)

const handleSubmit = () => {
  todoList.unshift({
    id: nextTodoId.value,
    title: title.value,
    body: body.value,
    isComplete: false
  })
  title.value = ''
  body.value = ''
  nextTodoId.value++
}

</script>

リアクティブにしたい値は

  • ref(プリミティブな値 string numberなど)
  • reactive(プリミティブではない値)

を使って定義する必要があります。

Todoをどこからでもアクセス・制御できる様に変更する (Composables)

先ほど作ったTodoには現状では他のコンポーネントなどで使うことができないため、グローバルに状態を管理するためにComposablesを利用します。
ついでにtypes/todo.tsも一緒に作成します。

types/todo.ts
export interface Todo {
  id: number;
  title: string;
  body: string;
  isCompleted: boolean;
}

export interface CreateFormPayload {
  title: string;
  body: string;
}
composables/todo.ts
import type { Todo, CreateFormPayload } from '~/types/todo' // 型を読み込む

export const useTodos = () => {
  const nextTodoId = useState<number>('nextTodoId', () => 1)
  const todoList = useState<Todo[]>('todoList', () => []) 
  // Todo を追加するための関数
  const createTodo = (payload: CreateFormPayload) => {
    todoList.value.unshift({
      id: nextTodoId.value,
      title: payload.title,
      body: payload.body,
      isCompleted: false
    })
    nextTodoId.value++
  }
  // ここで使いたい関数やデータを返す
  return {
    todoList: readonly(todoList),
    createTodo
  }
}

リアクティブな値にしたい場合は、useStateを利用します。

Nuxt2を使っていた方であれば、Vuexに近いものだと考えていただいて差し支えないかと思います。

つづいてView側を書いていきます。
Formは編集時にも使いまわしたいのでcomponents/Todo/Formを作成して移動させます。
また、Nuxt3ではPluginsやComponentsに加え、Composablesディレクトリ直下のファイルと、indexファイルに関しては自動でインポートされるためどこでも利用可能になります。

components/Todo/Form.vue
<template>
  <v-card class="pa-5 bg-white">
    <v-form @submit.prevent="handleSubmit">
      <v-text-field
        v-model="title"
        label="Title"
        variant="outlined"
      />
      <v-text-field
        v-model="body"
        label="Body"
        variant="outlined"
      />
      <v-btn color="primary" type="submit">
        Submit
      </v-btn>
    </v-form>
  </v-card>
</template>
<script setup lang="ts">
const title = ref('')
const  body = ref('')
const { createTodo } = useTodos()
const handleSubmit = () => {
  createTodo({
    title: title.value,
    body: body.value,
  })
  title.value = ''
  body.value = ''
}

</script>
app.vue
<template>
  <v-app>
    <v-container>
      <h3 class="subtitle-1 mb-5">
        Home
      </h3>
      <TodoForm />
    </v-container>
  </v-app>
</template>

Validation(VeeValidate, yup)

これで一通りのForm作成作業は終了致しましたが、VeeValidate4とyupを使ってバリデーションを追加します。

$ yarn add vee-validate yup
/components/Todo/Form.vue
<template>
  ~~ 変更がないため省略 ~~
</template>
<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  title: yup.string().required().max(10).label('Title'),
  body: yup.string().required().max(20).label('Body')
})
const { validate, resetForm } = useForm({ validationSchema: schema })
const { value: title, errorMessage: titleError } = useField<string>('title')
const { value: body, errorMessage: bodyError } = useField<string>('body')

const { createTodo } = useTodos()
const handleSubmit = async () => {
  const result = await validate()
  if (result) {
    createTodo({
      title: title.value,
      body: body.value,
    })
    resetForm({
      values: {
        title: '',
        body: ''
      }
    })
  }
}

</script>

VeeValidate3とはかなり書き方が変わりました。
template内でVeeValidateの記述を追加する方法がメインのようですが、Vuetifyと合わせて使う場合はscript内に書くのが良いと感じました。

Todo一覧を表示する

Todoテーブル内で以下の処理を行いたいのでtodo.tsに追加します

  • 削除処理
  • 未完了Todoの取得
  • Todoの完了・未完了のステータス変更
composables/todo.ts
import type { Todo, CreateFormPayload } from '~/types/todo'

export const useTodos = () => {
  ~ 省略 ~
  const activeTodoList = computed(() => {
    return todoList.value.filter(todo => !todo.isCompleted)
  })

  const findIndexTodo = (id: number) => {
    const index = todoList.value.findIndex(todo => todo.id === id)
    if (index === -1) {
      throw throwError('Task is not found')
    }
    return index
  }

  ~ 省略 ~ 

  const toggleStatusTodo = (todo: Todo) => {
    const targetIndex = findIndexTodo(todo.id)
    todoList.value[targetIndex].isCompleted = !todo.isCompleted
  }

  const deleteTodo = (id: number) => {
    const targetIndex = findIndexTodo(id)
    todoList.value.splice(targetIndex, 1)
  }

  return {
    todoList: readonly(todoList),
    activeTodoList: readonly(activeTodoList),
    createTodo,
    deleteTodo
    toggleStatusTodo
  }
}

特に配列を処理してるだけなので変わった書き方はないと思いますが、唯一気になったのが
throw throwError('Task is not found')という所です
throwErrorは、Nuxt3が用意してる機能なのですが名前的に勝手にthrowしてくれそうですが
composables内で使った場合にはエラーページは表示されないので注意してください。(setup内では正しく動作します)

components/Todo/Table.vue
<template>
  <div>
     <!-- 完了済みTodoの表示切り替え -->
    <v-checkbox
      v-model="show"
      :label="toggleShowText"
      class="d-inline-block"
    />
    <!-- table -->
    <v-table v-if="displayTodoList.length">
      <thead>
        <tr>
          <th class="text-left">
            ID
          </th>
          <th class="text-left">
            Title
          </th>
          <th class="text-left">
            Body
          </th>
          <th />
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="(item, index) in displayTodoList"
          :key="index"
        >
          <td>
            <v-checkbox
              v-model="item.isCompleted"
              :label="String(item.id)"
              hide-details
              @click="handleOnCheck(item)"
            />
          </td>
          <td>{{ item.title }}</td>
          <td>{{ item.body }}</td>
          <td class="text-right whitespace-nowrap">
	    <!-- Todoのステータス切り替え -->
            <v-btn
              size="small"
              :color="item.isCompleted ? 'primary': 'success'"
              class="mr-5"
              @click="handleOnCheck(item)"
            >
              <v-icon
                :icon="item.isCompleted ? 'mdi-restore' : 'mdi-check-circle-outline'"
              />
            </v-btn>
	    <!-- 編集ページのリンク -->
            <v-btn
              :to="{ name: 'edit-id', params: { id: item.id } }"
              size="small"
              color="warning"
              class="mr-5"
            >
              <v-icon>mdi-square-edit-outline</v-icon>
            </v-btn>
	    <!-- 削除機能 -->
            <v-btn
              color="error"
              size="small"
              @click="drop(item.id)"
            >
              <v-icon>mdi-delete</v-icon>
            </v-btn>
          </td>
        </tr>
      </tbody>
    </v-table>
    <v-alert v-else type="info">
      No data available.
    </v-alert>
  </div>
</template>
<script setup lang="ts">
import { Todo } from 'types/todo'
const show = ref(false)
const toggleShowText = computed(() => {
  const text = show.value ? 'Hide closed' : 'Show closed'
  return text
})

const { todoList, activeTodoList, deleteTodo, toggleStatusTodo } = useTodos()
// showがtrueの場合は全て表示。falseの場合は未完了のTodoのみ表示する
const displayTodoList = computed(() => {
  const todos = show.value ? todoList.value : activeTodoList.value
  return todos
})

const handleOnCheck = (todo: Todo) => {
  toggleStatusTodo(todo)
}

const drop = (id: number) => {
  deleteTodo(id)
}
</script>
<style lang="sass" scoped>
.whitespace-nowrap
  white-space: nowrap
</style>

先ほどtodo.tsで追加した機能と、完了タスクの表示・非表示切り替え・編集ページのリンクが見れる様になっております。

app.vueでコンポーネントを呼び出して、ある程度形になりました。

app.vue
<template>
  <v-app>
    <v-container>
      <h3 class="subtitle-1 mb-5">
        Home
      </h3>
      <TodoForm class="mb-5" />
      <TodoTable />
    </v-container>
  </v-app>
</template>

ページの追加

次に、編集ページを追加していきますが、全ての変更点を記入するとコード量が膨大になるのと、編集機能自体にNuxt3の機能が大きく絡むわけではないので
開発してて気になった部分を説明していきます。

app.vueについて

Nuxt3のアプリができたときは、このファイルがメインになって変更することになりますが、ページが増えた場合はこの様に変更します。

app.vue
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

レイアウトについて

レイアウトページは
layoutsディレクトリ配下にvueファイルを作成する必要があります。
default.vueは自動で読み込まれる仕様になっております。

default以外のレイアウトを使用したい場合

/pages/index.vue
// layouts/custom.vueを読み込みたい場合
<script setup lang="ts">
definePageMeta({
  layout: 'custom'
})
</script>

静的ページ

また、Nuxtはpagesディレクトリを自動でルーティングしてくれます。
/ => pages/index.vue
/about => pages/about.vue

動的ページ

今回の場合だと、タスクのidを渡して/edit/:idの様なページを作成したいのですが
そういった場合はpages/edit/[id].vueというファイルを作成することができます。
指定した変数名[id]は、ページ内でこのように取得できます。

/pages/edit/[id].vue
// 変数名がidの場合
<script setup lang="ts">
const route = useRoute()
route.params.id
</script>

Nuxt2ではpages/edit/_id.vueだったので違いを押さえておきましょう。
https://v3.nuxtjs.org/guide/directory-structure/pages#dynamic-routes

また、今回の様にidが数字のみでrouting時点でvalidationしたい時の書き方がNuxt2と大きく変わりました。

/pages/edit/[id].vue
<script setup lang="ts">
definePageMeta({
  middleware: [
    function (to) {
      if (typeof to.params.id !== 'string' || !/^\d+$/.test(to.params.id)) {
        return abortNavigation('Page not found')
      }
    }
  ]
})

// 上記の処理が終わってもroute.params.idが !== 'string'だと評価されてないことに注意
const route = useRoute()
route.params.id // string | string[]
</script>

動的ページのparamsは何故かは、string | string[]で帰ってるみたいですが、何故string[]があるのかよくわかりませんでした。
いつ配列の可能性があるのかわかる方がいれば教えてください。

参考

とりあえず個人的に綺麗だと思う書き方で書きましたが、Composition APIの開発経験も初なのと、公式ドキュメントも未完成の部分があるみたいなので参考程度にお願いします。
また、間違っている箇所があればコメントにて指摘いただけると幸いです。

VeeValidate4 公式
https://vee-validate.logaretm.com/v4/
Nuxt3 公式
https://v3.nuxtjs.org/
Vuetify3 公式
https://next.vuetifyjs.com/

https://zenn.dev/ytr0903/articles/d0a91f6180d34e
https://qiita.com/TakahiRoyte/items/57f64c56456e1d96c62d

Discussion