いまさらNuxt3(その2)

2025/03/03に公開2

はじめに

1回目に続いて、Nuxt3 の入門記事です。

https://zenn.dev/robon/articles/2932b0bbb8d251

作ってみよう

app.vue と layouts と pages と

ま、以下を参照してください。

https://nuxt.com/docs/guide/directory-structure/app

https://nuxt.com/docs/guide/directory-structure/layouts

https://nuxt.com/docs/guide/directory-structure/pages

で、app.vue。ま、main というかエントリポイントになってます。

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

これで、layouts を使うよ。とか、ルーティングで pages から刺してね。みたいな

layouts を使うと、(付け替えもできるけど)全部に被さるので、Vuetify のお約束の v-app に入れといてね❤を強制できます。今回は、Vuetify 感を出すために、v-app-bar を少しモリましたが、一か所で共通レイアウトとかコンポーネントとか実現できていい感じです。

layouts/default.vue
<template>
  <v-app>
    <v-app-bar>
      <template #prepend>
        <v-app-bar-nav-icon />
      </template>
      <v-app-bar-title>Title</v-app-bar-title>
      <template #append>
        <v-btn icon="mdi-dots-vertical" />
      </template>
    </v-app-bar>
    <v-main>
      <slot />
    </v-main>
  </v-app>
</template>

そして、pages も折角なんで、Vuetify の Grid と Style を使ったものにしました。

pages/index.vue
<template>
  <v-container>
    <v-row>
      <v-spacer />
      <v-col
        align="center"
        class="text-h1"
      >
        Top Page
      </v-col>
      <v-spacer />
    </v-row>
  </v-container>
</template>

で、pnpm dev して、動かして、ブラウザ上の Nuxt DevTools で見ると構造が見えて、頭の中も整理できるかもしれません。

お題

さて、お題です。RESTful な WebAPI のバックエンドに対するフロントエンドを作ろうというものです。このブログでは、かつて RESTful な API を作ろうシリーズをやってまして、そのお題と同じものをバックエンドとして想定したいと思います。

https://zenn.dev/robon/articles/76d4ec767b72ae

まず、Nuxt の中を流れるデータ型を定義します。Nuxt3 では、オートインポートの対象にならないのですが、shared/types に作ります。

https://nuxt.com/docs/guide/directory-structure/shared

shared/types/customer.ts
export interface Customer {
  customerId: number
  name?: string
  address?: string
}
shared/types/orderHeader.ts
export interface OrderHeader {
  orderId: number
  customerId?: number
  orderDate?: string
  orderDetail?: OrderDetail[]
}

export interface OrderDetail {
  rowNum: number
  productId?: number
  quantity?: number
  pricePerUnit?: number
}
shared/types/product.ts
export interface Product {
  productId: number
  name?: string
  pricePerUnit?: number
}

で、これらの NestJS が自動生成するところの create、findAll、findOne、update、remove を作っていきます。

create の画面

新規登録画面です。Vuetify に任せて、あまりゴリゴリ書かなくても、いい感じにできます。

フォームは、Vuetify の Grid でレスポンシブ対応しています。v-text-field に number で入出力したり、右端の矢印を消したり、右寄せにしたりしましたので、必要なところをお使いください。

また、v-text-field 単位のバリデーションを設定してあり、アクティブになっていないチェックも「作成」ボタンの押下時に検査されて、検査に通ってからサーバー呼び出しをします。

/pages/customer/new.vue
<script setup lang="ts">
import type { Customer } from '~/shared/types/customer'

const customer = ref<Partial<Customer>>({})
const formRef = useTemplateRef('form')

const create = async () => {
  const validResult = await formRef.value?.validate()
  if (validResult?.valid) {
    console.log('validなのでサーバを呼びます!')
  }
}
</script>

<template>
  <v-form ref="form">
    <v-container>
      <v-row class="pt-6">
        <div class="text-subtitle-1">
          顧客マスタ
        </div>
      </v-row>
      <v-row class="py-2">
        <v-col
          cols="12"
          sm="6"
          md="4"
          ls="3"
        >
          <v-text-field
            v-model.number="customer.customerId"
            name="customerId"
            label="顧客ID"
            hint="顧客の識別子"
            type="number"
            :hide-spin-buttons="true"
            class="my-input-number"
            :rules="[(value) => (value ? true : '必須項目です')]"
          />
        </v-col>
        <v-col
          cols="12"
          sm="6"
          md="4"
          ls="3"
        >
          <v-text-field
            v-model="customer.name"
            name="name"
            label="氏名"
            hint="顧客の氏名"
            type="text"
          />
        </v-col>
        <v-col
          cols="12"
          sm="6"
          md="4"
          ls="3"
        >
          <v-text-field
            v-model="customer.address"
            name="address"
            label="住所"
            hint="顧客の住所"
            type="text"
          />
        </v-col>
      </v-row>
      <v-row class="py-2">
        <v-spacer />
        <v-col
          cols="12"
          sm="6"
          md="4"
          ls="3"
        >
          <v-btn
            block
            text="作成"
            color="primary"
            @click="create"
          />
        </v-col>
      </v-row>
    </v-container>
  </v-form>
