🔥【Vue.js → Nuxt 入門推薦!🌟 新書即將上市 🌟】
📘《想要 SSR 嗎?就使用 Nuxt 吧!Nuxt 讓 Vue.js 更好處理 SEO 搜尋引擎最佳化》
👀 Nuxt v4 內容與範例也可以參考並購買本系列文筆著所著書籍
📦 預計於:2025/08/14 出版,目前天瓏書局預購有 7️⃣8️⃣ 折優惠
👉 點此前往購買:https://pse.is/7yulm5
注意:Nuxt 4 已於 2025/07/16 釋出,本文部分內容或範例可能和最新版本有所不同
上一篇,我們完成了基本的會員登入,接下來將進入網站的切版,這篇文章會使用布局模板來實現,上方導覽列與下方顯示網站內容的排版方式,接下來就會快速的進入到新增一篇部落格的文章,該如何實現 Server API 與前端進行串接。
首先,建立一個預設布局模板,我們預計使每個頁面於上方顯示導覽列,下方則是依據不同的頁面來顯示,大概如下所示。
+---------------------------+
| +-----------------------+ |
| | 導覽列                 | |
| +-----------------------+ |
| +-----------------------+ |
| | 不同頁面的內容顯示的位置  | |
| |                       | |
| |                       | |
| |                       | |
| +-----------------------+ |
+---------------------------+
我們可以建立一個 default.vue 於 layouts 目錄中,作為預設的佈局模板,模板內約會實作下面的程式碼:
<template>
  <div>
    <header>
      <!-- 導覽列 -->
    </header>
    <slot />
  </div>
</template>
<header> 裡面就是我們可以實作導覽列的位置,下方的 <slot /> 插槽,即會是我們可以放置頁面的容器。
完整的 ./layouts/default.vue 程式碼如下:
<template>
  <div>
    <header class="flex w-full justify-center px-8 xl:px-0">
      <nav class="flex w-full max-w-7xl items-center justify-between py-2">
        <div>
          <a aria-label="TailwindBlog" href="/">
            <div class="flex items-center justify-between">
              <div class="mr-3">
                <Icon class="h-12 w-12" name="logos:nuxt-icon" />
              </div>
              <div class="hidden h-6 text-2xl font-semibold text-gray-700 sm:block">
                Nuxt 3 Blog
              </div>
            </div>
          </a>
        </div>
        <div class="flex items-center text-base leading-5">
          <div class="flex flex-row items-center">
            <NuxtLink
              class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
              to="/login"
            >
              登入
            </NuxtLink>
          </div>
        </div>
      </nav>
    </header>
    <slot />
  </div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const userProfile = computed(() => userStore.profile)
</script>
接著記得調整 app.vue 內容,添加一個 <NuxtLayout> 元件來顯示預設的布局模板,包裹著的 <NuxtPage /> 即會放置於 default.vue 預設插槽之中,如此就能顯示路由的頁面。
<NuxtLayout>
  <NuxtPage />
