Webサイト発注虎の巻ダウンロード
Webサイト発注虎の巻ダウンロード

Nuxt3のリリースが待ち切れない!ベータ版を使ってToDoリストを作ってみた

ザワ

こんにちは、エンジニアのザワです。

Go言語でAPIを作ってみたかったので、前回は手っ取り早く作れるアプリケーションとしてToDoリストを作ってみました。

執筆時点ではまだベータ版であるNuxt3をどうしても使ってみたいので、今回はToDoリスト制作を通してNuxt3の一部機能を試したいと思います。

Nuxt3のロードマップを見ると、2022年6月に安定板がリリースされる予定になっています。

今回もミニマムな要件で作りきることを目標にして、データの永続化はせず、見た目を作っていきます。少ない手間でスタイリングしていくために、UIフレームワークであるVuetifyを使います。

それではNuxt3、Vuetifyのインストールから始めます。

環境・バージョン情報

使用した環境とバージョン情報は以下の通りです。

  • Node.js(v16.14.0)
  • Nuxt(3.0.0-27465767.70f067a)
  • Vuetify(3.0.0-beta.0)

Nuxt3のインストール

ドキュメントの手順通りに進めていきます。いくつか選択肢がありますが、私はnpxでインストールしました。

npx nuxi init todo

ものの数秒でインストールが終わるので驚きです。対話式に色々聞かれることもありません。

- todo
  - .gitignore
  - app.vue
  - nuxt.config.ts
  - package.json
  - README.md
  - tsconfig.json

この時点でのファイル構成はこのようになっています。

Create Nuxt AppでNuxt2をインストールしたときとは構成が違うので真新しく感じます。不必要なファイルがなく最小構成で美しいですね。

次にパッケージをインストールします。

yarn install

といっても、package.jsonを見るとnuxt3しか書かれていません。インストールもすぐ終わると思います。

次にアプリケーションを起動します。

yarn dev

Nuxt3の初期画面

localhost:3000で起動して画面が表示されました。

Vuetifyのインストール

Vue3でVuetifyを使いたいときはVuetifyのバージョンを3にする必要がありそうです。Vuetifyのドキュメントに注意点として記載されています。執筆時点ではVuetify3はベータ版でした。

ロードマップを見ると2022年5月に3.0のリリース予定ということでこちらも楽しみです。

ということで、npmからインストールします。

yarn add vuetify@3.0.0-beta.0

Nuxt3&Vuetify3の正式な環境構築方法がわからないので、GitHubのdiscussionsで議論されている内容を参考にしました。

編集するファイルは2つ。

nuxt.config.ts

import { defineNuxtConfig } from 'nuxt3'

// https://v3.nuxtjs.org/docs/directory-structure/nuxt.config
export default defineNuxtConfig({
  css: ['vuetify/lib/styles/main.sass'],
  build: {
    transpile: ['vuetify']
  },
  vite: {
    define: {
      'process.env.DEBUG': 'false',
    }
  },
  meta: {
    link: [
      {
        rel: 'stylesheet',
        href: 'https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css'
      }
    ]
  }
})

 

/plugins/vuetify.ts

import { defineNuxtPlugin } from '#app'
import { createVuetify } from 'vuetify'

import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

export default defineNuxtPlugin((nuxtApp) => {
   const vuetify = createVuetify({
      components,
      directives
   })
   nuxtApp.vueApp.use(vuetify)
})

起動しようとするとsassがインストールされていないことを怒られるので、インストールします。

yarn add sass

あとはVuetifyのコンポーネントが使えることを確認します。

app.vue

<template>
  <div>
    <v-btn>button</v-btn>
    <v-icon>mdi-alert-circle</v-icon>
  </div>
</template>

Vuetifyのボタンとアイコンが表示され、Vuetifyを使用する準備が整いました。

コンポーネントを作成する

ヘッダーコンポーネントを作成してapp.vueで使用してみます。

components/TheHeader.vue

<template>
  ToDo
</template>

 

app.vue

<template>
  <TheHeader/>
</template>

コンポーネントの自動インポート機能はNuxt2でも可能でしたが、import文やコンポーネントを登録することなく使えるので便利です。Nuxt2では設定が必要でしたが、Nuxt3では設定を書くことなく利用できます。

componentsディレクトリのドキュメントはこちらです。

Layoutsを使ってみる

app.vue

<template>
  <div>
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>

app.vueファイル自体、Nuxt2にはなかったのでまだ馴染んでないですが、app.vueにNuxtLayout、NuxtPageを記述するとNuxt2と同じ感覚でlayouts、pagesが使えそうです。
 

layouts/custom.vue

