BACK
Featured image of post 【Nuxt3】實戰練習 - 實作部落格

【Nuxt3】實戰練習 - 實作部落格

大家經過 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 這篇文章,應該對於 Nuxt 3 有初步的理解,接下來我們將進入實戰部分,我將會以 Nuxt 3 來實作部落格網站,讓已經註冊的使用者可以在網站上發布文章,實作這個網站的程式碼可能不會講解得非常仔細,但一些實務開發上會需要注意的細節我會把個人經驗做一個紀錄,大家可以再參考看看,文末也都會附上完整的範例程式。接下來,讓我們開始吧!

參考網站
參考網站
參考網站

大家經過 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 這篇文章,應該對於 Nuxt 3 有初步的理解,接下來我們將進入實戰部分,我將會以 Nuxt 3 來實作部落格網站,讓已經註冊的使用者可以在網站上發布文章,實作這個網站的程式碼可能不會講解得非常仔細,但一些實務開發上會需要注意的細節我會把個人經驗做一個紀錄,大家可以再參考看看,文末也都會附上完整的範例程式。接下來,讓我們開始吧!


資料庫與會員系統

資料庫 (Database)

這篇文章實作的部落格,會把會員與文章等資料儲存於伺服器的資料庫之中,大家可以選擇自己習慣或合適的資料庫來做儲存。為了方便及後續的展示,我最終決定使用 Prisma 搭配本地的 SQLite 來當作儲存體,讓大家測試時不用再煩惱怎麼架設資料庫,可以快速的執行範例程式碼。

你可以在自己的 Nuxt 專案或從新專案開始進行,若你已經有自己的資料庫,也可以直接跳過此段介紹後續實作自己的後端 API 來接續我們的實作系列。

安裝 Prisma

Prisma 操作起來很像 ORM (Object-Relational Mapping),但實際上依據官網的說明,其實不大依樣,Prisma 透過撰寫並根據 Schema 來建立或操作資料庫,在進行 CRUD 的操作時,都是透過 Prisma Client 進行,這也是最方便的地方,此外也支援多種資料庫的來源,只要操作 Model 就可以映射到資料庫的資料,不再需要寫 SQL,在一些情境之下是非常方便的。

首先,使用 NPM 安裝 prisma@prisma/client

1
2
3
npm install -D prisma @prisma/client
# or
# yarn add -D prisma @prisma/client

打開終端機 (Terminal) 於 Nuxt 專案目錄中, 使用下列指令,初始化一個 PrismaSchema

1
npx prisma ini

初始化完成後,會建立一個 schema.prisma 檔案。

./prisma/schema.prisma 檔案內容如下,這裡就是定義我們資料庫位置與 Schema 的地方,之後我們就可以透過 PrismaClient 使用 ORM 來操作資料庫。

prisma/schema.prisma
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 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 = "postgresql"
  url      = env("DATABASE_URL")
}

我們調整 ./prisma/schema.prisma 檔案內容,將 datasource 替換為 SQLite 並儲存在本地的 ./dev.db

1
2
3
4
datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

你也可以將 provider 替換為 PostgreSQL 或 MySQL 等,可以參考這裡,但要注意可能後面定義的 Schema 語法會略微不同。

接下來我們定義一個 User 的資料表,在 schema.prisma 撰寫如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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
}

這張 User 資料表,將用作於部落格的登入使用者做使用,也可以視為是會員系統使用的資料表,大家也可以依據需求來擴增欄位,以下稍微講述一下各個欄位將作為何用。

  • id: 預設為 UUID,為使用者的唯一識別。
  • providerName: 作為第三方登入的供應商記錄使用,例如該名使用者使用 Google OAuth 註冊登入,我會在欄位就會填上 google。如果為空值 (null) 表示使用者用電子信箱註冊登入。
  • providerUserId: 與 providerName 搭配使用,第三方供應商通常也會有一組專屬於使用者的 Id,以此我們就可以來比對登入的是哪位使用者。如果為空值 (null) 表示使用者用電子信箱註冊登入。
  • nickname: 使用者暱稱,預設值為字串 User。
  • email: 使用者登入的電子信箱,這裡我將欄位設定為 @unique 表示,電子信箱是系統中唯一。
  • password:使用者密碼的雜湊值,如果使用第三方註冊登入,則該欄位為 空值 (null)。
  • avatar: 使用者的頭像,存放圖片網址。
  • emailVerified: 布林值,預設為 false,表示使用者的電子信箱是尚未通過驗證。
  • createdAt: 使用者建立時間,預設為插入該筆資料的時間。
  • updatedAt: 使用者更新個人資料的時間,預設為更新該筆資料的時間。

./prisma/schema.prisma 檔案內容看起來如下:

prisma/schema.prisma
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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"
}

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
}

當我們調整好 schema 後,就可以執行下列指令,來初始化資料庫,Prisma 會依照 schema.prism 來幫我們建立對應的資料表。

1
npx prisma db push

初始化完畢後,你可以登入你的資料庫查看是否建立成功,也可使用 Prisma 提供的 Prisma Studio 來快速的檢視與操作資料庫內的資料。Prisma Studio 已經內建在 prisma 套件中,執行以下指令後,就會啟動一個 Web 服務,如 http://localhost:5555,我們就可以在網頁中查看資料庫內的資料囉!

1
npx prisma studio

可以在 Prisma Studio 看到我們建立的 User 資料表,也將是我們稍後使用 Prisma 操作 ORM 所對應的 User Model

最後記得執行下列指令來產生 Prisma Client,這樣我們就可以在 Nuxt 3 中使用 Prisma Client 操作資料庫囉!

1
npx prisma generate

Nuxt 3 操作 Prisma Client 建立一個使用者至資料庫

我們接下來就能使用如下程式碼建立 Prisma Client,後續可以用來來操作 Model,更多的 Prisma Client API 可以參考官方文件

1
2
3
import { PrismaClient } from '@prisma/client'

const prismaClient = new PrismaClient()

我們建立一隻 Server API,新增 ./server/api/test-create-user.get.js,用來測試收到請求後建立一個測試使用者,詳細的程式碼如下:

server/api/test-create-user.get.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { PrismaClient } from '@prisma/client'

const prismaClient = new PrismaClient()