</template>

<style scoped>
.my-input-number :deep(input) {
  text-align: end
}
</style>

サーバー呼び出しをしていないので、ここまでで画面は動きます。

create のサーバー

上の画面から直接バックエンドの API を呼び出しても良いのですが、今回のお題としては、バックエンドの API は、この Nuxt のフロントエンドからだけ呼び出せる(=外部にさらさない)というものなので、Nuxt 自身でも API を作って、リレーします。

また、後出しなのですが、UI 用の URL を API 用の URL に近いものにしたいという要件もあり、そうすると、Nuxt で実装する API と同じになってしまいます。/api は、以下の用途で封じられているので、今回は、/my の下に入れておきます。

https://nuxt.com/docs/guide/directory-structure/server#server-routes

routes/my/customer/index.post.ts
import type { Customer } from '~/shared/types/customer'

export default defineEventHandler(
  async (event): Promise<Customer> => {
    try {
      const body = await readBody(event)
      // return await $fetch<Customer>(`https://xxxxx/customer/`, {
      //   method: 'POST',
      //   body: body,
      // })
      return body
    }
    catch (e) {
      throw createError(e as Error)
    }
  },
)

ま、張りぼてなんですが。これを呼ぶように、new.vue も書き換えて

pages/customer/new.vue
@@ -7,7 +7,10 @@ const formRef = useTemplateRef('form')
 const create = async () => {
   const validResult = await formRef.value?.validate()
   if (validResult?.valid) {
-    console.log('validなのでサーバを呼びます!')
+    customer.value = await $fetch<Customer>('/my/customer', {
+      method: 'POST',
+      body: JSON.stringify(customer.value),
+    })
   }
 }

composables なるもので snackbar なるものを

サーバー呼び出しをしてみたが、これだと終わったのか終わってないのかよくわからないので、alert みたいにダサくない方法で、成功しましたしたいし、エラーの場合ももちろん通知したいし、それって、この画面だけじゃないよね。ということで、

https://vuetifyjs.com/ja/components/snackbars/

https://nuxt.com/docs/guide/directory-structure/composables

を使って、コンポーネント間の状態を共有しつつ、部品化してみます。

composables/useSnackbar.ts
const show = ref(false)
const message = ref('')
const color = ref('info')

export const useSnackbar = () => {
  const clear = () => {
    show.value = false
    message.value = ''
    color.value = 'info'
  }

  const error = (msg: string) => {
    show.value = true
    message.value = msg
    color.value = 'error'
  }
  const info = (msg: string) => {
    show.value = true
    message.value = msg
    color.value = 'info'
  }
  const success = (msg: string) => {
    show.value = true
    message.value = msg
    color.value = 'success'
  }
  const warning = (msg: string) => {
    show.value = true
    message.value = msg
    color.value = 'warning'
  }

  return {
    show,
    message,
    color,
    clear,
    error,
    info,
    success,
    warning,
  }
}

ここで定義した状態を使った UI を layouts に埋め込みます。

layouts/default.vue
@@ -1,3 +1,7 @@
+<script setup lang="ts">
+const { show, message, color, clear } = useSnackbar()
+</script>
+
 <template>
   <v-app>
     <v-app-bar>
@@ -12,5 +16,19 @@
     <v-main>
       <slot />
     </v-main>
+    <v-snackbar
+      v-model="show"
+      :color="color"
+      location="top center"
+      timeout="4000"
+    >
+      {{ message }}
+      <template #actions>
+        <v-btn
+          icon="mdi-close-circle"
+          @click="clear"
+        />
+      </template>
+    </v-snackbar>
   </v-app>
 </template>

こうしておけば、使う側の各画面には、以下のように組み込めます。

pages/customer/new.vue
@@ -4,13 +4,22 @@ import type { Customer } from '~/shared/types/customer'
 const customer = ref<Partial<Customer>>({})
 const formRef = useTemplateRef('form')
 
+const { error, success } = useSnackbar()
+
 const create = async () => {
   const validResult = await formRef.value?.validate()
   if (validResult?.valid) {
-    customer.value = await $fetch<Customer>('/my/customer', {
-      method: 'POST',
-      body: JSON.stringify(customer.value),
-    })
+    try {
+      customer.value = await $fetch<Customer>('/my/customer', {
+        method: 'POST',
+        body: JSON.stringify(customer.value),
+      })
+      success('登録しました')
+    }
+    catch (e) {
+      console.error(e)
+      error('失敗しました')
+    }
   }
 }

と、ここまでやると、こんな感じで、まぁまぁな感じのアプリケーションになります。

おわりに

いまさら Nuxt3?と思って始めたのですが、なかなか実践的なノウハウが公開されていなくて、1か月ぐらい試行錯誤したので、その一部を公開していきます。次回からは認証の予定です。

GitHubで編集を提案
株式会社ROBONの技術ブログ

Discussion

hedyhedy

当局は現在 nuxt4 を準備中です