<template>
  <v-app>
    <v-main>
      <v-container>
        <div>
          custom layout
        </div>
        <slot />
      </v-container>
    </v-main>
  </v-app>
</template>

 

pages/index.vue

<template>
  <div>
    index.vue
  </div>
</template>

<script setup>
definePageMeta({
  layout: 'custom',
})
</script>

layouts/custom.vueではslotを記述してページコンポーネントが表示される箇所を指定します。そして、ページコンポーネントでdefinePageMeta関数にレイアウトを指定します。

他にもslotを利用したり動的にレイアウトを変更する例が公式で紹介されています。

layoutsとpagesが利用できました。

ToDoリストを作る

完成した表示とソースコードは先にお見せします。

ToDoリスト

layouts/custom.vue

<template>
  <div>
    <ClientOnly>
      <!-- 追加 -->
      <div class="d-flex align-center">
        <v-text-field
          v-model="taskNameModel"
          label="タスクを追加"
          hide-details
        ></v-text-field>
        <v-btn class="ml-4" @click="addTask">
          追加
        </v-btn>
      </div>
      <!-- リスト -->
      <v-list>
        <v-list-item v-for="task in taskList" :key="task.id">
          <v-list-item-avatar left>
            <v-checkbox v-model="task.isDone" hide-details></v-checkbox>
          </v-list-item-avatar>
          <v-list-item-header>
            <v-list-item-title>{{ task.taskName }}</v-list-item-title>
          </v-list-item-header>
          <template v-slot:append>
            <v-list-item-avatar right>
              <v-menu
                bottom
                left
              >
                <template v-slot:activator="{ props }">
                  <v-btn
                    dark
                    icon
                    v-bind="props"
                  >
                    <v-icon icon="mdi-dots-vertical" />
                  </v-btn>
                </template>
                <v-list>
                  <v-list-item
                    v-for="(item, i) in menu"
                    :key="i"
                    :value="item"
                    @click="onMenuClick(item.type, task)"
                  >
                    <v-list-item-title>{{ item.title }}</v-list-item-title>
                  </v-list-item>
                </v-list>
              </v-menu>
            </v-list-item-avatar>
          </template>
        </v-list-item>
      </v-list>
      <!-- 編集モーダル -->
      <v-dialog
        v-model="updateDialog.isShow"
      >
        <v-card width="400">
          <v-card-text>
            <v-text-field
              v-model="updateDialog.taskName"
              label="タスク名を更新"
            ></v-text-field>
          </v-card-text>
          <v-card-actions class="d-flex justify-center">
            <v-btn color="white" @click="updateDialog.hide()">Cancel</v-btn>
            <v-btn color="primary" @click="updateDialog.update()">Update</v-btn>
          </v-card-actions>
        </v-card>
      </v-dialog>
    </ClientOnly>
  </div>
</template>

<script setup lang="ts">
definePageMeta({
  layout: 'custom',
})
type Menu = {
  title: string
  type: MenuType
}
const MenuType = {
  Update: 'update',
  Delete: 'delete'
} as const

type MenuType = typeof MenuType[keyof typeof MenuType]

type Task = {
  id: number
  taskName: string
  isDone: boolean
}

const menu: Menu[] = [
  {
    title: '編集',
    type: MenuType.Update
  },
  {
    title: '削除',
    type: MenuType.Delete
  }
]

const onMenuClick = (type: MenuType, task: Task) => {
  switch (type) {
    case MenuType.Delete:
      deleteTask(task)
      break;
    case MenuType.Update:
      updateDialog.show(task)
      break;
    default:
      break;
  }
}

const taskList = ref<Task[]>([])
let id = 1

const taskNameModel = ref('')
const addTask = () => {
  taskList.value.push({
    id,
    taskName: taskNameModel.value,
    isDone: false
  })
  id++
  taskNameModel.value = ''
}

const deleteTask = (task: Task) => {
  const index = taskList.value.indexOf(task)
  taskList.value.splice(index, 1)
}

const updateDialog = reactive({
  taskId: -1,
  isShow: false,
  taskName: '',
  show: (task: Task) => {
    updateDialog.taskId = task.id
    updateDialog.taskName = task.taskName
    updateDialog.isShow = true
  },
  hide: () => {
    updateDialog.initialize()
    updateDialog.isShow = false
  },
  update: () => {
    const target = taskList.value.find(task => task.id === updateDialog.taskId)
    target.taskName = updateDialog.taskName
    updateDialog.hide()
  },
  initialize: () => {
    updateDialog.taskName = ''
    updateDialog.taskId = -1
  }
})
</script>