export default defineEventHandler(() => {
  const user = prismaClient.user.create({
    data: {
      providerName: null,
      providerUserId: null,
      nickname: 'Ryan',
      email: '[email protected]',
      password: '這裡要放密碼的雜湊值',
      avatar: '',
      emailVerified: true
    }
  })

  return user
})

當我們送出 /api/test-create-user 後,後端會使用 Prisma Client 操作 User Model,我們就能使用 ORM 來建立出使用者的資料庫記錄。

Nuxt 3 使用者註冊帳號

我們將 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 這篇文章,所串接的 Google OAuth 及 Cookie 做一個結合,讓使用者透過 Google Auth 登入後可以自動的註冊建立使用者或登入產生 Access Token。

./server/api/auth/google.post.js 程式碼如下:

server/api/auth/google.post.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { OAuth2Client } from 'google-auth-library'
import jwt from 'jsonwebtoken'
import db from '@/server/db'

const runtimeConfig = useRuntimeConfig()

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const oauth2Client = new OAuth2Client()
  oauth2Client.setCredentials({ access_token: body.accessToken })

  const userInfo = await oauth2Client
    .request({
      url: 'https://www.googleapis.com/oauth2/v3/userinfo'
    })
    .then((response) => response.data)
    .catch(() => null)

  oauth2Client.revokeCredentials()

  if (!userInfo) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Invalid token'
    })
  }

  let userRecord = await db.user.getUserByEmail({
    email: userInfo.email
  })

  if (userRecord) {
    if (
      (userRecord.providerName === 'google' && userRecord.providerUserId === userInfo.sub) === false
    ) {
      throw createError({
        statusCode: 400,
        statusMessage: 'This email address does not apply to this login method'
      })
    }
  } else {
    userRecord = await db.user.createUser({
      providerName: 'google',
      providerUserId: userInfo.sub,
      nickname: userInfo.name,
      email: userInfo.email,
      password: null,
      avatar: userInfo.picture,
      emailVerified: userInfo.email_verified
    })
  }

  const jwtTokenPayload = {
    id: userRecord.id
  }

  const maxAge = 60 * 60 * 24 * 7
  const expires = Math.floor(Date.now() / 1000) + maxAge

  const jwtToken = jwt.sign(
    {
      exp: expires,
      data: jwtTokenPayload
    },
    runtimeConfig.jwtSignSecret
  )

  setCookie(event, 'access_token', jwtToken, {
    httpOnly: true,
    maxAge,
    expires: new Date(expires * 1000),
    secure: process.env.NODE_ENV === 'production',
    path: '/'
  })

  return {
    id: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})

程式碼內容稍微有一點多,但講解一下流整與概念:

  1. 當前端 Google OAuth 登入成功後,將回傳的 Google access_token 傳送至這隻 API,並使用 Google API 取得使用者資訊。
  2. db.user.getUserByEmail 這個是我封裝的方法,裡面對應著 Prisma 的 ORM 操作,如果你想也可以在這邊替換你的資料庫操作邏輯,主要這個方法,就是依照使用者的 Email 回傳資料庫內是否存在一筆符合的使用者記錄
  3. 如果存在,我會判斷 provider 是否符合 Google 的使用者資訊,否則判斷為應該是用電子信箱註冊的使用者。
  4. 如果不存在,則建立一個新的使用者至資料庫內,建立時不需要傳入 id 資料庫因為設定為自動產生 UUID。
  5. 最後就是產生使用者的 JWT 並設定在 cookie 之中。

另外,我也實作了使用電子信箱直接註冊的方式,./server/api/auth/register.post.js 程式碼如下:

server/api/auth/register.post.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import bcrypt from 'bcrypt'
import db from '@/server/db'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  let userRecord = await db.user.getUserByEmail({
    email: body.email
  })

  if (userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'A user with that email address already exists'
    })
  }

  userRecord = await db.user.createUser({
    providerName: null,
    providerUserId: null,
    nickname: body.nickname,
    email: body.email,
    password: bcrypt.hashSync(body.password, 10),
    avatar: null,
    emailVerified: false
  })

  return {
    id: userRecord.id,
    nickname: userRecord.nickname,
    email: userRecord.email
  }
})

使用電子信箱與密碼註冊的流程很簡單,就是判斷是否存在相同信箱的使用者,不存在的話就為它建立一筆紀錄。

這邊要注意的是,會員系統或牽扯到帳號密碼相關的,請一律使用雜湊演算法,例如 BCryptArgon2,為使用者的密碼做 Hash,不要再存明碼在資料庫之中囉,以免發生資安事件時,造成不可挽回的悲劇。

順便也實作一下使用電子信箱與密碼登入的 API,./server/api/auth/login.post.js 程式碼如下:

server/api/auth/login.post.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import db from '@/server/db'

const runtimeConfig = useRuntimeConfig()

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  const userRecord = await db.user.getUserByEmail({
    email: body.email
  })

  if (!userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email or password is incorrect'
    })
  }

  if ((await bcrypt.compare(body.password, userRecord.password)) !== true) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email or password is incorrect'
    })
  }

  const jwtTokenPayload = {
    id: userRecord.id
  }

  const maxAge = 60 * 60 * 24 * 7
  const expires = Math.floor(Date.now() / 1000) + maxAge

  const jwtToken = jwt.sign(
    {
      exp: expires,
      data: jwtTokenPayload
    },
    runtimeConfig.jwtSignSecret
  )

  setCookie(event, 'access_token', jwtToken, {
    httpOnly: true,
    maxAge,
    expires: new Date(expires * 1000),
    secure: process.env.NODE_ENV === 'production',
    path: '/'
  })

  return {
    id: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})
  • 使用 Google OAuth 登入

  • 使用電子信箱與密碼登入

結合 Pinia 儲存使用者資料

我們可以結合 Pinia 來將使用者的資料持久話儲存在 Local Storage 之中,這樣就可以在前端儲存使用者登入的狀態,例如導覽列的頭像、信箱,就可以從 Store 中拿出來囉。

建立 ./server/profile.get.js 檔案,用來取得使用者資料:

server/profile.get.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import jwt from 'jsonwebtoken'
import db from '@/server/db'