</NuxtLayout>
至此,我們就完成的第一個導覽列,可以由導覽列提供的「登入」,切換至登入頁面,並且在每個頁面中,都使用預設的布局模板,所以都會顯示導覽列。
在Nuxt 3 布局模板 (Layouts) 這一篇文章內有介紹到,我們可以建立多個布局模板,而 Nuxt 也提供我們可以為頁面元件取消使用或選擇特定的布局模板。
例如,我們想將登入與註冊頁面,取消套用預設的布局模板,我們就可以使用 definePageMeta() 來傳入 layout: false 屬性來取消布局模板的使用。
調整登入頁面 ./pages/login.vue
<script setup>
// ...
definePageMeta({
  layout: false
})
</script>
如此一來,登入頁面將不會使用布局模板,導覽列也就不會顯示。
為了豐富導覽列,我們可以為已登入的使用者,建立一個使用者頭像,點擊後可以顯示使用者專用的選項,例如登出等功能。
我們的使用的是 Tailwind CSS 來做樣式的處理,headless UI 已經封裝一些實用且好看的元件,元件基於 Tailwind CSS 可以讓我們的風格更一致,也能更彈性的自訂成自己喜歡的樣式。
使用 NPM 安裝 @headlessui/vue
npm install -D @headlessui/vue
調整 nuxt.config,將 @headlessui/vue 新增至 build.transpile 屬性之中。
export default defineNuxtConfig({
  build: {
    transpile: ['@headlessui/vue']
  }
})
新增 ./components/NavigationBar/NavigationBarAvatarMenu.vue 內容如下:
<template>
  <div>
    <Menu as="div" class="relative inline-block text-left">
      <div>
        <MenuButton class="inline-flex w-full justify-center">
          <img
            class="inline-block h-10 w-10 rounded-full bg-white/90 object-cover object-center p-0.5 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur hover:bg-opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
            src="https://images.unsplash.com/photo-1577023311546-cdc07a8454d9?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=128&q=80"
            alt="使用者選單"
          />
        </MenuButton>
      </div>
      <transition
        enter-active-class="transition duration-100 ease-out"
        enter-from-class="transform scale-95 opacity-0"
        enter-to-class="transform scale-100 opacity-100"
        leave-active-class="transition duration-75 ease-in"
        leave-from-class="transform scale-100 opacity-100"
        leave-to-class="transform scale-95 opacity-0"
      >
        <MenuItems
          class="absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
        >
          <div class="px-1 py-1">
            <MenuItem v-slot="{ active }">
              <button
                :class="[
                  active ? 'bg-emerald-500 text-white' : 'text-gray-900',
                  'group flex w-full items-center rounded-md px-2 py-2 text-sm'
                ]"
              >
                <Icon
                  :active="active"
                  class="mr-2 h-5 w-5 text-emerald-400"
                  name="ri:logout-box-line"
                  aria-hidden="true"
                />
                登出
              </button>
            </MenuItem>
          </div>
        </MenuItems>
      </transition>
    </Menu>
  </div>
</template>
<script setup>
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
</script>
將 <NavigationBarAvatarMenu> 元件添加至導覽列之中,我們就可以擁有一個使用者頭像的選單囉。
使用者頭像選單的元件應該控制於使用者登入之後再顯示,所以我們可以結合 Pinia 進行狀態管理,來檢查使用者資訊的 store 是否具有資料且符合我們的判定依據再進行顯示,否則,我們僅需要渲染出登入的按鈕即可。
例如,我們從 User Store 取出使用者資訊 (Profile),並判斷是否有 id 來決定要顯示使用者頭像選單或登入。
<template>
  <div>
    <ClientOnly>
      <NavigationBarAvatarMenu v-if="userProfile?.id" />
      <NuxtLink
        v-else
        class="px-3 py-2 text-gray-700 transition hover:text-emerald-500"
        to="/login"
      >
        登入
      </NuxtLink>
    </ClientOnly>
  </div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const userProfile = computed(() => userStore.profile)