データの永続化をしていないので、画面を再読み込みすると追加したタスクは消えてしまいますが、一通りの機能を作成しました。

  • 一覧の表示
  • タスクの追加
  • タスク名の更新

ClientOnlyはwarningが発生するため記述しています。

scriptタグ内での書き方ですが、Nuxt2ではOptions APIと呼ばれるものでした。Nuxt3ではVue3で導入されたComposition APIという方法が採用されています。

Nuxt2でも@vue/composition-api@nuxtjs/composition-apiなどプラグインをインストールすることで利用できましたが、Nuxt3ではscriptタグにsetupと書けば利用できます。

今回のソースコードはComposition APIを書いたことがない方にとっては、refやreactiveなど見慣れないと思いますが、他は関数なので読むことは難しくないと思います。

composition APIを知りたい方はVue3のドキュメントを読むことをおすすめします。

さて、ToDoリストはここまでで一通り完成しているのですが、せっかくなのでcomposablesとuseStateを使ってリファクタしていきます。

composablesを使ってみる

In the context of Vue applications, a “composable” is a function that leverages Vue Composition API to encapsulate and reuse stateful logic.
hat is a “Composable”?

Composition APIで利用する処理のカプセル化をしたり、再利用する処理を管理するところみたいですね。

composables/useTodo.ts

import { Menu, MenuType, Task } from "~~/types/types"

export default function useTodo() {
  let id = 1
  const menu: Menu[] = [
    {
      title: '編集',
      type: MenuType.Update
    },
    {
      title: '削除',
      type: MenuType.Delete
    }
  ]
  const taskList = ref<Task[]>([])
  const taskNameModel = ref('')

  const onMenuClick = (type: MenuType, task: Task) => {
    switch (type) {
      case MenuType.Delete:
        deleteTask(task)
        break;
      case MenuType.Update:
        updateDialog.show(task)
        break;
      default:
        break;
    }
  }

  const addTask = () => {
    taskList.value.push({
      id,
      taskName: taskNameModel.value,
      isDone: false
    })
    id++
    taskNameModel.value = ''
  }

  const deleteTask = (task: Task) => {
    const index = taskList.value.indexOf(task)
    taskList.value.splice(index, 1)
  }

  const updateDialog = reactive({
    taskId: -1,
    isShow: false,
    taskName: '',
    show: (task: Task) => {
      updateDialog.taskId = task.id
      updateDialog.taskName = task.taskName
      updateDialog.isShow = true
    },
    hide: () => {
      updateDialog.initialize()
      updateDialog.isShow = false
    },
    update: () => {
      const target = taskList.value.find(task => task.id === updateDialog.taskId)
      target.taskName = updateDialog.taskName
      updateDialog.hide()
    },
    initialize: () => {
      updateDialog.taskName = ''
      updateDialog.taskId = -1
    }
  })

  return {
    taskList,
    taskNameModel,
    addTask,
    deleteTask,
    updateDialog,
    menu,
    onMenuClick
  }
}

 

pages/index.vue

<!-- 一部抜粋 -->
<script setup lang="ts">
definePageMeta({
  layout: 'custom',
})

const {
  taskList,
  taskNameModel,
  addTask,
  deleteTask,
  updateDialog,
  menu,
  onMenuClick
} = useTodo()
</script>

型に関してはtypesディレクトリにまとめました。

どこまでをcomposablesにするかの判断が難しそうですが、今は一旦全部composablesにまとめます。composablesディレクトリにファイルを作ると自動インポートしてくれるため、呼び出し元でimport文を書かなくていいのでスッキリします。

useStateを使ってみる

Nuxt3のドキュメントに従って使ってみます。

composables/states.ts

import { Task } from "~~/types/types"

export const useTaskList = () => useState<Task[]>('taskList', () => [])

 

composables/useTodo.ts

// 一部抜粋
const taskList = useTaskList()

refで定義していたtaskListをuseStateで定義します。

説明にコンポーネント間で共有されるstateと書いているのと、ディレクトリ構成を見てもstoreがないので、グローバルな状態管理はこのuseStateを利用するのかなと推測しています。

さいごに

今回はNuxt3やVue3の機能の一部を利用してみました。

自動インポートによってソースコードの記述量が減ったりComposition APIによって関数ベースで記述できるので開発者体験は良いという感想です。

composablesやuseStateなども使っていけば慣れると思うのでそんなに心配してませんが、「composablesに何を含めるか、何を含めないか」といった設計・ルールは考えないといけないなと思いました。

今回は始めからNuxt3を利用しましたが、Nuxt2からNuxt3へ移行する際の手順も紹介されているので、案件で利用するケースも増えていきそうですね。