const runtimeConfig = useRuntimeConfig()

export default defineEventHandler(async (event) => {
  const jwtToken = getCookie(event, 'access_token')

  let userInfo = null

  try {
    const { data } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret)
    userInfo = data
  } catch (e) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }

  if (!userInfo?.id) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }

  const userRecord = await db.user.getUserById({
    id: userInfo.id
  })

  if (!userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Could not find user.'
    })
  }

  return {
    id: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})

新增一個 userstore./stores/user.js 內容如下:

stores/user.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    profile: {
      id: null,
      provider: {
        name: null,
        userId: null
      },
      nickname: null,
      avatar: null,
      email: null
    }
  }),
  actions: {
    async refreshUserProfile() {
      const { data, error } = await useFetch('/api/user/profile', { initialCache: false })
      if (data.value) {
        this.profile = data.value
      } else {
        return error.value?.data?.message ?? '未知錯誤'
      }
    }
  },
  persist: {
    enabled: true,
    strategies: [
      {
        key: 'user',
        storage: process.client ? localStorage : null
      }
    ]
  }
})

我們就可以直接使用 refreshUserProfile() 來發送請求至 /api/user/profile 取得最新的使用者資料來更新 store。

1
2
3
4
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()

userStore.refreshUserProfile()

使用伺服器中間件來驗證 JWT

我們的會員系統在登入後,會產生一組 JWT 放置於 cookie 之中,在後端 API 使用時都要在呼叫 getCookie() 來解析 cookie,所以我們可以將驗證 JWT 的流程,放置在伺服器中間件 (middleware) 之中,後端收到的每個請求就會經過這個中間件,只要有夾帶 access_token 的 cookie 就會進行驗證解析出 JWT 所含的 payload id,即為使用者的 ID。

建立 ./server/middleware/auth.js 檔案,內容如下:

server/middleware/auth.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import jwt from 'jsonwebtoken'

const runtimeConfig = useRuntimeConfig()

export default defineEventHandler((event) => {
  const jwtToken = getCookie(event, 'access_token')

  if (!jwtToken) {
    return
  }

  let userInfo = null

  try {
    const { data } = jwt.verify(jwtToken, runtimeConfig.jwtSignSecret)

    userInfo = data
    if (userInfo?.id) {
      event.context.auth = {
        user: {
          id: userInfo.id
        }
      }
    }
  } catch (e) {
    console.error('Invalid token')
  }
})

伺服器的中間件只要定義在 ./server/middleware 目錄下就會自動被載入,之後在每個 Server API 收到請求,中間件只要有成功驗證並解析 JWT,就會在 event.context.auth 添加使用者資訊,之後在 Server API 的處理函數中,就可以以下列程式碼進行使用。

1
2
3
export default defineEventHandler(async (event) => {
  const user = event.context?.auth?.user
})

調整後的 ./server/profile.get.js 檔案,就會乾淨許多囉!

server/profile.get.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 userRecord = await db.user.getUserById({
    id: user.id
  })

  if (!userRecord) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Could not find user.'
    })
  }

  return {
    id: userRecord.id,
    provider: {
      name: userRecord.providerName,
      userId: userRecord.providerUserId
    },
    nickname: userRecord.nickname,
    avatar: userRecord.avatar,
    email: userRecord.email
  }
})

為了實作部落格,我們使用 Prisma 快速的建立資料庫環境,也方便大家可以下載範例程式碼,就可以在自己的電腦上運作 SQLite。也結合 Pinia 來將使用者的資料進行持久化的儲存,這樣我們就可以實作出如判斷使用者是否登入或是建立導覽列上的登入狀態。

範例程式碼 - Nuxt 3 - 實作部落格資料庫與會員系統


導覽列模板與新增文章

上面我們完成了基本的會員登入,接下來將進入網站的切版,以下會使用布局模板來實現,上方導覽列與下方顯示網站內容的排版方式,接下來就會快速的進入到新增一篇部落格的文章,該如何實現 Server API 與前端進行串接。

預設布局模板

首先,建立一個預設布局模板,我們預計使每個頁面於上方顯示導覽列,下方則是依據不同的頁面來顯示,大概如下所示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
+---------------------------+
| +-----------------------+ |
| | 導覽列                 | |
| +-----------------------+ |
| +-----------------------+ |
| | 不同頁面的內容顯示的位置  | |
| |                       | |
| |                       | |
| |                       | |
| +-----------------------+ |
+---------------------------+

我們可以建立一個 default.vuelayouts 目錄中,作為預設的佈局模板,模板內約會實作下面的程式碼:

1
2
3
4
5
6
7
8
<template>
  <div>
    <header>
      <!-- 導覽列 -->
    </header>
    <slot />
  </div>
</template>

<header> 裡面就是我們可以實作導覽列的位置,下方的 <slot /> 插槽,即會是我們可以放置頁面的容器。

完整的 ./layouts/default.vue 程式碼如下:

layouts/default.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<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 預設插槽之中,如此就能顯示路由的頁面。

1
2
3
4
5
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

至此,我們就完成的第一個導覽列,可以由導覽列提供的「登入」,切換至登入頁面,並且在每個頁面中,都使用預設的布局模板,所以都會顯示導覽列。

取消或替換特定頁面的布局模板

【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 - Nuxt 3 布局模板 (Layouts) 內有介紹到,我們可以建立多個布局模板,而 Nuxt 也提供我們可以為頁面元件取消使用或選擇特定的布局模板。

例如,我們想將登入與註冊頁面,取消套用預設的布局模板,我們就可以使用 definePageMeta() 來傳入 layout: false 屬性來取消布局模板的使用。

調整登入頁面 ./pages/login.vue

pages/login.vue
1
2
3
4
5
6
7
<script setup>
// ...

definePageMeta({
  layout: false
})
</script>

如此一來,登入頁面將不會使用布局模板,導覽列也就不會顯示。

建立使用者選單

為了豐富導覽列,我們可以為已登入的使用者,建立一個使用者頭像,點擊後可以顯示使用者專用的選項,例如登出等功能。

安裝 headless UI

我們的使用的是 Tailwind CSS 來做樣式的處理,headless UI 已經封裝一些實用且好看的元件,元件基於 Tailwind CSS 可以讓我們的風格更一致,也能更彈性的自訂成自己喜歡的樣式。