</script>
整個完成後,介面看起來就會比較乾淨也符合登入與未登入時,應該顯示的介面樣子。
當我們處理好登入後,就開可以開始來實作使用者建立部落格的文章囉!
我們的資料庫,是透過 Prisma 的 Schema 來自動產生資料表,我們可以建立如下的 Schema,來作為儲存文章內容的資料表。
model Article {
  id             Int      @id @default(autoincrement())
  title          String
  content        String
  cover          String
  tags           String
  authorId       String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
}
欄位相對簡單,各個欄位的用途與說明如下:
User 資料表的 id 欄位,表示文章的作者。另外,我想讓 Article 具有關聯性,所以我們可以使用 Prisma 提供的語法,來建立與 User 的外鍵 (Foreign Key),最後完整的 schema.prisma 如下:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
  relationMode = "prisma"
}
model User {
  id             String   @id @default(uuid())
  providerName   String?
  providerUserId String?
  nickname       String   @default("User")
  email          String   @unique
  password       String?
  avatar         String?
  emailVerified  Boolean  @default(false)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  Article        Article[]
}
model Article {
  id             Int      @id @default(autoincrement())
  title          String
  content        String
  cover          String
  tags           String
  authorId       String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
  User           User?    @relation(fields: [authorId], references: [id])
}
記得執行以下指令,來讓 Prisma 建立新的資料表。
npx prisma db push
建立 ./server/api/manage/articles.post.js,內容如下:
import db from '@/server/db'
export default defineEventHandler(async (event) => {
  const user = event.context?.auth?.user
  if (!user?.id) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }
  const body = await readBody(event)
  const authorId = user.id
  const articleRecord = await db.article.createArticle({
    title: body.title,
    content: body.content,
    cover: body.cover,
    tags: body.tags,
    authorId
  })
  if (!articleRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Create article failed. Please try again later.'
    })
  }
  return articleRecord
})
整個 Server API 的運作流程如下:
/api/manage/article,伺服器中間件,將會解析 JWT 來得到 user。user.id 來決定是否具有權限,如果正確解析 JWT,表示請求為一個已登入的使用者發送,也將放行給予新增文章。建立文章的 Prisma Client 操作如下:
async createArticle(options) {
  const articleRecord = await prisma.article
    .create({
      data: {
        title: options.title,
        content: options.content,
        cover: options.cover,
        tags: options.tags,
        authorId: options.authorId
      }
    })
    .catch((error) => {
      console.error(error)
      throw createError({
        statusCode: 500,
        statusMessage: 'Could not create article. Please try again later.'
      })
    })
  return articleRecord
}
我們就完成了建立文章的 API 囉!
使用 Prisma Client 來取得文章,也非常方便,如下程式碼,我們就能取出文章資料並以建立時間遞減排序囉!
async getArticles(options = {}) {
  const articleRecords = await prisma.article
    .findMany({
      orderBy: {
        createdAt: 'desc'
      },
      skip: options.pageSize ? options.page * options.pageSize : undefined,
      take: options.pageSize
    })
    .catch((error) => {
      console.error(error)
      throw createError({
        statusCode: 500,
        statusMessage: 'Could not create user. Please try again later.'
      })
    })
  return articleRecords
}
./server/api/articles.get.js 是實作取得文章的 API,我們期望任何人都可以瀏覽這個部落格的文章,所以這隻 API 我們無需驗證使用者即可放行,程式碼如下:
import db from '@/server/db'
export default defineEventHandler(async () => {
  const articlesRecord = await db.article.getArticles()
  return articlesRecord
})
我們也可以在 ./server/api/articles.get.js 處理函數內,添加資料的分頁或為文章資料進行加工,以利前端顯示。
今天我們建立好了部落個的版面,有了導覽列和使用者選單,網站看起來也更專業了一些,結合布局模板,我們可以控制每個頁面所顯示的布局,在未來有更多頁面的時候能具有可控性。最後也實作了新增文章的 API,完成了第一篇文章的新增與瀏覽。
感謝大家的閱讀,這是我第一次參加 iThome 鐵人賽,請鞭小力一些,也歡迎大家給予建議 :)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。
你好,請教一下將 @headlessui/vue 新增至 build.transpile,為何是放在這邊,找不到說明安裝文件,這邊跟 modules 不同是嗎?,該如何判別要設置在哪裡呢。
其實主要還是觀察原本的套件是如何做使用來做判斷,
如果是專們為 Nuxt 或有支援 Nuxt 的套件或模組,多數都會描述如何安裝以及如何做配置,也多以配置在 modules 的設定。
而如果套件單純是 Vue 的套件,則會去判斷說是否有依賴到 Vue 的實例來結合 Nuxt 的插件配置來做使用。
最後是這些套件本身就是通用或者是需要為 Vue 額外開分支或不同流程的套件,那麼配置確實有時不大好找,需要仰賴本身的開發經驗與判斷套件是否有依賴到 Vue 的實例或編譯時的配置。
所以也可能發生,照著導入完發生一些錯誤等等的情況發生,這時候就是依據錯誤訊息來判斷是否有哪部分遺漏了或尋找其他支援。
以 @headlessui/vue 的狀況來說,最一開始我在導入時也是發現一些問題尋找 Github 上 Issues 才順利的解決。
了解,感謝詳細說明!