使用 NPM 安裝 @headlessui/vue

1
2
3
npm install -D @headlessui/vue
# or
# yarn add -D @headlessui/vue

調整 nuxt.config.ts,將 @headlessui/vue 新增至 build.transpile 屬性之中。

nuxt.config.ts
1
2
3
4
5
export default defineNuxtConfig({
  build: {
    transpile: ['@headlessui/vue']
  }
})

建立使用者頭像選單元件

新增 ./components/NavigationBar/NavigationBarAvatarMenu.vue 內容如下:

components/NavigationBar/NavigationBarAvatarMenu.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<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> 元件添加至導覽列之中,我們就可以擁有一個使用者頭像的選單囉。

結合使用者 Store 來控制顯示的時機

使用者頭像選單的元件應該控制於使用者登入之後再顯示,所以我們可以結合 Pinia 進行狀態管理,來檢查使用者資訊的 store 是否具有資料且符合我們的判定依據再進行顯示,否則,我們僅需要渲染出登入的按鈕即可。

例如,我們從 User Store 取出使用者資訊 (Profile),並判斷是否有 id 來決定要顯示使用者頭像選單登入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<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,來作為儲存文章內容的資料表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
model Article {
  id             Int      @id @default(autoincrement())
  title          String
  content        String
  cover          String
  tags           String
  authorId       String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt
}

欄位相對簡單,各個欄位的用途與說明如下:

  • id: 文章的 ID,採用自動遞增的數字。
  • title: 文章的標題。
  • content: 文章的內容。
  • cover: 文章的封面圖片。
  • tags: 文章的標籤。
  • authorId: 對應 User 資料表的 id 欄位,表示文章的作者。
  • createdAt: 文章建立時間,預設為插入該筆資料的時間。
  • updatedAt: 使用者更新文章資料的時間,預設為更新該筆資料的時間。

另外,我想讓 Article 具有關聯性,所以我們可以使用 Prisma 提供的語法,來建立與 User 的外鍵 (Foreign Key),最後完整的 schema.prisma 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 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"
}

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 建立新的資料表。

1
npx prisma db push

建立新增文章的 API

建立 ./server/api/manage/articles.post.js,內容如下:

server/api/manage/articles.post.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 的運作流程如下:

  1. 使用者將欲新增的文章資料以 POST 發送至 /api/manage/article,伺服器中間件,將會解析 JWT 來得到 user。
  2. 判斷 user.id 來決定是否具有權限,如果正確解析 JWT,表示請求為一個已登入的使用者發送,也將放行給予新增文章。
  3. 處理函數將解析 Body 內的資料,並建構出往資料庫新增文章記錄的內容。
  4. 判斷是否新增成功,回傳新增的文章資料。

建立文章的 Prisma Client 操作如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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 來取得文章,也非常方便,如下程式碼,我們就能取出文章資料並以建立時間遞減排序囉!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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 我們無需驗證使用者即可放行,程式碼如下:

server/api/articles.get.js
1
2
3
4
5
6
7
import db from '@/server/db'

export default defineEventHandler(async () => {
  const articlesRecord = await db.article.getArticles()

  return articlesRecord
})

我們也可以在 ./server/api/articles.get.js 處理函數內,添加資料的分頁或為文章資料進行加工,以利前端顯示。

我們建立好了部落個的版面,有了導覽列和使用者選單,網站看起來也更專業了一些,結合布局模板,我們可以控制每個頁面所顯示的布局,在未來有更多頁面的時候能具有可控性。最後也實作了新增文章的 API,完成了第一篇文章的新增與瀏覽。


頁面的導航守衛與切換效果

我們完成了新增文章的頁面與流程,在網站開發的過程中,有些頁面是具有瀏覽的限制,例如,我們不希望一般的使用者能進到管理者頁面專用的頁面進行操作,這時候我們就需要做一些權限的驗證與限制,在 Nuxt 的頁面提供了路由中間件可以在我們導航至頁面之前,執行一些處理函數,就實作導航守衛 (Navigations Guards) 的效果。最後會介紹一下在 Nuxt 3 所提供的頁面和布局切換時的進度條 (Progress bar)轉場效果 (Transitions)

頁面間的導航守衛

只允許已登入使用者新增文章

首先,我們新增一個路由中間件 ./middleware/manage-auth.js 內容如下:

middleware/manage-auth.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { useUserStore } from '@/stores/user'

export default defineNuxtRouteMiddleware(() => {
  if (process.client) {
    const userStore = useUserStore()

    if (!userStore.profile?.id) {
      return navigateTo('/login')
    }
  } else {
    return navigateTo('/')
  }
})

在頁面元件中使用 definePageMeta() 來套用 manage-auth 中間件。調整 ./pages/manage/articles/create.vue 內容:

pages/manage/articles/create.vue
1
2
3
4
5
6
7
<script setup>
// ...

definePageMeta({
  middleware: 'manage-auth'
})
</script>

我們首頁新增一個可以導向至 /manage/articles/create 頁面的按鈕。

1
2
3
4
5
6
<NuxtLink
  class="text-md mt-12 rounded-sm bg-emerald-500 py-2 px-4 font-medium text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2"
  to="/manage/articles/create"
>
  前往撰寫文章
</NuxtLink>

當我們處於未登入的情況,點擊前往後,會經由中間件判斷 process.client 是否是在客戶端,進而從 User Store 取出使用者資訊進行判斷,當不存在 userStore.profile.id 時,表示未登入,我們將導航至登入頁面 /login;當登入完成後,就可以使用按鈕成功導航至新增文章的頁面。如果 process.clientfalse,表示導航是在伺服器端觸發的,例如,我們直接透過網址進入新增文章頁面,將會一律被重新導航至首頁。

登入完成後導回至登入前瀏覽的頁面

當使用者在瀏覽網站時被引導或準備登入時,我們可以將使用者目前的頁面進行記錄,以便登入完成後,可以重新導向至使用者登入前的頁面,以此提供使用者更棒的體驗。

首先,我們新增一個路由中間件 ./middleware/logged-in-redirect.js 內容如下:

middleware/logged-in-redirect.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export default defineNuxtRouteMiddleware((to, from) => {
  if (from && to.path !== from.path && !to.query.redirect_to) {
    let redirectTo = null
    if (from.query.redirect_to) {
      redirectTo = from.query.redirect_to
      from.query.redirect_to = undefined
    } else {
      redirectTo = from.fullPath
    }

    to.query.redirect_to = redirectTo

    return navigateTo(to)
  }
})

這個 logged-in-redirect 中間件的處理邏輯,我們接收 tofrom,分別為目標頁面與來源頁面,當使用者目標頁面 /login 還未帶上了 Query 參數 redirect_to,我們就將來源的完整路徑添加上去 from.fullPath,最後進行導向。

這裡需要進行判斷 redirect_to,否則會重複發生重新導向。

在登入頁面中使用 definePageMeta() 來套用 logged-in-redirect 中間件。調整 ./pages/login.vue 內容:

pages/login.vue
1
2
3
4
5
6
7
<script setup>
// ...

definePageMeta({
  middleware: 'logged-in-redirect'
})
</script>

在登入完成的地方,我們就可以使用 navigateTo() 導向至 redirect_to 給的頁面路徑。

1
2
3
4
5
6
7
8
9
<script setup>
const route = useRoute()

const handleEmailLogin = async () => {
  // ...

  navigateTo(route.query.redirect_to ?? '/')
}
</script>

完成後,我們從首頁點擊登入後.當登入完成後就會導向回首頁;而從文章頁面點擊登入,完成後則會導向回文章頁面。

頁面載入進度元件

Nuxt 3 提供一個 <NuxtLoadingIndicator> 元件,用作頁面導航後顯示載入的進度,會在頁面上方有一個進度條 (Progress bar)。

只需要將 <NuxtLoadingIndicator> 元件添加至 app.vue 或布局之中,調整 app.vue 內容如下:

app.vue
1
2
3
4
5
6
<template>
  <NuxtLayout>
    <NuxtLoadingIndicator />
    <NuxtPage />
  </NuxtLayout>
</template>

元件可以傳入的屬性 (Props) 如下:

  • color:進度條的顏色,可以傳入CSS 支援的色碼repeating-linear-gradient() 函数,預設為 repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)
  • height:進度條的高度數值,單位: px,預設值為 3
  • duration:進度條載入的持續時間,單位: 毫秒,預設值為 2000
  • throttle:進度條的隱藏與顯示,限制在特定時間內僅觸發一次,單位: 毫秒,預設值為 200

當我們從首頁切換頁面時,網頁上方就會出現一個進度條,表示頁面正在載入中。

頁面切換的轉場效果

除了載入的進度條,頁面切換之間,也可以使用轉場效果 (Transitions) 來讓頁面之間的銜接更柔順,Nuxt 利用了 Vue 內建的 <Transition> 元件來幫助處理轉場和動畫,用以響應不斷變化的頁面與狀態。

Nuxt 預設為所有頁面 (Pages) 都設置了轉場,如果要啟用,請將以下 CSS 添加至 app.vue 中:

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <NuxtPage />
</template>

<style>
.page-enter-active,
.page-leave-active {
  transition: all 0.4s;
}
.page-enter-from,
.page-leave-to {
  opacity: 0;
  filter: blur(1rem);
}
</style>

每個頁面的 pageTransition 預設屬性皆為 { name: 'page', mode: 'out-in' }namepage 也就對應了 CSS 類別的開頭;modein-outout-indefault 三種參數可選。

1
2
3
4
5
6
7
8
9
.[pageTransition.name]-enter-active,
.[pageTransition.name]-leave-active {
  transition: all 0.4s;
}
.[pageTransition.name]-enter-from,
.[pageTransition.name]-leave-to {
  opacity: 0;
  filter: blur(1rem);
}

套用好頁面的轉場,就會有切換頁面時有模糊的效果。

自訂頁面的轉場

既然知道 pageTransitionname 會對應頁面轉場的 CSS 名稱,我們就可以來自定義更多轉場,讓不同頁面套用不同的效果。

例如在 app.vue 添加 rotate 為前綴的類別名稱 CSS。

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<style>
/* ... */
.rotate-enter-active,
.rotate-leave-active {
  transition: all 0.4s;
}
.rotate-enter-from,
.rotate-leave-to {
  opacity: 0;
  transform: rotate3d(1, 1, 1, 15deg);
}
</style>

在頁面中使用 definePageMeta() 來設定 pageTransition.namerotate。調整 ./pages/login.vue 添加如下程式碼。

pages/login.vue
1
2
3
4
5
6
7
8
9
<script setup>
// ...

definePageMeta({
  pageTransition: {
    name: 'rotate'
  }
})
</script>

當頁面切換時,皆會使用預設的模糊轉場效果,當切換至登入頁面就會套用指定的 rotate 頁面轉場,而有旋轉的轉場效果。

布局的轉場效果

Nuxt 同樣為所有布局 (Layouts) 都設置了轉場,如果要啟用,請將以下 CSS 添加至 app.vue 中。

1
2
3
4
5
6
7
8
.layout-enter-active,
.layout-leave-active {
  transition: all 0.4s;
}
.layout-enter-from,
.layout-leave-to {
  filter: grayscale(1);
}

建立 ./layouts/teal.vue 布局,程式碼如下:

layouts/teal.vue
1
2
3
4
5
<template>
  <div class="h-screen bg-teal-50">
    <slot />
  </div>
</template>

將登入頁面的布局套用 teal

1
2
3
4
5
6
7
<script setup>
// ...

definePageMeta({
  layout: 'teal'
})
</script>

建立 ./layouts/green.vue 布局,程式碼如下:

layouts/green.vue
1
2
3
4
5
<template>
  <div class="h-screen bg-teal-50">
    <slot />
  </div>
</template>

將註冊頁面的布局套用 green

1
2
3
4
5
6
7
<script setup>
// ...

definePageMeta({
  layout: 'green'
})
</script>

在登入頁面與註冊頁面切換時,因為兩個頁面使用了不同的布局,布局的轉場效果,使背影顏色會有灰階效果的轉場。

你也可以像自訂頁面轉場一樣,來使用 definePageMeta() 設定 layoutTransition.name 屬性,來指定自訂的轉場效果。

1
2
3
4
5
6
7
8
<script setup>
definePageMeta({
  layout: 'green',
  layoutTransition: {
    name: 'slide-in'
  }
})
</script>

禁用轉場效果

頁面與布局的轉場效果,都可以透過 definePageMeta 來設定 pageTransitionlayoutTransitionfalse 來禁止套用轉場效果。

1
2
3
4
5
6
<script setup>
definePageMeta({
  pageTransition: false
  layoutTransition: false
})
</script>

全域預設的轉場效果

你也可以在 nuxt.config.ts 設置預設的頁面與轉場效果,例如:

nuxt.config.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export default defineNuxtConfig({
  pageTransition: {
    name: 'fade',
    mode: 'out-in' // default
  },
  layoutTransition: {
    name: 'slide',
    mode: 'out-in' // default
  }  
})

當然要將所有頁面與布局預設禁用也可以設置如下:

1
2
3
4
export default defineNuxtConfig({
  pageTransition: false,
  layoutTransition: false
})

pageTransitionlayoutTransition 接受的屬性可以參考 TransitionProps

元件屬性傳入 transition

app.vue 中使用 <NuxtPage /> 時,你可以將 TransitionProps 作為元件的 Props 來啟用全域預設的轉場效果。

1
2
3
4
5
6
7
8
9
<template>
  <div>
      <NuxtPage :transition="{
        name: 'bounce',
        mode: 'out-in'
      }" />
    </NuxtLayout>
  </div>
</template>

當使用此方法設定轉場效果時,就不能在頁面中使用 definePageMeta() 來覆蓋這裡的頁面轉場設置。

以上我們主要實作了導航守衛,來為特定頁面添加瀏覽的權限,我們除了使用客戶端的 User Store 驗證外,也可以搭配 Cookie 再後端進行驗證,甚至為每個請求解析使用者,並查詢資料庫是否具有權限瀏覽,以此來控制使用者瀏覽頁面的權限,除了前端的阻擋外,更重要的是後端 API 也需要搭配進行權限驗證,才能有要的防止網頁漏洞產生,否則可能會發生,使用者無權瀏覽新增或管理的頁面,但是可以透過打 API 來進行相關操作,這樣是非常危險的。最後,我們將頁面切換時設置了進度條與轉場效果,使得整體網站能提供使用者更好的操作體驗,更多的轉場設置,也可以參考 Nuxt 官方文件

範例程式碼 - Nuxt 3 - 設置導航守衛與頁面切換效果範例


添加 HTML Meta Tag

詳細 Nuxt3 添加 HTML Meta Tag 的說明,可以參考 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記 - 搜尋引擎最佳化 (SEO) 與 HTML Meta Tag,這邊我們說明為實作的部落格文章添加 Meta,調整 ./pages/articles/[id].vue 檔案,添加以下程式碼:

pages/articles/[id].vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script setup>
// ...

useHead({
  meta: [
    { name: 'description', content: `${article.value.summary} | Nuxt 3 Blog` },
    { name: 'keywords', content: `${article.value.tags}` },
    { property: 'og:title', content: `${article.value.title} | Nuxt 3 Blog` },
    { property: 'og:description', content: article.value.summary },
    { property: 'og:image', content: article.value.cover }
  ],
  title: `${article.value.title} | Nuxt 3 Blog`
})
</script>

其中 article 是我們從 Server API 回傳的指定貼文的資料並具有響應性,最後看看成果。

在 Nuxt 3 提供了一些可以設置網頁屬性與 Meta 的組合式函數,更能動態的調整與響應,結合 SSR 讓這些標記在伺服器端就能渲染出來,對網站做搜尋引擎最佳化 (SEO) 非常得友善,也是多數人選擇 Nuxt 3 來處理 SSR 與 SEO 的原因。除了 SEO 可能會使用到的的標記外,也有 OG Tag 可以加強網頁的連結預覽效果,雖然 OG Tag 與 SEO 沒有絕對的關係,但是透過伺服器端渲染調整這些 Meta 對搜尋引擎或網站都非常的方便,我們只要根據需求及指引做設定,就能對網站的能見度有所提升。


邁向國際化實作多國語系I18n

當一個網站需要面向不同國家或不同語言的使用者,我們就需要做國際化 (Internationalization),將網站內容進行翻譯或語言的對應,讓使用者能理解網頁上的操作或內容,I18n 意謂著「Internationalization」這個單字中,I 和 n 之間有 18 個字母,也正是各個框架的實作多國語系套件的一個常用名稱,例如 Vue 生態就有 vue-i18n,而這篇文章將講述如何在 Nuxt 3 中整合 Vue I18n 來協助實作多國語系。

Nuxt 3 使用 Vue I18n

想要在 Vue 3 使用 Vue I18n 獲得比較好的支援度需要使用正處於 Beta 階段的 v9 版本,因此有幾種方式可以在 Nuxt 整合 Vue I18n,分別是依照 Vue I18n 官方指引、使用 Nuxt Community提供的 @nuxtjs/i18n@next 模組及 @intlify/nuxt3 模組。

比較早以前,Nuxt 3 社群模組還沒有支援比較新的 Vue I18n,所以需要自己額外的製作插件來安裝,後來也有一些相容性的問題,所以 Vue I18n v9 的作者 kazupon 發布了 @intlify/nuxt3 模組來方便 Nuxt 3 整合 vue-i18n-next (Vue I18n v9),目前仍然可以使用 @intlify/nuxt3 來進行整合,但我最後選擇使用 @nuxtjs/i18n@next 模組,除了看好是由 Nuxt Community 提供的支援及未來的發展外,也能使用到 @nuxtjs/i18n 提供的一些組合函數等。

Nuxt 3 整合 @nuxtjs/i18n

目前 @nuxtjs/i18n 模組的穩定版本在 v7.3.0,而使用 Vue 3 和 Vue I18n v9 的模組則是下一個迭代版本 v8,所以使用 NPM 安裝的時候需要稍微注意一下。

下圖於 2020/10/10 擷取自 NPM - @nuxyjs/i18n

Step 1. 安裝 @nuxtjs/i18n

目前有 A 或 B 兩種方式可以安裝 @nuxtjs/i18n v8 分別如下,可以選擇其中一種即可:

A. 使用 next 標籤來安裝 v8 版本

1
2
3
npm install -D @nuxtjs/i18n@next
# or
# yarn add -D @nuxtjs/i18n@next

B. 安裝 @nuxtjs/i18n-edge (Edge Version)

  1. 添加 "@nuxtjs/i18n": “npm:@nuxtjs/i18n-edge”package.jsondependenciesdevDependencies 依賴中,看起來可能像這樣:
1
2
3
4
5
6
7
8
{
  // ...
  "devDependencies": {
    // ...
    "@nuxtjs/i18n": "npm:@nuxtjs/i18n-edge",
    // ...
  }
}
  1. 執行 npm install

這邊我使用的是用 next 標籤來安裝。

Step 2. 添加模組

nuxt.config.ts 中的 modules 屬性添加 '@nuxtjs/i18n',參考如下:

nuxt.config.ts
1
2
3
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n']
})

Step 3. 設定 i18n 參數

當添加完模組後,我們可以先設置 @nuxhjs/i18n 模組的選項,來測試是否能正常運作,於 nuxt.config.ts 中,添加 i18n 屬性,用以設置**@nuxhjs/i18n** 模組;i18n.vueI18n 設置的選項也可以參考 Vue I18n v9 官方文件

nuxt.config.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    vueI18n: {
      legacy: false,
      locale: 'zh',
      messages: {
        en: {
          hello: 'Hello!',
          language: 'Language'
        },
        zh: {
          hello: '你好!',
          language: '語言'
        }
      }
    }
  }
})

記得設置 i18n.vueI18n.legacyfalse 來關閉使用較舊的 API 模式。

Step 4. 確認專案下的 pages 目錄

當完成 Step 3. 的模組設置後,就可以啟動伺服器來看看效果;不過若是 Nuxt 3 專案目錄下缺少了 pages 目錄,可能會無法啟用 Vue Router 自動產生路由,而出現下圖的錯誤。

我們只需要新增 ./pages/ingex.vue 路由頁面,就可以解決錯誤囉!

建立英語和繁體中文的語系切換

我們安裝與配置好 @nuxtjs/i18n 後,可以嘗試建立頁面來看看效果,以下以 enzh 作為英語和繁體中文兩個語系的使用範例。

新增 ./pages/index.vue 內容如下:

pages/index.vue
1
2
3
4
5
6
7
<template>
  <div class="flex flex-col items-center bg-white">
    <h1 class="mt-48 text-8xl font-medium text-blue-500">
      {{ $t('hello') }}
    </h1>
  </div>
</template>

我們就可以使用 $t() 來傳入我們在 nuxt.config.ts 中的 i18n.vueI18n.messages 定義語系與對應的文字,而我們預設是 zh 繁體中文語系,畫面上就出現 hello: ‘你好!’ 對應的「你好!」文字囉!

新增按鈕來實現切換不同的語系,完整的程式碼如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template>
  <div class="flex flex-col items-center bg-white">
    <h1 class="mt-48 text-8xl font-medium text-blue-500">
      {{ $t('hello') }}
    </h1>
    <div class="mt-24 space-x-4">
      <button
        type="button"
        class="inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
        @click="setLocale('en')"
      >
        English
      </button>

      <button
        type="button"
        class="inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
        @click="setLocale('zh')"
      >
        繁體中文
      </button>
    </div>
    <div class="mt-4 flex flex-row justify-center">
      <label class="text-gray-600">{{ $t('language') }}</label>
      <span class="ml-4 font-bold text-gray-800">{{ locale }}</span>
    </div>
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
</script>

若沒有特殊需求,切換語系你可以選擇直接設定 locale 來自動響應或使用 setLocale() 來設定語系。

1
2
3
4
5
6
7
8
<script setup>
import { useI18n } from 'vue-i18n'
const { locale, setLocale } = useI18n()

locale.value = 'en'
// or
setLocale('en')
</script>

完成後我們就可以切換 enzh 語系,當然你也可以製作成選單來放置更多的語系來支援切換。

獨立存放語系檔案

隨著專案增大,語系的翻譯數量也會增多,而全部放在 nuxt.config.ts 中也實在太過冗長,所以我習慣獨立放置在一個資料夾內做處理。

稍微調整一下 nuxt.config.ts 如下:

nuxt.config.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    defaultLocale: 'zh',
    langDir: 'locales',
    locales: [
      { code: 'en', file: 'en.json', iso: 'en-US', name: 'English' },
      { code: 'zh', file: 'zh.json', iso: 'zh-TW', name: '繁體中文' }
    ],
    strategy: 'no_prefix',
    vueI18n: {
      legacy: false
    }
  }
})

接著我們就可以建立各個語系放置的資料夾對應著 i18n.langDir 屬性,目錄名稱為 locales,再依序建立 en.jsonzh.json 檔案,最終目錄的結構大概會長這樣:

1
2
3
4
5
6
7
nuxt-app/
├── ...
├── locales/
│   ├── en.json
│   └── zh.json
├── ...
└── nuxt.config.ts

./locales/en.json 內容如下:

locales/en.json
1
2
3
4
{
  "hello": "Hello!",
  "language": "Language"
}

./locales/zh.json 內容如下:

locales/zh.json
1
2
3
4
{
  "hello": "你好!",
  "language": "語言"
}

翻譯的語系檔案,也可以在根據需求使用 .jsyamljson 格式的檔案,來搭配接受參數來產生翻譯文字。

持久化語系設定

當使用者切換語系之後,我們希望儲存這個語系設定,讓使用者下次瀏覽網站時,能套用儲存的語系,而不用再次切換,我們可以藉由瀏覽器的 LocalStorageCookie 來持久化。

LocalStorage 來儲存語系設定

我們可以在變更語系時,同時將語系儲存至 LocalStorage 之中,下次瀏覽網站時,再從中取出套用語系。

例如,我們調整 ./pages/index.vue 程式碼如下,在我們自訂的 changeLanguage() 方法內,同時呼叫 localStorage.setItem('i18n-lang', localeCode) 來儲存語系。

pages/index.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
  <div class="flex flex-col items-center bg-white">
    <h1 class="mt-48 text-8xl font-medium text-blue-500">
      {{ $t('hello') }}
    </h1>
    <div class="mt-24 space-x-4">
      <button
        v-for="localeItem in locales"
        :key="localeItem.code"
        type="button"
        class="inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
        @click="changeLanguage(localeItem.code)"
      >
        {{ localeItem.name }}
      </button>
    </div>
    <div class="mt-4 flex flex-row justify-center">
      <label class="text-gray-600">{{ $t('language') }}</label>
      <span class="ml-4 font-bold text-gray-800">{{ locale }}</span>
    </div>
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n'
const { locale, locales } = useI18n()

const changeLanguage = (localeCode) => {
  locale.value = localeCode
  localStorage.setItem('i18n-lang', localeCode)
}
</script>

調整 app.vue 從 LocalStorage 讀取 i18n-lang 的設定值

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<template>
  <div>
    <NuxtPage />
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()

const defaultLocaleCode = locale.value

if (process.client) {
  locale.value = localStorage.getItem('i18n-lang') ?? defaultLocaleCode
}
</script>

當下次我們瀏覽網站時,就會查看 LocalStorage 中的語系設定並套用。

Nuxt 3 的伺服器渲染,會導致第一次的請求無法在伺服器端取得 LocalStorage 的設定,所以上面的例子,仔細看會發現,雖然正確的套用儲存的 en 英語語系設定,但畫面是先從預設的 zh 繁體中文再切換過去,導致語系切換時閃了一下。

我們可以透過使用 cookie 的方式來儲存語系,這樣伺服器端在接受第一次請求時也能解析出使用者的偏好設定,回傳正確的語系翻譯文字。

你可以自己使用 Nuxt 3 提供 useCookie 等方式操作 cookie 來實現這套流程,不過呢,@nuxtjs/i18n 模組預設是啟用 cookie 相關的設定,提供的 setLocaleCookie()getLocaleCookie() 可以來協助儲存語系設定至 cookie。

調整 ./pages/index.vue 程式碼如下,在我們自訂的 changeLanguage() 方法內,同時呼叫 setLocaleCookie(localeCode) 來儲存語系。

pages/index.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
  <div class="flex flex-col items-center bg-white">
    <h1 class="mt-48 text-8xl font-medium text-blue-500">
      {{ $t('hello') }}
    </h1>
    <div class="mt-24 space-x-4">
      <button
        v-for="localeItem in locales"
        :key="localeItem.code"
        type="button"
        class="inline-flex items-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
        @click="changeLanguage(localeItem.code)"
      >
        {{ localeItem.name }}
      </button>
    </div>
    <div class="mt-4 flex flex-row justify-center">
      <label class="text-gray-600">{{ $t('language') }}</label>
      <span class="ml-4 font-bold text-gray-800">{{ locale }}</span>
    </div>
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n'
const { locale, locales, setLocaleCookie } = useI18n()

const changeLanguage = (localeCode) => {
  locale.value = localeCode
  setLocaleCookie(localeCode)
}
</script>

調整 app.vue 呼叫 getLocaleCookie() 從 cookie 解析的設定值,並再進行套用。

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<template>
  <div>
    <NuxtPage />
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n'
const { locale, getLocaleCookie } = useI18n()

const defaultLocaleCode = locale.value

locale.value = getLocaleCookie() ?? defaultLocaleCode
</script>

當我們使用 setLocaleCookie() 儲存語系至 cookie,會將 localeCode 儲存在 cookie 的 i18n_redirected 值上,當我們重新進入網頁,cookie 也將自動夾帶了這個 cookie 過去給伺服器端,並由 getLocaleCookie() 解析再由我們進行語系套用。

其實 @nuxtjs/i18n 因為已將 detectBrowserLanguage.useCookie 預設為 true 等啟用了 cookie 儲存的相關設定,當使用者進入網站時,就會嘗試檢查瀏覽器的 cookie 來將使用者儲存的偏好語言進行套用或重定向。

因此,我們在 app.vue 也不需要特別從 cookie 取出再進行套用,@nuxtjs/i18n 會自動幫我們完成。

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<template>
  <div>
    <NuxtPage />
  </div>
</template>

<script setup>
// 除非你有特需的需求,不然這邊都不需要自己來了

// import { useI18n } from 'vue-i18n'
// const { locale, getLocaleCookie } = useI18n()

// const defaultLocaleCode = locale.value

// locale.value = getLocaleCookie() ?? defaultLocaleCode
</script>

而當使用者調整語系時,在第一個範例有提到,如果沒有特別需求,我們可以直接使用 locale 來設定語系,來自動響應變更語系,再使用 setLocaleCookie(localeCode) 將語系保存至 cookie 之內。

1
2
3
4
5
6
7
8
9
<script setup>
import { useI18n } from 'vue-i18n'
const { locale, locales, setLocaleCookie } = useI18n()

const changeLanguage = (localeCode) => {
  locale.value = localeCode
  setLocaleCookie(localeCode)
}
</script>

但我們只要使用 setLocal() 方法來套用語系,就會一同更新至 cookie 中進行儲存,非常方便。最後我們將 ./pages/index.vue 中的 調整如下,一樣可以達到在 cookie 中儲存使用者語系並在下次瀏覽網頁時自動套用。

pages/index.vue
1
2
3
4
5
6
7
8
<script setup>
import { useI18n } from 'vue-i18n'
const { locale, locales, setLocale } = useI18n()

const changeLanguage = (localeCode) => {
  setLocale(localeCode)
}
</script>

使用 setLocaleCookie()getLocaleCookie() 操作的預設設定,我們可以透過 nuxt.config.ts 中的 i18n.detectBrowserLanguage 進行調整,例如,調整 cookieKey 來變更儲存的名稱 i18n_redirected 等,更多選項可以參考這裡

nuxt.config.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    // ...
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected'
    },
    // ...
  }
})

@nuxtjs/i18n 模組提供的 cookie 操作語系持久化,甚至能做到一些重導向等行為,較 Nuxt 3 提供 useCookie 等方式自己來實現這套流程能更擁有更多功能。


範例程式碼 - Nuxt 3 - 搜尋引擎最佳化 (SEO) 之設定網頁 Meta 及 OG Tag 範例


comments powered by Disqus