BACK
Featured image of post 【Nuxt3】開箱即用的 Nuxt3 玩轉筆記

【Nuxt3】開箱即用的 Nuxt3 玩轉筆記

在使用 Vue 3 開發網站可能會面臨需要做 SEO 的情境,為了解決該情境問題,你可能會找到使用 Vite 做預渲染或其他方式,Nuxt 3 也是其中一個解決方案,且有更多強大的功能,目前 Nuxt 3 已經正式發布穩定版,就讓我們開始嘗鮮上手吧!本篇會著重在 Nuxt 3 的特色與功能學習,所以你可能需具備些 Vue 3 基礎,我將從搭建 Nuxt 3 開發環境、頁面路由與佈局一直到使用 Nuxt 3 建立 SEO 所需的資訊做些筆記及介紹,讓你有個概念並嘗試用 Nuxt 3 來做 SSR 或 SSG,進而解決 Vue 3 SPA 不好做 SEO 的問題。

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

在使用 Vue 3 開發網站可能會面臨需要做 SEO 的情境,為了解決該情境問題,你可能會找到使用 Vite 做預渲染或其他方式,Nuxt 3 也是其中一個解決方案,且有更多強大的功能,目前 Nuxt 3 已經正式發布穩定版,就讓我們開始嘗鮮上手吧!

本篇會著重在 Nuxt 3 的特色與功能學習,所以你可能需具備些 Vue 3 基礎,我將從搭建 Nuxt 3 開發環境、頁面路由與佈局一直到使用 Nuxt 3 建立 SEO 所需的資訊做些筆記及介紹,讓你有個概念並嘗試用 Nuxt 3 來做 SSR 或 SSG,進而解決 Vue 3 SPA 不好做 SEO 的問題。


Nuxt 3 介紹

前言

採用 SSR + SPA,將第一個畫面透過 SSR 產生,其他就交給 CSR 來做處理,既能兼具 SEO 還能擁有 SPA 的使用者體驗,不是很完美嗎?沒有錯,但在沒有任何框架輔助之下,同一個頁面你可能就需要實作兩次程式碼,也就是前後端各一次的渲染邏輯,這也就是相應的代價。在介紹 Nuxt 3 之前就稍微提一下 Isomorphic JavaScriptUniversal JavaScript

Isomorphic JavaScript 與 Universal JavaScript

Isomorphic JavaScript

隨著 Node.js 的出現,讓客戶端瀏覽器之外的伺服器也能執行 JavaScript 程式碼,使得 JavaScript 成為一種同構語言 (Isomorphic Language)。Isomorphic JavaScript 即稱之為同樣的 JavaScript 程式碼可以在客戶端及伺服器端運行;也就是說同一份 Code 除了能在前端瀏覽器也能在後端執行。

然而 Isomorphic JavaScriptUniversal JavaScript 的名詞存有一些爭議與差異,如今,Isomorphic JavaScript 多以稱相同的程式碼元件,可以在客戶端與伺服器端用來組裝或渲染出頁面的技術

Universal JavaScript

Michael Jackson 的文章提到,Isomorphic 這個詞的含義「corresponding or similar in form and relations」為兩個實體不相同,但具有相似的操作或對應的關係,換句話說就是,兩個看起來不一樣的使用方法或語法,但最終的執行結果是一樣的,例如 jQuery 或 Zepto 操作 DOM 的語法長的不大一樣,但是最終也都是對應 JavaScript 的 document.querySelector 等方法。

所以說,為了描述相同的程式碼但能在不同環境中運行的名詞,就有了 Universal JavaScript 一詞,這個名字告訴人們 JavaScript 它不僅可以在服務器和瀏覽器上運行,還可以在本機設備和嵌入式架構上運行

那麼 Isomorphic JavaScript 和 Universal JavaScript 與我們又有什麼關係呢?

有的,因為 Nuxt 3 正是一個 Isomorphic JavaScript 框架,Nuxt 3 是目前你使用 Vue 3 在開發網站時,會需要採用 SSR 技術或優化 SEO 問題所可以使用的解決方案。

Nuxt 3

Nuxt 3 官方在 2022/11/16 正式發布穩定版本 Nuxt 3.0 stable

Nuxt 3 官網的標語 The Intuitive Web FrameworkNuxt 3 將讓你更直覺的體驗混合渲染等新功能,讓在使用 Vue 3 開發時變得更加簡單!

Nuxt 3 的新特性

Nuxt 3 官網的提供訊息所列,簡單說明一下 Nuxt 3 的新特性,共有下列 12 個:

  • Lighter:更輕量,針對現在瀏覽器,伺服器部署與客戶端打包能減少最高 75 倍之多。
  • Faster:更快速,通過 Nitro 提供支持的動態伺服器端程式碼拆分來優化冷啟動問題。
  • Hybird:動態的靜態頁面生成和其他高級模式,現在這些都將成為可能。
  • Suspense:在導航觸發的前後,皆可以在任何元件中取得數據資料。
  • Composition API:使用 Composition API 和 Nuxt 3 的 Composables 實現真正的程式碼可重用性。
  • Nuxt CLI:全新的零依賴體驗,幫助你輕鬆建立專案與模組整合。
  • Nuxt Dev Tools:專屬開發除錯工具,提供更多的資訊與快速修復,讓工作更高效。
  • Nuxt Kit:具備基於 TypeScript 和跨版本兼容性的全新模組開發。
  • webpack 5:更快速的打包時間與打包出更小的體積,並且無需任何配置。
  • Vite:使用 Vite 作為打包工具,體驗閃電般快速的 HMR。
  • Vue 3:完全支持 Vue 3。
  • TypeScript:使用 TypeScript 與 ESM 構建而成,無需額外的配置步驟。

Nitro

Nuxt 3 由一個全新的伺服器引擎 Nitro 提供支持,它具有以下幾個特點:

  • 跨平台支持,支持 Node.js 與瀏覽器等。
  • Serverless 支持。
  • API 路由,使用 unjs/h3
  • 自動程式碼拆分 (code-splitting) 與異步加載 chunk (async-loaded chunks)。
  • 混合渲染模式,供靜態 (static)與無伺服器 (serverless) 網站。
  • 開發伺服器上的 HMR (hot module reloading)。

簡單來說,Nitro 已經是 Nuxt 3 包含的全新伺服器引擎,無需再進行配置。Nitro 除了支援 SPA、建立靜態的網站,甚至能在後端打 API 時直接調用相關函數,從而降低 API Request,整體來說是非常強大的伺服器引擎。

Nuxt 3 渲染模式

Nuxt 3 目前支援兩種渲染模式,Client-side Only RenderingUniversal Rendering。之後將會推出更先近的渲染模式,混合渲染 (Hybrid Rendering)邊緣渲染 (Edge-Side Rendering)

  • Client-side Only Rendering

Nuxt 3 設定為該模式,如同單純使用 Vue 3 建置出的 SPA,瀏覽器下載並載入完 Vue 程式碼後,渲染 HTML 的所有動作皆在客戶端執行,也就是客戶端渲染 CSR。

  • Universal Rendering

Universal Rendering 是 Nuxt 3 預設的渲染模式,在這個模式之下,無論頁面是預先產生並緩存還是動態渲染,Nuxt 都會在伺服器環境中執行 Vue 程式碼,並渲染 HTML,也就是如同 SSR,伺服器會向瀏覽器返回一個完整呈現的 HTML 頁面。當瀏覽器完整呈現的頁面出現後,也會開始載入 Vue 程式碼,以因應後續的動態頁面與路由跳轉等,即轉換為 SPA 開始由客戶端進行渲染。也就是說 Nuxt 3 的 Universal Rendering 即是指 SSR + SPA

  • 混合渲染 (Hybrid Rendering)

我們在使用 Vue 開發 SPA 網站時,通常是會設置路由 (Route) 讓網頁能夠到達不同的頁面再來請求資料,此為 CSR。在使用 Nuxt 開發時能讓能網站擁有 SSR + SPA 的 Universal Rendering 渲染方式,除此之外,Nuxt 3 還提供了一種更先進的渲染模式——混合渲染 (Hybrid Rendering),可以為每個路由設置不同的渲染與緩存的規則,讓部分頁面使用 CSR 在客戶端進行渲染,另一部分使用 SSR 在伺服器端進行渲染。

  • 邊緣渲染 (Edge-Side Rendering)

Nitro 為 Nuxt 3 提供支持的全新伺服器端渲染引擎,它為 Node.js、Deno、Worker 等提供跨平台的支持,讓 Nuxt 可以在 CDN Edge Workers 進行渲染,故稱為邊緣渲染 (Edge-Side Rendering),能有效分擔在伺服器端渲染時的資源負荷,將其提升到另一個層次,從而減少網路延遲及成本。

Nuxt 3 的建構工具

Nuxt 3 預設的建構工具如下:

Nuxt 3 已經幫我們配置好一堆設定啦,真的是開箱即用,而且 Nuxt 也支援 TypeScript。如果說真的要調整配置,也可以再 nuxt.config 中進行調整,非常方便貼心。


使用 nuxi 建立第一個專案

線上玩玩看

如果你還在觀望或是從隔壁棚過來的,還不想安裝相關工具與環境,那麼可以點擊下面其一連結,在 StackBlitza 或 CodeSandbox 的線上編輯器環境稍微玩玩,直接線上體驗 Nuxt 3。

開始建立第一個專案

先決條件

開始之前,依照 Nuxt 3 官網的起手式我們有幾個項目需要做一下檢查,我們這裡僅先注意一下 Node.js 的版本,建議使用目前最新 LTS 版本 v16.17.0 (包含 NPM v8.15.0)。

使用 nuxi 建立 Nuxt 3 專案

nuxi 全名為 Nuxt Command Line Interface,是由 Nuxt 提供開發的標準工具,Nuxt CLI 就像是 Vue CLI 可以建立 Vue 專案,我們當然也就可以使用 Nuxt CLI 來建立 Nuxt 的專案。

首先,打開終端機 (Terminal),將目錄切換至自己習慣的工作區,接著輸入如下的 nuxi 提供指令並執行,來初始化建立一個 Nuxt 3 專案。在此,我們的專案名為 nuxt-app

1
npx nuxi init nuxt-app

如下圖,npx 會詢問你是否安裝最新版本的套件 [email protected] 輸入 y 即可。

執行完指令後直到出現如下提示,代表專案已經初始建立成功,完成後可以發現目前目錄下多了一個名為 nuxt-app 的資料夾,這個資料夾也就是 Nuxt 3 專案的根目錄。

接下來,我們可以進入專案目錄 nuxt-app

1
cd nuxt-app

nuxt-app 專案目錄下可以看到熟悉的 package.json,我們就可以開始安裝 Nuxt 3 專案的相關依賴套件。

1
2
3
npm install
# or
# yarn

Nuxt CLI 如同 Vue CLI 已經在建立專案時幫我們初始完成許多事情,再安裝完相關依賴套件後輸入下列指令,我們就可以在開發環境下啟動 Nuxt 了。

1
2
3
npm run dev -- -o
# or
# yarn run dev -- -o

如果沒有意外,可以看到 Nitro 幫我們啟動了 Nuxt 3 的服務。

根據提示,我們可以在瀏覽器輸入網址 http://localhost:3000/ 看看服務是否正常運作。

如果看到如下圖的歡迎畫面,恭喜你!我們已經成功建立第一個 Nuxt 3 專案囉!

現在你可以嘗試打開在專案目錄下的 app.vue 檔案之中,app.vue 是我們 Nuxt 專案的進入點,它的內容如下:

1
2
3
4
5
<template>
  <div>
    <NuxtWelcome />
  </div>
</template>

可以發現它與 Vue 3 的單一元件檔 (Single File Component, SFC) 相比,好像少了點什麼,怎麼有用了 <NuxtWelcome /> 卻沒看見在哪邊 import 的呢?

其實呢,<NuxtWelcome /> 這個元件就是我們前面瀏覽器看到的歡迎畫面,是 Nuxt 框架自帶的一個元件,而且 Nuxt 自動導入元件的更是其特色之一,後面的系列我們會再提到自動導入 (Auto Imports)

在這裡直接移除 <NuxtWelcome /> 就可以了,我們將 app.vue 調整如下,並保存檔案。

1
2
3
4
5
<template>
  <div>
    <h1>2022 iThome 鐵人賽</h1>
  </div>
</template>

可以看見因為 Nuxt 預設 Vite 建構並支援 HMR (Hot Module Replacement),我們可以在瀏覽器快速的看見修改後的結果。

至此,大家就可以先簡單的玩玩 Nuxt 3,接下來我將補充一些 nuxi 的指令與參數簡介。

Nuxt CLI 常用指令

以下將簡單介紹一些常用的指令,若您在專案目錄下記得使用 npx 來執行 nuxi

另外在此提醒,下面所列的指令部分參數會進行省略,若有興趣翻翻完整的指令與參數說明,可以參考 Nuxt 3 官方文件

nuxi init

1
npx nuxi init|create [dir]

這個 nuxi init 指令是用來初始化一個 Nuxt 專案,等同 nuxi create 指令,dir 你可以填字串作為專案與資料夾名稱,也可以填寫完整路徑來建立專案目錄。

nuxi dev

1
npx nuxi dev [--open, -o] [--port, -p]

當我們在本地端進行開發時,需要運行開發環境,當我們執行 npm run dev -- -o 時,依據 package.json 中的 scripts 所列,其實就是在執行 nuxi dev -o,其中的 -o 表示服務啟動後開啟瀏覽器

如果啟動服務發現 Port 衝突,你也可以透過 -p 來將預設的監聽的 Port: 3000 調整為其他數值。

1
2
3
nuxi dev -p 3001
# 或
# npm run dev -- -o -p 3001

nuxi cleanup

1
npx nuxi clean|cleanup

nuxi cleanup 等同 nuxi clean 指令,用來刪除 Nuxt 自動產生的檔案和緩存包括:

  • .nuxt
  • .output
  • node_modules/.vite
  • node_modules/.cache

nuxi upgrade

1
npx nuxi upgrade [--force|-f]

這個指令可以用來將目前專案的 Nuxt 3 升級至最新的版本,如果有一些可能行為調整或不相容的情況,可以再依據實際情境搭配 -f 參數來強制更新。

如下圖,只要一行指令 npx nuxi upgrade 就可以將專案版本由 3.0.0-rc.9 升級至 3.0.0-rc.10,在 RC 階段或之後想定期升級版本來說非常好用,但也請記得升級前可以先看一下官方的 Changelog


Nuxt 3 + TypeScript + ESLint + Prettier 環境建置

在我們使用完 Nuxt CLI 建立完專案後,其實就可以開始進行專案的開發,但是呢,相信不少人對於程式碼的排版都有自己的風格,不同人的 Coding Style 肯定也都不一樣,然而在團隊協作需要標準或為了整體一致且美觀下,Linter 就是你的好幫手,此外,TypeScript 在 Nuxt 3 已經有內建支援,我也建議及推薦使用 TypeScript,接下來分享我自己在使用 Nuxt 3 開發時的 Linter 環境配置,包含了 TypeScriptESLintPrettier

TypeScript 類型檢查

Nuxt 3 已經有內建支援 TypeScript,一些 TypeScript 設定都可以在專案根目錄下配置 tsconfig.json,例如,在開發過程中針對 TypeScript 我會在開發環境下啟動類型檢查,可以參考以下進行配置。

Step 1. 安裝 VS Code 插件

首先,推薦大家安裝下列兩個 VS Code 插件:

Step 2. 安裝 Vue 類型檢查套件

1
2
3
npm install -D vue-tsc typescript
# or
# yarn add -D vue-tsc typescript

Step 3. 調整 nuxt.config.ts 設定

根據 nuxt3 官方文件說明,我們可以在 nuxt.config.ts 中,設置 typescript.typeCheck: true 來讓開發時期能執行類型檢查。

1
2
3
4
5
export default defineNuxtConfig({
  typescript: {
    typeCheck: true
  }
})

Step 4. 重新啟動開發環境服務

我們重新執行 npm run dev -- -o 來重啟開發環境的服務,這樣就配置完成囉!

看看效果

如果我們在 app.vue 寫了以下程式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div>
    <NuxtWelcome />
  </div>
</template>

<script lang="ts" setup>
const year: number = '2022'
const title: string = `${year} iThome 鐵人賽`
</script>

可以發現終端機 (Terminal) 就指出了一個錯誤的類型指派,並告知你有錯誤程式碼的位置。

ESLint 設置 Linter

ESLint 是一個 JavaScript Linter,它用來檢查 JavaScript Coding Style 的工具,主要能幫你分析並找到語法錯誤,也能用來統一風格,例如:縮排空白數、字串要用單引號或雙引號等,提醒你刪掉多餘程式碼或遵照最佳的實踐方式,確保程式碼能具有一定的水準。在團隊協作下 ESLint 更能讓大家撰寫程式碼時遵照規則,確保程式碼品質。ESLint 除了提供你可以使用大公司如 Google、Airbnb 等的規則配置來作為檢查基準,也可以客製自訂出自己喜好或團隊共識的規則來分析與提醒你校正語法。

Step 1. 安裝 ESLint 套件

現在我們至 Nuxt 專根目錄下,開始安裝 ESLint 相關套件,當然,你也可以挑自己喜歡的進行配置,不過在這裡選擇以 Nuxt 3 官方提供的 ESLint 設定來做標準配置,並添加支援 Vue 3 的 ESLint 設定。

1
2
3
npm install -D eslint @nuxtjs/eslint-config-typescript eslint-plugin-vue
# or
# yarn add -D eslint @nuxtjs/eslint-config-typescript eslint-plugin-vue

Step 2. 配置 ESLint 設定檔

安裝完所需套件後,接下來我們就可以來設定 ESLint,我們在專案根目錄下建立 .eslintrc.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
module.exports = {
  env: {
    browser: true,
    es2021: true
  },
  extends: ['@nuxtjs/eslint-config-typescript', 'plugin:vue/vue3-recommended'],
  parserOptions: {
    ecmaVersion: 13,
    sourceType: 'module'
  },
  plugins: [],
  rules: {},
  overrides: [
    {
      files: [
        '**/pages/**/*.{js,ts,vue}',
        '**/layouts/**/*.{js,ts,vue}',
        '**/app.{js,ts,vue}',
        '**/error.{js,ts,vue}'
      ],
      rules: {
        'vue/multi-word-component-names': 'off'
      }
    }
  ]
}

在我們建立的 .eslintrc.js 檔案中 extends 可以是一個陣列,主要是來放置擴展 ESLint 的規則的配置,這裡稍微注意一下順序如下:

  1. @nuxtjs/eslint-config-typescript:對應 @nuxtjs/eslint-config-typescript 套件,由 Nuxt 官方提供用於 Nuxt 的 ESLint 規則配置。如果你不是使用 TypeScript 可以使用 @nuxtjs/eslint-config

  2. plugin:vue/vue3-recommended:對應 eslint-plugin-vue 套件,由 Vue.js 官方提供的 ESLint 插件,包含了能配合 Vue SFC 語法及特性的規則,也可以參考官方文件,依據需求稍作調整。

將特定目錄下的檔案關閉 vue/multi-word-component-names 規則,以此來因應 nuxt 的開發避免提示錯誤。

因為目前 @nuxtjs/eslint-config-typescript 是基於 @nuxtjs/eslint-config 來擴展 TypeScript 的設定,而且 @nuxtjs/eslint-config 包含的是 Vue 2 的 ESLint 設定,所以我們需要再額外安裝 eslint-plugin-vue 來擴展 Vue 3 規則配置,並覆寫 vue/multi-word-component-names 規則。

用 ESLint 來嘗試檢查

首先,我們編輯 app.vue 檔案內容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div>
    <NuxtWelcome />
  </div>
</template>

<script lang="ts" setup>
const year: number = 2022
const title: string = `${year} iThome 鐵人賽`
</script>

接著我們執行下列指令來使用 eslint 檢查 app.vue 這個檔案。

1
npx eslint -- app.vue

可以看到下圖,ESLint 指出 app.vue 的問題,在第 9 行中的 title 這個變數已經宣告了卻沒有被使用。

這個錯誤訊息正是依據上面我們的 ESLint 配置所被檢視出來的,在 ESLint 規則定義好後,也有分成錯誤 (Error) 與警告 (Warning),我們可以再依據 ESLint 給予的提示進行調整與修正。

我們嘗試修正一下 app.vue 這個檔案,在 template 中使用 title 這個變數。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div>
    <h1>{{ title }}</h1>
  </div>
</template>

<script lang="ts" setup>
const year: number = 2022
const title: string = `${year} iThome 鐵人賽`
</script>

再一次執行 npx eslint -- app.vue,就會發現沒有錯誤訊息產生囉!

在 package.json 設置腳本

我們除了逐個檔案做檢查,也可以設置腳本來檢查整個專案目錄下的程式碼。

package.json 的 scripts 腳本中,我們可以新增一個指令 "lint": "eslint --ext .ts,.js,.vue .",這樣一來我們就可以使用如下指令來檢查專案目錄下的所有包含 .ts.js.vue 副檔名的檔案。

1
2
3
npm run lint
# or
# yarn run lint

在 VS Code 中顯示 ESLint 錯誤或警告

推薦大家安裝 VS Code 的 ESLint 插件:

  • ESLint:讓你在編輯器開發時就能有指令檢查,更可以做到全域或特定工作區開啟儲存或快捷鍵自動修正程式碼等設置。

安裝完畢後,建議重啟 VS Code 來重新載入相關設定。

現在,你會發現在 VS Code 中編輯程式的過程中,如果有 ESLint 檢查到的錯誤或建議,就會出現紅色或黃色的波浪底線,當滑鼠指標移動至波浪底線處,就會發現有個小視窗提示你錯誤或警告的原因是什麼。

在 VS Code 中自動修正 ESLint 錯誤或警告

在某些情況,ESLint 可以幫助你做自動修正程式碼,甚至在存擋時將錯誤部分直接進行修正。

手動快速修正

我們可以在編輯器將滑鼠指標移動至波浪底線點擊「快速修正 (Quick Fix)…」,你也可以使用編輯器建議的快捷鍵 Command + . (macOS),此時就能選擇要修復或關閉略過錯誤原因;這裡我們選擇「Fix all auto-fixable problems」,來修復所有可能可以被自動修復的問題。

存擋自動修正

除了手動快速修正外,你也可以透過添加 VS Code 的設定檔,如專案目錄下新增 .vscode/settings.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "editor.formatOnSave": true,
  "prettier.semi": false,
  "files.trimTrailingWhitespace": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "eslint.format.enable": true,
  "eslint.validate": ["javascript", "javascriptreact", "html", "vue", "typescript", "typescriptreact"],
  "eslint.enable": false
}

當我們編輯完程式碼,並保存檔案或快捷鍵存擋 Command + S (macOS),就會觸發 ESLint 自動修復囉。

Prettier 設置

Prettier 是程式碼格式化的工具,也可以與 ESLint 進行搭配,ESLint 與 Prettier 就能各司其職將 JaveScript 與 Vue 等檔案依照配置進行檢查與排版。

Step 1. 安裝 Prettier 套件

我們直接安裝 prettier、eslint-config-prettier 與 eslint-plugin-prettier 三個套件。

1
2
3
npm install -D prettier eslint-config-prettier eslint-plugin-prettier
# or
# yarn add -D prettier eslint-config-prettier eslint-plugin-prettier

Step 2. 配置 Prettier 設定檔案

我們在專案根目錄下建立 .prettierrc.js 檔案,內容如下,這些配置都可以自己設定來配合 ESLint,更多選項或說明可以參考 Prettier 官方說明文件

1
2
3
4
5
6
7
8
module.exports = {
  printWidth: 120, // 每行文字數量達 100 字元就換到新的一行
  semi: false, // 每個語句的結尾不需要分號
  singleQuote: false, // 字串使用單引號,而不是雙引號
  trailingComma: "none", // 如 Object、Array 內的元素不需要尾隨逗號
  endOfLine: "auto",
  vueIndentScriptAndStyle: true
}

Step 3. 配置 ESLint 設定檔案

我們在安裝時多裝了兩個 ESLint 相關套件,分別為 eslint-config-prettiereslint-plugin-prettier

對此我們需要將其添加至 ESLint 的 .eslintrc.js 設定檔內,在 extends 添加字串 prettier 表示使用 eslint-config-prettier 擴充配置,主要用來防止 Prettier 排版與 ESLint 發生衝突,讓其可以用來禁用 ESLint 的格式化;接著在 plugins 中添加 prettier 字串表示使用 eslint-plugin-prettier 套件擴充,讓 ESLint 可以提示我們格式有錯誤的地方。

為了讓 Prettier 與 ESLint 有更好的搭配,在 rules 的參數中記得添加 'prettier/prettier': 'error' 讓 ESLint 可以提示 Prettier 的排版異常提示供我們做修正,至此 .eslintrc.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
module.exports = {
  env: {
    browser: true,
    es2021: true
  },
  extends: [
    '@nuxtjs/eslint-config-typescript',
    'plugin:vue/vue3-recommended',
    'prettier'
  ],
  parserOptions: {
    ecmaVersion: 13,
    sourceType: 'module'
  },
  plugins: ['prettier'],
  rules: {
    'prettier/prettier': 'error'
  },
  overrides: [
    {
      files: [
        '**/pages/**/*.{js,ts,vue}',
        '**/layouts/**/*.{js,ts,vue}',
        '**/app.{js,ts,vue}',
        '**/error.{js,ts,vue}'
      ],
      rules: {
        'vue/multi-word-component-names': 'off'
      }
    }
  ]
}

Step 4. 安裝 VS Code 的 Prettier 插件

  • Prettier - Code formatter:提供我們做程式碼的格式化,最重要的是來協助我們自動載入 .prettierrc.js 配置。

安裝完畢後,建議重啟 VS Code 來重新載入相關設定。

我們完成了 TypeScript 與 Linter 的配置,雖然配置有些繁瑣,但寫程式的風格有個標準及規則依循,肯定能協助你寫出令人讚嘆的完美的程式碼。


使用 Tailwind CSS

為了後續範例程式在頁面呈現上能有比較好看的樣式,原先有想過使用 UI 框架或純寫 CSS,但是總覺得好像不夠潮,所以呢,我決定帶入近年滿熱門的 CSS 框架 Tailwind CSS,在本篇文章我就不再多花篇幅介紹 Tailwind 的語法及指令等,主要針對 Nuxt 3 如何導入 Tailwind CSS 至專案內使用。

其實有稍微糾結一下要不要使用 Master CSS,雖然最後決定先緩緩,但我認為它非常有淺力成為這類框架的霸主,大家有空也可以支持一下或玩玩這個由臺灣團隊開發的 CSS 框架哦!

Tailwind CSS 是近年滿熱門的 Utility-First CSS 框架,曾掀起一番論戰說 Class 不是讓你這樣用的之類的批評及缺點,但如果你深入暸解它後,確實不可否認它能為你帶來更好的生產效率等諸多優點。

接下來,我會分別介紹兩種導入 Tailwind CSS 方式,分別為 Nuxt Community 釋出的 Tailwind CSS 整合模組Tailwind CSS 官方指引步驟,以下的 AB 兩種導入方式,大家只要擇一就可以了。

使用 Nuxt Tailwind 模組

Step 1. 安裝相關套件

首先安裝 @nuxtjs/tailwindcss

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

Step 2. 添加模組至 nuxt.config.ts

打開 ./nuxt.config.ts@nuxtjs/tailwindcss 模組添加至 modules 設定參數中,完成後看起來會像下面這樣。

1
2
3
4
5
6
7
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
  modules: ['@nuxtjs/tailwindcss'],
  typescript: {
    typeCheck: true
  }
})

Step 3. 重啟 Nuxt 服務

重新啟動我們的 Nuxt 服務。

1
2
3
npm run dev -- -o
# or
# yarn run dev -- -o

使用 @nuxtjs/tailwindcss 只需要兩個步驟就完成了 Tailwind CSS 的配置,這個模組已經包含手動安裝時需要建立的 Tailwind CSS 指令 tailwindcss.css 需要的全域設定與 PostCSS 參數選項,同時也預設了 tailwind.config.js 的相關 content 目錄路徑,詳細可以參考 Nuxt Tailwind

擴充或覆寫 @nuxtjs/tailwindcss 配置

如果你沒有特別需要擴充或覆寫設定可以跳過這一段。

專案內若使用 @nuxtjs/tailwindcss 模組,這兩個 tailwind.csstailwind.config.js 檔案其實模組已經有預設,不需要手動建立,tailwind.css 對應模組內的 tailwind.css 可以參考專案檔案內容 tailwind.csstailwind.config.js 則是透過模組動態新增配置。

倘若想要修改也可以透過建立設定檔案來新增或覆寫預設定,例如在專案下分別建立 tailwind.csstailwind.config.js 兩個檔案:

  • tailwind.css

專案目錄下若存在路徑檔名一致的 ./assets/css/tailwind.css 檔案,@nuxtjs/tailwindcss 就會以這個檔案取代模組內預設的 tailwind.css 檔案。

tailwind.css 預設內容如下:

1
2
3
@tailwind base;
@tailwind components;
@tailwind utilities;

如果有成功覆蓋配置,在重啟 Nuxt 3 服務時,Terminal 會提示下列字串,表示使用了自訂的 tailwind.css

1
Using Tailwind CSS from ~/assets/css/tailwind.css
  • tailwind.config.js

專案目錄下若存在 tailwind.config.js 檔案就會以新的配置拓展或覆寫 @nuxtjs/tailwindcss 預設的 tailwind.config

例如,我們想拓展或覆寫 content 的配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './components/**/*.{vue,js,ts}',
    './layouts/**/*.vue',
    './pages/**/*.vue',
    './composables/**/*.{js,ts}',
    './plugins/**/*.{js,ts}',
    './app.{js,ts,vue}'
  ],
  theme: {
    extend: {}
  },
  plugins: []
}

Tailwind CSS 官方指引步驟

如果你已經使用了 @nuxtjs/tailwindcss 進行配置,則可以跳過這一段。

Step 1. 安裝相關套件

首先進入我們的 Nuxt 專案目錄,並安裝 Tailwind CSS 會使用到的一些相關套件。

1
2
3
npm install -D tailwindcss postcss@latest postcss-custom-properties@latest autoprefixer@latest
# or
# yarn add -D tailwindcss postcss@latest postcss-custom-properties@latest autoprefixer@latest

Step 2. 建立 tailwind.config.js

安裝完套件後,可以執行下列指令進行初始化,完成後會在專案根目錄下產生一個名為 tailwind.config.js 的 Tailwind CSS 設定檔。

1
npx tailwindcss init

Step 3. 調整 tailwind.config.js

打開剛剛產生的設定檔 tailwind.config.js,在配置中的 content 陣列添加一些路徑,這些路徑皆是跟 Nuxt 有關的資料夾目錄與檔案,完成後檔案內容如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './components/**/*.{vue,js,ts}',
    './layouts/**/*.vue',
    './pages/**/*.vue',
    './composables/**/*.{js,ts}',
    './plugins/**/*.{js,ts}',
    './app.{js,ts,vue}'
  ],
  theme: {
    extend: {}
  },
  plugins: []
}

Step 4. 建立 tailwind.css

接著我們在專案建立目錄 assets 與子目錄 css 用來放置 Tailwind CSS 的自定義指令,我們將其放置在 tailwind.css 供後續做使用,tailwind.css 的路徑應會是 ./assets/css/tailwind.css 檔案內容如下。

1
2
3
@tailwind base;
@tailwind components;
@tailwind utilities;

Step 5. 配置全域共用 CSS

上面我們建立好 tailwind.css 後,為了讓每個頁面都可以吃得到 Tailwind CSS,我們修改專案根目錄的 nuxt.config.ts 檔案,在 css 參數陣列內新增 tailwind.css 路徑,讓 Nuxt 可以配置全域共用的 CSS,並添加 postcss 選項及我們剛才安裝的套件作為插件,最後 nuxt.config.ts 檔案看起來如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
  postcss: {
    plugins: {
      'postcss-import': {},
      'tailwindcss/nesting': {},
      tailwindcss: {},
      autoprefixer: {}
    }
  },
  css: ['@/assets/css/tailwind.css'],
  typescript: {
    typeCheck: true
  }
})

至此,我們專案目錄檔案如下結構。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
nuxt-app
├── .nuxt/
├── assets/
│   └── css/
│       └── tailwind.css      // 手動新增的檔案,用於設置 Tailwind CSS 指令並讓全部頁面引用
├── node_modules/
├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── app.vue
├── nuxt.config.ts
├── package-lock.json
├── package.json
├── README.md
├── tailwind.config.js       // Tailwind 初始化指令產生的設定檔
└── tsconfig.json

Step 6. 重啟 Nuxt 服務

重新啟動我們的 Nuxt 服務。

1
npm run dev -- -o

開始感受 Tailwind CSS 的魅力

當我們配置完 Tailwind CSS 並重啟 Nuxt 服務後,我們編輯 app.vue 檔案內容,撰寫以下程式碼:

1
2
3
4
5
6
7
8
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-blue-600">2022 iThome</h1>
      <p class="mt-4 text-9xl font-bold text-gray-900">鐵人賽</p>
    </div>
  </div>
</template>

重新瀏覽一次網頁,可以發現排版、顏色、字型都有出現我們設定的效果了。

自動排序 Tailwind CSS 的 Class

當我們在寫 Tailwind CSS 時終究會碰到類型過多,第一眼不確定是否包含某個 class,或者對於順序上有強烈統一的要求,那麼這個 Prettier 的插件prettier-plugin-tailwindcss 插件是 Tailwind 推出的,目的在於可以使用官方推薦的 class 順序,來實現自動排序 class 來做到統一程式碼風格。

Tailwind x Prettier

Step 1. 安裝插件

1
2
3
npm install -D prettier-plugin-tailwindcss
# or
# yarn add -D prettier-plugin-tailwindcss

如果你不是照著前面的步驟建置專案,請記得確認已經有安裝 prettier 套件。

Step 2. 配置設定檔

開啟 .prettierrc.js 檔案,添加 'prettier-plugin-tailwindcss'plugins 陣列中:

1
2
3
4
5
6
7
8
9
module.exports = {
  plugins: [
    'prettier-plugin-tailwindcss'
  ],
  printWidth: 100,          // 每行文字數量達 100 字元就換到新的一行
  semi: false,              // 每個語句的結尾不需要分號
  singleQuote: false,        // 字串使用單引號,而不是雙引號
  trailingComma: 'none'     // 如 Object、Array 內的元素不需要尾隨逗號
}

Step 3. 自動修正效果

你可以在編輯器內移至錯誤波浪底線使用「快速修復 (Quick Fix)…」功能,或設置 "editor.codeActionsOnSave""source.fixAll.eslint": true 來達到保存後自動修正 prettier 引發的 ESLint 錯誤。

小結

相較於 Tailwind CSS 官方指引的安裝方式,使用 Nuxt Community 幫我們整理好的 @nuxtjs/tailwindcss 模組,可以省去繁瑣的設定步驟,也解決了一些目前 Nuxt 3 專案中,導入 Tailwind CSS 使用 HMR 可能無法自動編譯並重新套用等問題。


使用 Pug

pug 為 html 模板語言,可支援撰寫 JS 邏輯。寫法上,省略了 HTML 的開、閉合標籤,能夠大大的提升工程師開發速度,看上去也更為簡潔。

在本篇文章我依舊不再多花篇幅介紹 pug 的語法,主要針對 Nuxt 3 如何導入 pug 至專案內使用。

為了之後開發能少打一點 Code,這邊加入 pug 到專案。

安裝 @vite-plugin-pug

1
2
3
npm install -D vite-plugin-pug
# or
# yarn add -D vite-plugin-pug

直接開始體驗 Pug

直接將原先的 app.vue 改用 pug 語法來撰寫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template lang="pug">
div
  .bg-white.py-24
  .flex-col.items-center.flex
    h1.text-6xl.font-semibold.text-blue-600 2022 iThome
    p.mt-4.text-9xl.font-bold.text-gray-900 鐵人賽
</template>

<script lang="ts" setup>
const year: string = "2022"
</script>

這邊推薦一款 VS Code 的套件 - html2pug,將 HTML 反白後點選 VS Code 上方的 View > Command Palette…,尋找並執行 "> html2pug: Transform",即可將反白的 HTML 自動轉換成 pug 語法。

不用管 HTML 的結尾符號,寫起來整個就是舒服阿~~~


目錄結構與自動載入 (Auto Imports)

隨著整個專案的開發,目錄與元件勢必也會越來越多,如同 Vue 3 的專案可能會有專門放置元件目錄 components 等,在 Vue 3 我們可能不太需要去在意目錄的名稱,但在 Nuxt 3 的專案下,有一些目錄與名稱其實有一些基本的規定與名稱,只要照著 Nuxt 3 約定好的方式進行開發,就能更好利用 Nuxt 3 幫你完成許多貼心的功能。

Nuxt 3 的目錄結構

當我們 Nuxt CLI 建立第一個 Nuxt 3 專案並第一次啟動開發伺服器後,專案目錄結構會長得像這樣:

如果你是跟著這個系列安裝了 TypeScript、Linter、Tailwind CSS 及 Pug 等套件,那麼專案目錄結構會長得像這樣:

雖然我們能依照需求建立檔案及目錄,但 Nuxt 3 在目錄的結構與命名其實有一定的規則與模式,以下針對 Nuxt 3 來講述一下目錄的結構與遵循的方式。

Nuxt 3 預設的目錄結構

Nuxt 3 框架希望我們可以專注在開發而不是在配置,所以在預設的情況專案的目錄架構已經有一個不錯的結構可以遵循。

依據官方文件,一個完整的 Nuxt 3 專案,它的目錄結構如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
nuxt-app/
├── .nuxt/
├── .output/
├── assets/
├── components/
├── composables/
├── content/
├── layouts/
├── middleware/
├── node_modules/
├── pages/
├── plugins/
├── public/
└── server/
    ├── api/
    ├── routes/
    └── middleware/
├── .gitignore
├── .nuxtignore
├── app.config.ts
├── app.vue
├── nuxt.config.ts
├── package.json
└── tsconfig.json

.nuxt 目錄

開發環境下由 Nuxt 產生出 Vue 的網站,.nuxt 目錄是自動產生的,你不應該任意的調整裡面檔案

.output 目錄

當你的網站準備部署至正式環境時,每次編譯建構專案時,皆會自動重新產生這個目錄,你不應該任意的調整裡面檔案

assets 目錄

顧名思義,這是靜態資源檔案所放置的位置,目錄內通常包含以下類型的檔案:

  • CSS 樣式檔案 (CSS、SASS 等…)
  • 字型
  • 圖片

這些靜態資源,最終在專案編譯建構時,由 Vite 或 webpack 進行編譯打包。

components 目錄

放置 Vue 元件的地方,Nuxt 會自動載入這個目錄中的任何元件。

composables 目錄

組合式函數放置的目錄,簡單來說可以把常用或通用的功能寫成一個共用的函數或 JS 檔案,放置在這個目錄視為組合式函數,Nuxt 也會自動載入這些組合式函數,讓需要使用的頁面或元件可以直接做使用。

content 目錄

透過使用 Nuxt Content,我們可以在這個目錄下建立 .md.yml.csv.json 檔案,Nuxt Content 會讀取並解析這些文件並進行渲染,用來建立基於文件的內容管理系統(Content Management System,簡稱:CMS)。

layouts 目錄

用於放置通用或可能重複使用到的佈局模板,提供程式碼的可重複使用性。

middleware 目錄

Nuxt 3 提供了路由中間件的概念,用於導航到下一個頁面之前執行一些程式碼如權限驗證…等。

node_modules 目錄

通常有使用 Node.js 的套件管理,例如 NPM,對此目錄應該有一些印象,使用 Nuxt 3 及專案所需要的相依套件都會存放在這個目錄。

pages 目錄

這個目錄主要是用來配置我們的頁面,你也可以只使用 app.vue 來完成你的網站,但如果建立了 pages 這個目錄,Nuxt 3 會自動整合 vue-router,並會依據目錄及檔案結構規則來自動產生出對應路由,也是 Nuxt3 產生路由的方式。

plugins 目錄

Nuxt 會自動載入這個目錄檔案,作為插件使用,在檔案名稱可以使用後綴 .server.client,例如,plugin.server.tsplugin.client.ts 來決定只讓伺服器端或客戶端載入這個插件。

server 目錄

用於建立任何後端的邏輯如後端 API,這個目錄下還包含了 apiservermiddleware 來區分功能,不具有自動載入,但支援 HMR。

.gitignore 檔案

在使用 Git 版本控制時,可以設置一些不需要或忽略關注變動的檔案及目錄。

.nuxtignore 檔案

可以設置讓 Nuxt 編譯建構時,忽略一些不需要或欲忽略的檔案。

app.config.ts 檔案

提供服務運行時暴露給客戶端使用的設定,因此,請不要在 app.config.ts 檔案中添加任何機密資訊。

app.vue 檔案

Nuxt 3 網站的入口點元件。

nuxt.config.ts 檔案

用於配置 Nuxt 專案的設定檔。

package.json 檔案

這個檔案裡面定義了專案資訊、腳本、相依套件及版本號,通常有使用 Node.js 套件管理工具建置的專案都會包含此檔案。

tsconfig.json 檔案

Nuxt 3 會在 .nuxt 目錄下自動產生一個 tsconfig.json 檔案,其中已經包含了一些解析別名等預設配置;你可以透過專案目錄下的 tsconfig.json 來配置擴展或覆蓋 Nuxt 3 預設的 TypeScript 設定檔。

自訂目錄名稱

前面簡介了 Nuxt 3 的預設目錄結構與用途,在目錄名稱上 Nuxt 3 也提供了可以調整目錄名稱的方式,只要在 nuxt.config.ts 修改對應的參數,就可以自訂目錄的名稱。不過呢,也並不是所有的目錄都能修改,目前官方提供的 dir 參數共有以下四個目錄參數選項供修改:

  • layouts
  • middleware
  • pages
  • public

例如我想將 pages 目錄名稱調整為 views 就可以新增下列 dir 設定添加至 nuxt.config.ts,就可以將 pages 目錄功能及規則調整為 views 目錄來實現。

1
2
3
4
5
export default defineNuxtConfig({
  dir: {
    pages: 'views',
  }
}

自動載入 (Auto Imports)

在介紹目錄結構時有提到,某些目錄下的檔案是具有自動載入 (Auto Imports)的功能,意思就是說,當我們在這些特定的目錄 componentscomposableslayoutsplugins 添加檔案時,Nuxt 3 會自動載入這些元件或函數。

Nuxt 3 的自動載入具體有以下三種:

  • Nuxt 常用元件與函數
  • Vue 3 的 API
  • 基於目錄的自動載入

Nuxt 常用元件與函數的自動載入

Nuxt 會自動載入一些元件或組合式函數,用以讓開發時可以在全部頁面或定義組件和插件可以使用。

Nuxt 自動載入的元件就好比 app.vue 程式碼內,一開始在 template 就有的 <NuxtWelcome /> 歡迎頁面元件,其他還包含了 <NuxtPage><NuxtLayout><NuxtLink> 等,詳細可以參考官方文件

例如,下面程式碼中的 useAsyncData$fetch 就是 Nuxt 自動載入的組合式函數,在各個頁面或元件都能做使用。

1
2
3
<script setup>
const { data, refresh, pending } = await useAsyncData('/api/hello', () => $fetch('/api/hello'))
</script>

Vue 3 API 的自動載入

例如,Vue 3 中會使用到的 refcomputed 等這類的 helpers 或 lifecycle hooks,在 Nuxt 3 也都將會自動的載入,不需要在 import。

1
2
3
4
5
<script setup>
// 不需要在 import ref 或 computed
const count = ref(1)
const double = computed(() => count.value * 2)
</script>

基於目錄的自動載入

如前面所提及的,Nuxt 會自動載入定義在特定目錄的檔案,例如:

  • components: 相對於 Vue 的組件。
  • composables: 相對於 Vue 的組合式函數。

建立一個自動導入的元件

我們建立一個 ./components/IronManWelcome.vue 檔案:

components/IronManWelcome.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
      <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
    </div>
  </div>
</template>

app.vue 檔案中,新增 <IronManWelcome /> 元件。

app.vue
1
2
3
4
5
<template>
  <div>
    <IronManWelcome />
  </div>
</template>

現在,瀏覽器可以看見我們添加的元件效果。

可以發現,我們不需要添加 import IronManWelcome from './components/IronManWelcome' 就可以直接在 template 直接使用 <IronManWelcome /> 元件,這就是 Nuxt 3 基於目錄的自動載入功能。

關閉自動載入

如果想關閉 Nuxt 的自動載入元件或函數的功能,可以修改專案目錄下的 nuxt.config.ts 檔案,將 imports.autoImport 設定為 false

nuxt.config.ts
1
2
3
4
5
export default defineNuxtConfig({
  imports: {
    autoImport: false
  }
})

顯式載入 (Explicit Imports)

Explicit (顯式、明確的),當我們需要手動載入,就可以用 #import 這個 Nuxt 釋出的別名,來個別載入那些具有自動載入的元件或函數。

1
2
3
4
5
6
<script setup>
import { ref, computed } from '#imports'

const count = ref(1)
const double = computed(() => count.value * 2)
</script>

Nuxt 3 的專案目錄與結構已經有一個規定可以遵守,Nuxt 3 規劃的目錄及檔案架構,讓我們可以不用再煩惱該如何配置,只需要專注開發,當你熟悉這些目錄檔案規則與自動載入的特性,肯定能更快上手 Nuxt 3。


頁面 (Pages) 與路由 (Routing)

在 Vue 中,我們會使用到 Vue Router 來實現切換頁面或路由的需求,而在 Nuxt 3 中,預設是沒有使用路由相關套件,直至建立了 pages 目錄,Nuxt 將會自動載入 Vue Router 來管理路由,並且具有一定的規則需要遵循,以下將介紹頁面目錄與路由之間的關係。

基於檔案的路由 (File-based Routing)

在 Nuxt 3 專案的 pages 目錄下,當我們建立了一個頁面檔案,就會以該檔案建立出相對應的路由,Nuxt 3 基於檔案的路由,也使用了程式碼拆分將每個頁面需要的程式碼梳理出來,並以動態載入的方式載入最小所需要的程式碼。因為是以目錄結構檔案命名方式來約定,也稱之為約定式路由。

建立第一個頁面

Nuxt 3 的 pages 目錄,是用來建立頁面並放置的目錄,當專案下有存在 pages 目錄,Nuxt 將會自動載入 Vue Router 來實現路由效果,目錄下的檔案通常是 Vue 的元件,也允許具有 .vue.js.jsx.ts.tsx 副檔名的檔案。

當我們建立 ./pages/index.vue,檔案內容如下,則表示路由 / 對應到這個頁面檔案,我們只需要建立檔案,路由的配置將會由 Nuxt 3 自動產生。

pages/index.vue
1
2
3
4
5
6
7
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-gray-800">這裡是首頁</h1>
    </div>
  </div>
</template>

若你還記得 Vue Router 中的 <router-view />,這是路由需要的進入點,同樣的在 Nuxt 3 我們需要使用 <NuxtPage /> 來顯示我們建立的路由頁面,這裡非常重要,否則路由及頁面將無法正確運作

修改 ./app.vue,檔案內容如下:

app.vue
1
2
3
4
5
<template>
  <div>
    <NuxtPage />
  </div>
</template>

接著我們在瀏覽器瀏覽 / 路由,如 http://localhost:3000/,就可以看到我們在 ./pages/index.vue 頁面內寫的標題文字「這是首頁」囉!

多個路由頁面

在實務上,通常一個網站會有多個頁面,並分別對應到不同的路由,接下來我們嘗試建立 About 與 Contact 兩個頁面。

建立多個路由頁面

建立 ./pages/about.vue,內容如下:

pages/about.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-yellow-400">大家好我是 Ryan</h1>
      <p class="my-8 text-3xl text-gray-600">這裡是 /about</p>
    </div>
  </div>
</template>

建立 ./pages/contact.vue,內容如下:

pages/contact.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-rose-400">如果沒事不要找我 xDDD</h1>
      <p class="my-8 text-3xl text-gray-600">這裡是 /contact</p>
    </div>
  </div>
</template>

接著我們在瀏覽器分別瀏覽 /about/contact,就可以看到我們路由效果囉!

可以發現所建立的檔案名稱,最終就會自動對應產生/about/contact 路由。

自動產生的路由

如果你有興趣想看看 Nuxt 自動產生出來的路由配置長什麼樣子,可以使用 npm run buildnpx nuxt build 來建構出 .output 目錄,並打開 .output/server/chunks/app/server.mjs,搜尋 const _routes = 或剛剛建立的檔案名稱 about.vue,就可以找到下面這一段程式碼:

這段程式碼與 Vue 中的路由配置非常相像,其實這就是 Nuxt 3 檢測到 pages 目錄,自動幫我們載入 Vue Router 與依據 pages 目錄下的檔案結構,自動產生出所需的路由配置。

建立路由連結

在 Vue Router 我們可以使用 <router-link> 來建立路由連結,以此來導航至其他頁面,而在 Nuxt 3 的路由中,則是使用 <NuxtLink> 來建立路由連結來進行頁面的跳轉,我們嘗試在首頁新增幾個路由連結來進行頁面導航。

調整 ./pages/index.vue,內容如下:

pages/index.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-gray-800">這裡是首頁</h1>
      <div class="my-4 flex space-x-4">
        <NuxtLink to="/about">前往 About</NuxtLink>
        <NuxtLink to="/contact">前往 Contact</NuxtLink>
      </div>
    </div>
  </div>
</template>

接著我們在瀏覽器瀏覽首頁,點擊「前往 About」或「前往 Contact」就可以看見路由導航效果囉!

使用 <NuxtLink> 時,可以就把它想像為 <router-link> 的替代品,像 to 這個 Pros 控制路由位置的用法基本上一樣,其他更多的 Pros 用法及說明可以參考官網的文件

如果想要使用像 Vue Router 提供的 router.push 方法於 Vue 中直接呼叫來導航至其他頁面,在 Nuxt 中你可以使用 navigateTo,參數可以參考官方文件

約定式路由中的 index.vue

在開頭有提到,Nuxt 3 提供了一個基於檔案的路由,從上述例子你或許能發現,基本上檔案名稱就是對應著路由名稱,但 index.vue 比較特別,它所對應的是路由 /

index.[ext] 這個效果和特性,其實是與 Node.js 底層核心有關,在此就不贅述。

舉例來說,我可以在 pages 下建立一個 docs.vue 表示對應路由 /docs,也可以將檔案放置在 docs 目錄下並重新命名為 index.vue./pages/docs/index.vue,這樣也可以透過 /docs 瀏覽到相同的頁面。

所以當 index.vue 存在於 pages 目錄下,已經位於網站頁面的第一層,所以我們瀏覽 http://localhost:3000/ 就可以做出首頁的效果。

帶參數的動態路由匹配

在實務上,我們可能需要將路徑作為參數傳遞給同一個元件,例如,我們有一個 users 頁面元件,在 /users/ryan/users/jennifer 路徑,都能匹配到同一個 users 元件,並將 ryanjennifer 當作參數傳遞給 users 頁面元件使用,那麼我們就需要動態路由來做到這件事。

在 Vue 3 使用 Vue Router 我們可能會寫出如下路由配置:

1
2
3
4
5
{
  name: "users",
  path: "/users/:id",
  component: "./pages/users.vue",
}

這樣我們就能達到進入 /users/ryan 路由將 ryan 當作 id 參數傳入 users 元件中,路徑參數用冒號 : 表示,這個被匹配的參數 (params),會在元件中可以使用 useRoute()route.params.id 取得。

在 Nuxt 3 中,我們要實現這個效果,需要將檔案名稱添加中括號 [],其中放入欲設定的參數名稱,譬如下面的目錄結構與檔案名稱。

1
2
3
./pages/
└── users/
    └── [id].vue

建立 ./pages/users/[id].vue 檔案,內容如下:

pages/users/[id].vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-3xl text-gray-600">這裡是 Users 動態路由頁面</h1>
      <p class="my-8 text-3xl text-gray-600">
        匹配到的 Id: <span class="text-5xl font-semibold text-blue-600">{{ id }}</span>
      </p>
    </div>
  </div>
</template>

<script setup>
const route = useRoute()
const { id } = route.params
</script>

我們在 script 就可以從 route.params 拿到我們所設定的參數名稱 id,並將其在 template 中渲染出來。瀏覽 http://localhost:3000/users/ryan ,看看效果,Nuxt 3 就能匹配到使用者的 id 參數 ryan,並傳入 users 頁面元件。

你也可以在 template 直接使用 {{ $route.params.id }} 來渲染出 id 參數。

匹配所有層級的路由

如果你需要匹配某個頁面下的所有層級的路由,你可以在參數前面加上 ... ,例如,[...slug].vue,這將匹配該路徑下的所有路由。

建立 ./catch-all/[…slug].vue 檔案:

1
2
3
./pages/
└── catch-all/
    └── [...slug].vue

./catch-all/[…slug].vue 檔案內容如下:

catch-all/[…slug].vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-4xl text-gray-800">這是 catch-all/... 下的頁面</h1>
      <p class="mt-8 text-3xl text-gray-600">匹配到的 Params:</p>
      <p class="my-4 text-5xl font-semibold text-violet-500">{{ $route.params.slug }}</p>
      <span class="text-xl text-gray-400">每個陣列元素對應一個層級</span>
    </div>
  </div>
</template>

我們可以輸入 /catch-all/hello/catch-all/hello/world,路由的參數 slug 就會是一個陣列,陣列的每個元素對應每一個層級。

建立 404 Not Found 頁面

Nuxt 3 提供一個配置來處理 404 Not Found 的頁面,當我們建立 ./pages/[…slug].vue 頁面, Nuxt 3 所有未匹配的路由,將會交由這個頁面元件做處理,並同時使用 setResponseStatus(404) 函數設定 404 HTTP Status Code

pages/[…slug].vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-8xl font-semibold text-red-500">404</h1>
      <p class="my-8 text-3xl text-gray-800">Not Found</p>
      <p class="my-8 text-xl text-gray-800">真的是找不到這個頁面啦 >///<</p>
    </div>
  </div>
</template>

<script setup>
setResponseStatus(404)
</script>

/omg 這個是不存在的頁面,未匹配的路由就會交由 ./pages/[…slug].vue 頁面來處理。

建立多層的目錄結構

如果理解了動態路由的中括號 [] 用法,那我們就可以建立更複雜的頁面目錄結構:

1
2
3
4
5
6
7
8
./pages/
└── posts/
    ├── [postId]/
    │   ├── comments/
    │   │   └── [commentId].vue
    │   └── index.vue
    ├── index.vue
    └── top-[number].vue

這四個 Vue 頁面的參考程式碼如下:

pages/posts/index.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-4xl text-gray-600">這是 Posts 首頁</h1>
      <div class="my-4 flex space-x-4">
        <NuxtLink to="/posts/8"> 前往指定的文章 </NuxtLink>
        <NuxtLink to="/posts/8/comments/1">前往指定的文章留言</NuxtLink>
        <NuxtLink to="/posts/top-3">前往 Top 3</NuxtLink>
        <NuxtLink to="/posts/top-10">前往 Top 10</NuxtLink>
      </div>
    </div>
  </div>
</template>
pages/posts/top-[number].vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-3xl text-gray-600">這是 posts/top-[number] 的頁面</h1>
      <p class="my-8 text-3xl text-gray-600">
        匹配到的 Top Number: <span class="text-5xl font-semibold text-rose-500">{{ number }}</span>
      </p>
    </div>
  </div>
</template>

<script setup>
const route = useRoute()
const { number } = route.params
</script>
pages/posts/[postId]/index.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-3xl text-gray-600">這是 posts/[postId] 的頁面</h1>
      <p class="my-8 text-3xl text-gray-600">
        匹配到的 Post Id: <span class="text-5xl font-semibold text-blue-600">{{ postId }}</span>
      </p>
    </div>
  </div>
</template>

<script setup>
const route = useRoute()
const { postId } = route.params
</script>
pages/posts/[postId]/comments/[commentId].vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-3xl text-gray-600">這是 posts/[postId]/comments/[commentId] 的頁面</h1>
      <p class="my-8 text-3xl text-gray-600">
        匹配到的 Post Id: <span class="text-5xl font-semibold text-blue-600">{{ postId }}</span>
      </p>
      <p class="my-8 text-3xl text-gray-600">
        匹配到的 Comment Id:
        <span class="text-5xl font-semibold text-purple-400">{{ commentId }}</span>
      </p>
    </div>
  </div>
</template>

<script setup>
const route = useRoute()
const { postId, commentId } = route.params
</script>

來看看實際路由及匹配效果。

為了方便理解,整理了以下表格,來表示頁面結構及期望匹配的模式與參數:

./pages/posts/index.vue

匹配模式匹配路徑匹配參數 (Params)
/posts/posts

./pages/posts/top-[number].vue

匹配模式匹配路徑匹配參數 (Params)
/posts/top-:number/posts/top-3{ number: 3 }
/posts/top-:number/posts/top-5{ number: 5 }

./pages/posts/[postId]/index.vue

匹配模式匹配路徑匹配參數 (Params)
/posts/:postId/posts/8{ postId:8 }

./pages/posts/[postId]/comment/[commentId].vue

匹配模式匹配路徑匹配參數 (Params)
/posts/:postId/comments/:commentId/posts/8/comments/1{ postId: 8, commentId: 1 }

到這裡應該對於如何使用檔案名稱與目錄結構,來製作動態路由與匹配參數有一些概念了。

巢狀路由 (Nested Routes)

巢狀路由 (Nested Routes) 或稱嵌套路由,顧名思義,當我們想要在一個頁面鑲嵌另一個頁面時,就需要巢狀路由來幫助我們。

例如,我們想要在 docs 頁面元件中顯示 doc-1doc-2 頁面元件,並在切換 doc-1doc-2 頁面時,只是在 docs 下的嵌套頁面進行切換。

1
2
3
4
5
6
7
8
/docs/doc-1                           /docs/doc-2
+------------------+                  +-----------------+
| docs             |                  | docs            |
| +--------------+ |                  | +-------------+ |
| | doc-1        | |  +------------>  | | doc-2       | |
| |              | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+

在 Vue 3 使用 Vue Router 實作上述巢狀路由時,即 docs 頁面要能顯示 doc-1,我們在路由配置可能就會寫 path: '/docs'children,並在 children 加入 path: '/doc-1',其中 docs 頁面包含 <router-view />,最終瀏覽路由路徑 /docs/doc-1 就可以看到嵌套頁面的效果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  path: "/docs",
  component: () => import("./pages/docs.vue")
  children: [
    {
      path: "doc-1",
      component: () => import("./pages/docs/doc-1.vue")
    }
  ]
}

而在 Nuxt 3 頁面的約定式路由機制下,我們即是透過目錄結構與頁面元件實做出嵌套路由的效果。

舉例來說,當我們建立了下面的目錄頁面結構:

這裡需要注意,一定要有 docs.vuedocs 同名的目錄

1
2
3
4
5
./pages/
├── docs/
│   ├── doc-1.vue
│   └── doc-2.vue
└── docs.vue

頁面元件的參考程式碼如下:

pages/docs.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div class="bg-white">
    <div class="my-6 flex flex-col items-center">
      <h1 class="text-3xl font-semibold text-gray-800">這裡是 Docs</h1>
      <div class="my-4 flex space-x-4">
        <NuxtLink to="/docs/doc-1">前往 Doc 1</NuxtLink>
        <NuxtLink to="/docs/doc-2">前往 Doc 2</NuxtLink>
      </div>
    </div>
    <div class="border-b-2 border-gray-100" />
    <div class="flex flex-col items-center">
      <NuxtPage />
    </div>
  </div>
</template>
pages/doc-1.vue
1
2
3
4
5
<template>
  <div class="flex flex-col items-center">
    <p class="my-8 text-3xl text-blue-500">這是我的第一份文件</p>
  </div>
</template>
pages/doc-2.vue
1
2
3
4
5
<template>
  <div class="flex flex-col items-center">
    <p class="my-8 text-3xl text-green-500">這是我的第二份文件</p>
  </div>
</template>

Nuxt 3 在自動生成路由時,實際上幫我們做出了類似這樣子的路由結構:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  name: "docs",
  path: "/docs",
  component: "./pages/docs.vue",
  children: [
    {
      name: "docs-first-doc",
      path: "doc-1",
      component: "./pages/docs/doc-1.vue",
    }
  ]
}

一定要記得在 docs 頁面加上 <NuxtPage />,來作為顯示巢狀頁面的容器,接著分別瀏覽 /docs/docs/doc-1/docs/doc-2,可以發現在兩個頁面中上方的皆有顯示標題「這裡是 Docs」,該文字是由 docs.vue 元件提供的標題文字,而頁面下方則是 doc-1doc-2 子頁面顯示的地方,以此就可以實現巢狀路由效果囉!

透過目錄檔案的結構與名稱及中括號 [] 我們就可以完成多數路由的情境,確實方便很多,也足以應付大部分實務上的需求,如果真的需要手動建立路由規則可以在參考官方文檔或等待釋出更好解決方案。


布局模板 (Layouts)

Nuxt 3 提供了一個布局模板 (Layouts) 的功能,可以讓你定義好布局模板後,在整個 Nuxt 中使用,舉例來說就很適合如上方有導覽列,下方是網頁主體內容的這種排版方式,將其構建成一個布局模板後,我們就可以重複使用這種布局方式。

布局模板通常放置在 ./layouts 目錄之下,也具有異步自動導入的效果,當新增好布局檔案後,我們就可以在 app.vue 中,添加 <NuxtLayout /> 元件來表示使用布局模板,也可以通過 name 設定不同的模板名稱。

建立一個預設的布局模板

布局模板在 Nuxt 3 中有約定一個名稱為 default.vue 作為預設的模板,如果在頁面元件中未特別指定要使用哪個模板或 <NuxtLayout /> 沒有設定 name 屬性,那麼都將會使用 default 作為預設的布局。

Step 1. 建立預設布局模板

新增 ./layouts/default.vue 檔案內容如下:

layouts/default.vue
1
2
3
4
5
6
<template>
  <div class="bg-sky-100 py-2">
    <p class="px-6 py-4 text-2xl text-gray-700">這是預設的布局全部頁面都會使用到</p>
    <slot />
  </div>
</template>

在布局模板中,通常會包含一個 <slot /> 插槽,這個未命名的插槽 (slot) 即為預設插槽,這將會是採用這個布局模板的頁面元件,顯示的內容容器位置。

Step 2. 添加 元件

調整 app.vue 檔案,內容如下,我們添加 <NuxtLayout> 作為布局模板顯示的位置,name 屬性預設是 default,不過我們還是寫上 name="default" 避免誤會,這個 name 屬性值對應的即是布局模板的名稱。

下圖可以看見,我們在 app.vue 所寫的文字「這裡是最外層 app.vue」,會是在最外層,而緊接著的 <NuxtLayout name="default"></NuxtLayout>,就是布局頁面 default.vue

布局模板中的插槽

如果你有注意到,default.vue 檔案內程式碼內,有一個插槽 <slot />,這裡就會是 <NuxtLayout> 內的元素所顯示的位置。

例如,我們在 app.vue 稍作調整:

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div class="m-4 bg-white">
    <p class="pb-4 text-2xl text-slate-600">這裡是最外層 app.vue</p>
    <NuxtLayout name="default">
      <p class="px-6 pt-4 text-xl text-slate-800">
         NuxtLayout 包裹的元件將會放置到 Layout  slot 
      </p>
    </NuxtLayout>
  </div>
</template>

<NuxtLayout name="default"> 包裹的元素,就會在布局模板中的插槽 <slot /> 顯示。

在布局模板中建立多個插槽

當然,你也可以在布局模板中添加多個插槽,並給予名稱,這樣就可以將內容安排到特定的位置。

如果插槽沒有給予 name 屬性,預設為 default

layouts/default.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-sky-100 py-2">
    <p class="px-6 py-4 text-2xl text-gray-700">這是預設的布局全部頁面都會使用到</p>
    <slot name="header" />
    <slot />
    <slot name="footer" />
  </div>
</template>

Step 2. 將不同內容,顯示於指定的插槽位置。

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<template>
  <div class="m-4 bg-white">
    <p class="pb-4 text-2xl text-slate-600">這裡是最外層 app.vue</p>
    <NuxtLayout name="default">
      <template #header>
        <p class="px-6 pt-4 text-xl text-green-500">這段會放置在 header 插槽</p>
      </template>
      <template #default>
        <p class="px-6 pt-4 text-xl text-cyan-500">
           NuxtLayout 包裹的元件將會放置到 Layout 的預設 slot 
        </p>
      </template>
      <template #footer>
        <p class="px-6 pt-4 text-xl text-blue-500">這段會放置在 footer 插槽</p>
      </template>
    </NuxtLayout>
  </div>
</template>

下圖可以看見,我們就可以依此來安排各個元件,於布局模板插槽的所在位置。

布局模板與路由頁面

當你熟悉了插槽配置,你也可以在其中添加 <NuxtPage /> 與建立 pages 下的頁面元件,以達到不同的路由頁面,使用相同的布局方式。

如果布局模板結合了路由頁面,整體網站就會如下的巢狀顯示方式,網站的入口點 app.vue 放置布局模板,模板內的內容則使用路由的 <NuxtPage />,最後各個路由的頁面就會在 <NuxtPage /> 容器中顯示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
+---------------------------+
| app.vue                   |
| +-----------------------+ |
| | layout                | |
| | +-------------------+ | |
| | | page              | | |
| | |                   | | |
| | |                   | | |
| | +-------------------+ | |
| +-----------------------+ |
+---------------------------+

建立布局模板與路由頁面

Step 1. 調整 app.vue 入口點

app.vue 調整為以下內容:

app.vue
1
2
3
4
5
6
7
8
<template>
  <div class="m-4 bg-white">
    <p class="pb-4 text-2xl text-slate-600">這裡是最外層 app.vue</p>
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>

Step 2. 建立路由頁面

建立 ./pages/index.vue,內容如下:

pages/index.vue
1
2
3
4
5
6
7
8
<template>
  <div class="m-6 bg-slate-50 py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
      <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
    </div>
  </div>
</template>

這樣我們就可以來讓路由頁面共用相同的布局模板。

多個路由頁面共用預設布局模板

承上,我們建立好預設的布局模板,讓它負責顯示路由的頁面。

Step 1. 新增路由頁面

新增 ./pages/about.vue,內容如下:

pages/about.vue
1
2
3
4
5
6
7
8
<template>
  <div class="mx-6 mb-4 bg-slate-50 py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-yellow-400">大家好我是 Ryan</h1>
      <p class="my-8 text-3xl text-gray-600">這裡是 /about</p>
    </div>
  </div>
</template>

新增 ./pages/contact.vue,內容如下:

pages/contact.vue
1
2
3
4
5
6
7
8
<template>
  <div class="mx-6 mb-4 bg-slate-50 py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-rose-400">如果沒事不要找我 xDDD</h1>
      <p class="my-8 text-3xl text-gray-600">這裡是 /contact</p>
    </div>
  </div>
</template>

Step 2. 新增路由連結

調整 ./pages/index.vue,內容如下:

pages/index.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  <div class="mx-6 mb-4 bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-gray-800">這裡是首頁</h1>
      <div class="my-4 flex space-x-4">
        <NuxtLink to="/about">前往 About</NuxtLink>
        <NuxtLink to="/contact">前往 Contact</NuxtLink>
      </div>
    </div>
  </div>
</template>

可以發現,現在首頁 //about/contact 都套用上了預設布局。

建立多個布局模板

你也可以建立多個布局模板,再依據不同的情境,使用不同的布局模板。

Step 1. 建立新的布局模板

新增 ./layouts/custom.vue,內容如下:

layouts/custom.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-rose-100 py-2">
    <p class="px-6 py-4 text-2xl text-gray-700">
      使用 <span class="font-bold text-rose-500">Custom</span> 布局
    </p>
    <slot />
  </div>
</template>

Step 2. 新增一個路由頁面

新增 ./pages/custom.vue,內容如下:

pages/custom.vue
1
2
3
4
5
6
7
8
<template>
  <div class="m-6 bg-slate-50 py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
      <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
    </div>
  </div>
</template>

Step 3. 使用指定布局模板

./pages/custom.vue 中的 script 使用 definePageMeta 方法:

1
2
3
4
5
<script setup>
definePageMeta({
  layout: 'custom'
})
</script>

definePageMeta 方法,提供我們可以設定特定的布局模板,layout 參數值所對應的名稱,即為 ./layouts 目錄下的布局模板。

注意,布局模板的命名被規範使用 Kebab Case 命名法,若檔案名稱為 customLayout.vue,它將會以 custom-layout 作為 name 屬性傳遞給

更進階的指定布局模板

我們能使用 layout: false 來禁止使用預設的布局模板,並在 template 添加 <NuxtLayout name="custom"> 來使用 custom 布局模板。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<template>
  <NuxtLayout name="custom">
    <div class="mx-6 mb-4 bg-white py-24">
      <div class="flex flex-col items-center">
        <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
        <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
      </div>
    </div>
  </NuxtLayout>
</template>

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

當布局模板可以在 template 中設定使用,我們就能結合插槽甚至動態的調整 name 屬性,做出更多樣靈活的布局效果。

上圖的參考程式碼:

layouts/custom.vue
1
2
3
4
5
6
7
8
9
<template>
  <div class="bg-rose-100 py-2">
    <p class="px-6 py-4 text-2xl text-gray-700">
      使用 <span class="font-bold text-rose-500">Custom</span> 布局
    </p>
    <slot />
    <slot name="footer" />
  </div>
</template>
pages/custom.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
  <NuxtLayout name="custom">
    <template #default>
      <div class="mx-6 mb-4 bg-white py-24">
        <div class="flex flex-col items-center">
          <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
          <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
        </div>
      </div>
    </template>
    <template #footer>
      <div class="flex flex-col items-center">
        <p class="mt-4 text-xl text-slate-600">感謝您閱讀 Nuxe 3 學習筆記</p>
      </div>
    </template>
  </NuxtLayout>
</template>

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

當你使用 definePageMeta 方法,禁止使用預設的布局模板後,你也能使用 setPageLayout 方法來動態改變布局。

例如:

1
2
3
4
5
<script setup>
const = enableCustomLayout () => {
  setPageLayout('custom')
}
</script>

當你看過了這篇內容後,你會發現 Nuxt 3 所提供的布局模板非常的好用,布局模板規劃好後,爾後頁面所使用到的相同布局,只需要更改同一個布局模板,如果再結合元件化技巧,更是讓你的程式碼兼具重複使用性與維護性。


元件 (Components)

在建立 Vue 的網站開發過程中,我們可能會自己封裝元件 (Component) 讓程式碼可以被重複使用,也方便開發者維護,這些一個個的元件,可以透過全域註冊 (Global Registration) 讓整個 Vue 應用程式中都可以使用這個元件,也可以透過區域註冊 (Local Registration) 於特定的元件再載入使用。接下來我們將介紹 Nuxt 3 使用元件時應該遵循的規範及特色。

元件自動載入

在 Vue 中,雖然區域註冊使得元件間的依賴關係更加明確也對於 Tree shaking 更加友好,但我們在使用元件時,就得在需要的地方個別載入及註冊。而 Nuxt 3 提供在 components 目錄下專門放至這些元件,並具有自動載入及延遲載入等功能特色。

建立與使用元件

新增 ./components/IronManWelcome.vue,內容如下:

components/IronManWelcome.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
      <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
    </div>
  </div>
</template>

調整 ./app.vue,內容如下:

app.vue
1
2
3
4
5
<template>
  <div>
    <IronManWelcome />
  </div>
</template>

當我們建立了 ./components/IronManWelcome.vue 檔案後,Nuxt 會自動載入 ./components 目錄中的任何元件,在使用時的元件名稱也對應著檔案名稱,所以我們只需直接添加 <IronManWelcome /> 就可以直些使用這個元件。

元件名稱

Nuxt 所自動載入 ./components 目錄下的元件,在使用時的元件名稱也對應著檔案名稱,而當你在巢狀的目錄結構下的元件,元件的名稱將會基於目錄的路徑與檔案名稱,並刪除重複的字段

舉例來說,如果 ./components 目錄結構如下:

1
2
3
4
components/
└── base/
    └── apply/
        └── Button.vue

./components/base/apply/Button.vue 元件的名稱就會是由目錄與檔案名稱組合出的 <BaseApplyButton>

為了開發上能更清楚辨別,建議將檔案名稱設置與使用元件時的名稱相同,所以我們重新命名 ./components/base/apply/ 下的 Button.vueBaseApplyButton.vue

1
2
3
4
components/
└── base/
    └── apply/
        └── BaseApplyButton.vue

你也不用擔心元件名稱會不會變成 <BaseApplyBaseApplyButton> 看起來有點醜醜的,因為 Nuxt 會幫我們刪除重複的字段,所以在使用時元件名稱為 <BaseApplyButton>

元件名稱的命名規則

Vue 在註冊元件時,可以使用大駝峰式命名法 (Pascal Case) 或烤肉串命名法 (Kebab Case) 來為元件命名,並在 template 也可以自由使用兩種命名方式作為使用元件的標籤。

例如,以 <base-apply-button> 來表示使用 ./components/base/apply/BaseApplyButton.vue 元件。

抑或建立 ./components/base/apply/base-apply-button.vue 元件,使用時以 <BaseApplyButton> 表示。

兩種方式 Nuxt 都支援,可以根據自己的習慣做選擇,而我個人是以大駝峰式命名法 (Pascal Case) 為主,以此區別為自己建立的元件。

動態元件 (Dynamic Components)

如果想要使用像 Vue 中的 <component :is="someComputedComponent">動態的切換不同的元件,則需要使用 Vue 提供的 resolveComponentVue 方法來進行輔助。

例如:

1
2
3
4
5
6
7
8
<template>
  <component :is="show ? DynamicComponent : 'div'" />
</template>

<script setup>
const show = ref(false)
const DynamicComponent = resolveComponent('BaseApplyButton')
</script>

建立動態元件

Step 1. 建立元件

新增 ./components/base/apply/BaseApplyButton.vue,內容如下:

components/base/apply/BaseApplyButton.vue
1
2
3
4
5
6
7
8
<template>
  <button
    type="submit"
    class="mt-6 bg-blue-600 py-3 px-8 text-xl font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  >
    立即報名
  </button>
</template>

新增 ./components/round/apply/RoundApplyButton.vue,內容如下:

components/round/apply/RoundApplyButton.vue
1
2
3
4
5
6
7
8
<template>
  <button
    type="submit"
    class="mt-6 rounded-full bg-blue-600 py-3 px-8 text-xl font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  >
    立即報名
  </button>
</template>

Step 2. 使用 resolveComponent()

調整 ./app.vue,內容如下:

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
  <div class="flex flex-col items-center">
    <div class="mt-8 flex items-center">
      <input
        id="show-button"
        v-model="useRound"
        name="show-button"
        type="checkbox"
        class="h-5 w-5"
      />
      <label for="show-button" class="ml-2 block text-base text-slate-800">使用圓角按鈕</label>
    </div>
    <component :is="useRound ? RoundButton : BaseButton" />
  </div>
</template>

<script setup>
const useRound = ref(false)
const BaseButton = resolveComponent('BaseApplyButton')
const RoundButton = resolveComponent('RoundApplyButton')
</script>

呈現效果:

動態載入 (Dynamic Imports)

動態載入 (Dynamic Imports) 元件也稱之為延遲載入 (lazy-loading),如果頁面中不需要立刻使用或顯示某個元件,透過動態載入的方式可以延遲元件載入的時間點,有助於優化 JavaScript 首次載入時的檔案大小。

使用的方式也非常簡單,只需要在使用元件時,加上前綴 Lazy 就可以有延遲載入的效果。

建立一個動態載入的按鈕

Step 1. 建立按鈕元件

新增 ./components/base/apply/BaseApplyButton.vue,內容如下:

components/base/apply/BaseApplyButton.vue
1
2
3
4
5
6
7
8
<template>
  <button
    type="submit"
    class="mt-6 rounded-sm bg-blue-600 py-3 px-8 text-xl font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
  >
    立即報名
  </button>
</template>

Step 2. 添加 Lazy 前綴

調整 ./app.vue,內容如下:

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div class="flex flex-col items-center">
    <div class="mt-8 flex items-center">
      <input id="show-button" v-model="show" name="show-button" type="checkbox" class="h-5 w-5" />
      <label for="show-button" class="ml-2 block text-base text-slate-800">顯示報名按鈕</label>
    </div>
    <LazyBaseApplyButton v-if="show" />
  </div>
</template>

<script setup>
const show = ref(false)
</script>

看看延遲載入的效果:

在頁面上有一個核取方塊,使用者勾選時才顯示按鈕,我們透過瀏覽器的開發者工具觀察網路 (Network) 的使用情況,可以發現只有首次勾選後才請求了 BaseApplyButton.vue 按鈕元件 JS。當這個按鈕元件被設置為 Lazy 動態載入時,頁面首次載入其實不會包含這個按鈕的程式碼,而是等待需要這個元件時才去請求下載,以此達到延遲載入的效果,同時也降低首次進入網頁時需要下載的 JavaScript 程式碼大小。

控制伺服器端或客戶端渲染元件

<ClientOnly> 元件

Nuxt 3 提供了一個 <ClientOnly> 元件,可以控制被包裹的元件僅在客戶端進行渲染。

例如,我們建立 ./components/IronManWelcome.vue 檔案,內容如下:

components/IronManWelcome.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
      <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
    </div>
  </div>
</template>

使用 <ClientOnly> 元件包裹 <IronManWelcome>

調整 ./app.vue 中,內容如下:

app.vue
1
2
3
4
5
6
7
<template>
  <div>
    <ClientOnly>
      <IronManWelcome />
    </ClientOnly>
  </div>
</template>

這樣就可以將 <IronManWelcome> 元件設定為僅在客戶端進行渲染,首次請求頁面時將不會包含這個元件的 HTML。

<ClientOnly> 元件中提供了一個名為 fallback 的插槽 (Slot),可以用作於在伺服器渲染的預設內容,等到客戶端載入完成才接手渲染被包裹的 <IronManWelcome> 元件。

調整 ./app.vue 中,內容如下:

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div>
    <ClientOnly>
      <IronManWelcome />
      <template #fallback>
        <p class="my-6 flex justify-center">[IronManWelcome] 載入中...</p>
      </template>
    </ClientOnly>
  </div>
</template>

當進入首次網頁,會先渲染 fallback 插槽內的元素,所以瀏覽器先顯示 [IronManWelcome] 載入中… 文字,接著客戶端載入完 JS 後接手渲染 <IronManWelcome> 元件。

你也可以透過瀏覽器頁面中點擊右鍵後展開選單的「檢視網頁原始碼」功能,所看到的網頁原始碼,發現伺服器端僅先回應了 <p class="my-6 flex justify-center">[IronManWelcome] 載入中...</p>,表示這是由伺服器端渲染 <ClientOnly> 元件 fallback 插槽的內容。

.client 與 .server

Nuxt 3 的元件,也可以透過元件的檔案名稱來控制僅顯示在客戶端或伺服器端。

如果元件僅在客戶端呈現,則可以將 .client 加入元件檔名的後綴中。

建立一個 ./components/JustClient.client.vue 元件檔案,表示 <JustClient> 元件,僅會在客戶端進行渲染。

而添加 .server 後綴的元件檔案,則會是這個元件在伺服器端渲染的內容。

控制伺服器端或客戶端渲染元件範例

我們使用 <ClientOnly> 元件、.client.server 做一個範例來看呈現的效果。

components/IronManWelcome.vue
1
2
3
4
5
6
7
8
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-sky-400">2022 iThome</h1>
      <p class="mt-4 text-9xl font-bold text-gray-600">鐵人賽</p>
    </div>
  </div>
</template>
components/JustClient.client.vue
1
2
3
4
5
6
<template>
  <div class="mx-16 my-4 rounded-lg bg-green-100 p-4 text-sm text-green-700">
    <span class="font-semibold">[JustClient]</span>
    <span class="ml-2">這是只有在 <span class="font-bold">Client</span> 才會渲染的元件</span>
  </div>
</template>
components/ClientAndServer.client.vue
1
2
3
4
5
6
7
8
<template>
  <div class="mx-16 my-4 rounded-lg bg-sky-100 p-4 text-sm text-sky-700">
    <span class="font-semibold">[ClientAndServer]</span>
    <span class="ml-2">
      這是從 <span class="font-bold text-red-500">Client</span> 渲染出來的元件
    </span>
  </div>
</template>
components/ClientAndServer.server.vue
1
2
3
4
5
6
7
8
9
<template>
  <div class="mx-16 my-4 rounded-lg bg-sky-100 p-4 text-sm text-sky-700">
    <span class="font-semibold">[ClientAndServer]</span>
    <span class="ml-2">
      這是從 <span class="font-bold text-red-500">Server</span> 渲染出來的元件請等待 Client
      接手渲染
    </span>
  </div>
</template>
app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<template>
  <div>
    <ClientOnly>
      <IronManWelcome />
      <template #fallback>
        <p class="my-6 flex justify-center">[IronManWelcome] 載入中...</p>
      </template>
    </ClientOnly>
    <JustClient />
    <ClientAndServer />
  </div>
</template>

我們重新瀏覽網頁,可以發現到由伺服器渲染的插槽元素 [IronManWelcome] 載入中… 文字與 ClientAndServer.server.vue 元件,率先被顯示了出來。接著當 JS 載入完畢後,被 包裹的元件 <IronManWelcome> 也接手並覆蓋了「[IronManWelcome] 載入中…」文字,ClientAndServer.client.vueJustClient.client.vue 僅在客戶端渲染的元件,也分別的覆蓋與顯示出來。

透過元件我們能更好的整理可重複使用的程式碼,也提升了可維護性,而 Nuxt 中的動態載入更是能將非必要使用的元件延遲載入,大大提升了首次進入網頁所需要下載的檔案大小,最後我們也介紹了如何控制伺服器端或客戶端渲染元件,熟悉了之後就能針對網站需求設定元件的載入及渲染方式囉!


組合式函數 (Composables)

組合式函數 (Composables) 是一種利用 Vue 3 的 Composition API 來封裝和複用有狀態邏輯的函數,在 Nuxt 我們可以將一些通用的商業邏輯放置在 composables 來建立組合式函數,這樣一來就可以在各個頁面共用這個組合式函數。

Options API 與 Composition API

  • 選項式 API (Options API):

下列這段是 Vue 依據 Options API 撰寫出來的程式碼,也是 Vue 2 處理邏輯的寫法,所謂 Options (選項、可選的) 指的就是以程式碼的性質來分割程式碼,所有設定資料初始值的都會在 data 這邊處理,這個元件所需要的方法則會在 methods 這裡建立,datamethods 也就是使用者需要的選項

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<script>
export default {
  data() {
    return {
      count: 0,
      doubleCount: 0
    }
  },
  methods: {
    increment() {
      this.count += 1
    },
    incrementByTwo() {
      this.doubleCount += 2
    }
  }
}
</script>
  • 組合式 API (Composition API):

目前在 Vue 3 你仍可以繼續使用 Vue 2 的 Options API,但隨著程式碼邏輯的增加,看似有條理的分類,其實對於理解及維護上沒有想像中便利。

Vue 3 提出的組合式 API (Composition API) 則是以邏輯功能來進行分類,你可以將所有與某個功能的 datacomputedmethodswatch …等,寫在同一個段落行程一個區塊。

詳細說明可閱讀先前撰寫的圖解 Option API vs Composition API文章。

Mixins 與 Composables

在 Options API 可以使用 mixin 來引入可以重複使用的程式碼,讓不同的元件可以共用函數方法,但隨著專案變大,同一個元件可能使用 mixin 同時來引用許多的共用函數,這將導致容易產生命名衝突、元件間的耦合與來源不夠清晰等問題(詳細原因請參考淺談為什麼 Vue 和 React 都選擇了 Hooks?)。

Vue 3 為我們帶來了組合式 API,實現了更乾淨的程式碼編排與高效的邏輯重用,**組合式函數 (Composables)**也基於組合式 API 來進行封裝這些可複用的邏輯,更解決了使用 mixin 實現共用函數的缺點。

組合式函數 (Composables)

在 Nuxt 3 中要建立一個組合式函數 (Composables) 我們可以在 composables 目錄下編寫,這些常用的函數,將會被 Nuxt 3 自動載入做使用,實現在各個元件使用這些函數方法。

建立組合式函數

首先,我們新增 ./composables/useCounter.js,內容如下:

composables/useCounter.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export default function () {
  const count = ref(0)

  const increment = () => {
    count.value += 1
  }

  return {
    count,
    increment
  }
}

Nuxt 自動導入的特性,現在我們就能在其他元件中使用 useCounter 組合式函數。

新增 ./pages/count.vue,內容如下:

pages/count.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div class="flex flex-col items-center">
    <span class="mt-8 text-4xl text-gray-700">{{ count }}</span>
    <button
      class="my-6 rounded-sm bg-sky-600 py-2 px-4 text-base font-medium text-white hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
      @click="increment"
    >
      增加 1
    </button>
  </div>
</template>

<script setup>
const { count, increment } = useCounter()
</script>

Composables 組合式函數的名稱

前面範例使用的 useCounter() 是 Nuxt 3 從 ./composables/useCounter.js 自動載入的,而且 Nuxt 3 的組合式函數的名稱,有兩種方式會影響使用時的函數名稱,不過也建議在建立組合式函數可以使用 use 作為開頭來加以識別。

1. 使用預設匯出 (Default export)

如果在檔案內使用的是預設匯出,那麼這個組合式函數在使用時,即對應檔案名稱,檔案名稱可以是小寫駝峰式 (Camel case) 或烤肉串 (Kebab case),例如建立 ./composables/useCounter.js./composables/use-counter.js 檔案內容如下,使用時組合式函數為小寫駝峰式(Camel case) 名為 useCounter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export default function () {
  const count = ref(0)

  const increment = () => {
    count.value += 1
  }

  return {
    count,
    increment
  }
}

2. 使用具名匯出 (Named export)

如果建立組合式函數時,使用的是有具名的匯出,那麼組合式函數對應的名稱,就不是檔案名稱,而是檔案內 export 出來的名稱

例如,建立 ./composables/count.js,檔案內容如下,組合式函數名稱就不會是檔案名稱 count,而是具名導出的名稱 useCounter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export const useCounter = () => {
  const count = ref(0)

  const increment = () => {
    count.value += 1
  }

  return {
    count,
    increment
  }
}

Composables 自動載入的規則

composables 目錄下,Nuxt 3 會自動掃描 .js.ts.vue 副檔名的檔案,但只有最上層的檔案,才會自動的被載入為組合式函數,以下面這個目錄結構為例,只有 ./composables/useCounter.js 才會被正確的自動載入。

1
2
3
4
composables/
├── time/
│   └── useDateFormat.js
└── useCounter.js

下列這種形式,./composables/time/index.js 也能正確的自動載入:

1
2
3
4
composables/
├── time/
│   └── index.js
└── useCounter.js

如果你想讓巢狀的目錄結構下也能被 Nuxt 自動掃描載入,那麼你可以使用下面兩種方法:

一、重新匯出 [推薦]

配置 ./composables/index.js 將目錄下的函數於這裡整理並匯出你需要的作為組合式函數。

二、配置掃描巢狀目錄

修改 nuxt.config.ts 檔案,配置自動載入額外掃描 composables 下的巢狀目錄。

nuxt.config.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export default defineNuxtConfig({
  imports: {
    dirs: [
      // 掃描 composables 目錄頂層
      'composables',
      // 掃描深度一層的特定檔案
      'composables/*/index.{ts,js,mjs,mts}',
      // 掃描整個 composables 目錄下的檔案
      'composables/**'
    ]
  }
})

不論在 Nuxt 3 或是 Vue 3,組合式函數 (Composables) 都是能幫助你在整個網站可以共用函數的方法,目前也有專案 VueUse 提供了常用與實用的組合式函數集合,能幫我們省去不少開發時間與提升重複使用性,VueUse的介紹請點我前往


插件 (Plugins)

在開發時,為了不重複造輪子,我們可能會在網路上找看看有沒有現成的套件可以做使用,如果這個套件在 Nuxt 3 沒有專用的模組或插件,那麼我們就只能依照套件的支援與安裝方式嘗試添加至 Nuxt 中使用。這邊將會介紹在 Nuxt 插件的規則與特性,如何建立自己的插件及安裝 Vue 的插件。

說到插件 (Plugins),Chrome 或 Firefox 等瀏覽器的使用者,一定都聽過也安裝過插件;如果瀏覽器中的功能不夠滿足你的需求,那麼你可以透過安裝插件來嘗試解決你的問題,而插件做的就是幫助你的瀏覽器或是網站,添加一些功能或是配置,做一個擴充的動作。在 Nuxt 3 指的插件,道理也是一樣的,我們可以透過插件來協助我們擴充功能。

Nuxt 3 插件目錄

Nuxt 將自動讀取 plugins 目錄中的檔案並自動載入它們,也因為目錄中的所有插件都是自動載入的,因此不必於 nuxt.config.ts 中再個別配置。

Nuxt 會自動掃描 plugins 目錄下的 .js.ts.vue 副檔名的檔案,但只有最上層的檔案或最上層目錄下的 index.js 檔案才會被自動載入。

例如,只有 ./plugins/myPlugin.js./plugins/myPlugin/index.js 會自動的被 Nuxt 載入。

1
2
3
4
plugins/
├── myPlugin/
│   └── index.js
└── myPlugin.js

如何建立插件

當您建立好插件的檔案後,只需要使用預設導出 Nuxt 3 中的 defineNuxtPlugin 方法,傳遞給插件的唯一參數是 nuxtApp,接著就可以在裡頭實作功能。

1
2
3
export default defineNuxtPlugin(nuxtApp => {
  // 可以使用 nuxtApp 來做一些事情
})

初次建立插件時,可以嘗試印出 nuxtApp 參數看看,如下圖,nuxtApp 包含了各種的實例 (Instance),例如我們要使用 Vue 的 app.use,就可以在 nuxtApp 找到 Vue 的實例 vueApp,並透過 nuxtApp.vueApp.use 來安裝 Vue 的插件。

在插件中使用組合式函數 (Composables)

你也可以在自定義的插件中使用組合式函數,但在插件中存在著一些限制和差異。

插件的載入順序

插件是依照順序來呼叫的,如果你使用的組合式函數,依賴著尚未載入的插件,它將無法正常的執行;除非你很確定插件的載入順序,不然儘量在插件內使用其他會依賴插件或由其他插件所提供的組合式函數。

依賴 Vue 的生命週期

如果插件內所使用的組合式函數,依賴於 Vue 的生命週期,那麼也會無法正常的執行,因為插件只會綁定到 nuxtApp 的實例上,與 Vue 元件內使用組合式函數綁定的元件實例,有所不同。

Automatically Providing Helpers

如果想在 NuxtApp 的實例上提供 helper,我們可以在插件回傳的物件中添加 provide

例如,我們建立一個插件 ./plugins/myPlugin.js,內容如下:

plugins/myPlugin.js
1
2
3
4
5
6
7
export default defineNuxtPlugin(() => {
  return {
    provide: {
      hello: (msg) => `Hello ${msg}!`
    }
  }
})

調整 ./app.vue 內容:

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="my-2 text-6xl font-semibold text-sky-400">{{ title }}</h1>
      <h1 class="my-2 text-6xl font-semibold text-emerald-400">{{ $hello('Jennifer') }}</h1>
    </div>
  </div>
</template>

<script setup>
const { $hello } = useNuxtApp()
const title = $hello('Ryan')
</script>

我們就可以直接在 template 中使用 $hello,這個由插件提供的 Helper,注意這邊要加上錢字符號 $。也可以透過 useNuxtApp 方法取得 NuxtApp 的實例後再使用 $hello

僅限伺服器端或客戶端中使用

有些插件可能只能在瀏覽器中使用,原因可能是這個插件不需要或無法在伺服器端中執行。

我們可以透過插件的檔案名稱來添加後綴 .client.server,控制伺服器端或客戶端中使用。

Vue 插件與指令

這裡我們嘗試在 Nuxt 3 中使用 Vue 的 vue-gtag 插件,來添加 Google 分析標籤。

Nuxt 3 中使用 vue-gtag 插件

Step 1. 安裝套件

1
2
3
npm install -D vue-gtag-next
# or
# yarn add -D vue-gtag-next

Step 2. 建立 Nuxt 插件

新增 vue-gtag.client.js 檔案,內容如下:

plugins/vue-gtag.client.js
1
2
3
4
5
6
7
8
9
import VueGtag from 'vue-gtag-next'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.use(VueGtag, {
    property: {
      id: 'GA_MEASUREMENT_ID'
    }
  })
})

完成效果

可以看見瀏覽器就出現使用 vue-gtag 插件,幫我們自動插入與配置 script 的效果囉!

Nuxt 3 中建立 Vue 指令

在插件中可以取得 Vue 的實例,所以我們也能在插件註冊自定義的指令。

例如,新增 ./plugins/directive.js 檔案,內容如下:

plugins/directive.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.directive('focus', {
    mounted(el) {
      el.focus()
    },
    getSSRProps() {
      // you can provide SSR-specific props here
      return {}
    }
  })
})

我們就可以直接使用 v-focus 這個指令來控制元素聚焦的效果。

Nuxt 讓我們可以很輕鬆的建立插件並選擇配置於伺服器端或客戶端,對於使用 UI 框架或元件,更能在插件中直接取得實例來安裝 Vue 的插件與指令,不過呢,目前也有個 RFC 正在徵求意見期待讓 Nuxt 使用 Vue 插件可以更方便容易。


模組 (Modules)

Nuxt 提供了一個模組系統來擴展自身框架的核心,也簡化了整合過程中需要的繁瑣配置。當你想擴展 Nuxt 或 Vue 的功能,雖然 Nuxt 可以通過安裝與配置插件進行功能擴展,但是在多個專案或使用上可能繁瑣耗時或重複性很高的,但如果套件已經有針對 Nuxt 模組做整合,我們就不必從頭開始開發或像安裝插件一樣需要建立與維護這些配置。

Nuxt 3 中插件與模組的差異

Nuxt 模組與 Nuxt 插件的差異在於,模組載入執行的時間點更早,意思是 Nuxt 在啟動伺服器後,首先會依序的載入模組並執行,接續建立 Nuxt 的環境 (Context) 與 Vue 的實例 (Instance),最後才開始執行 Nuxt 的插件。

因此,Nuxt 模組可以做更多的事情,包含在使用 nuxi devnuxi build 啟動或建構 Nuxt 時,可以透過模組來覆蓋模板、配置 webpack 及配置插件等許多任務。

Nuxt 3 安裝使用模組

Nuxt 模組是一個導出異步函數的 JavaScript 檔案,當安裝使用模組時,通常會配置在 nuxt.config.ts 檔案的 modules 中,例如使用 Nuxt Tailwind 模組 會添加上 ’@nuxtjs/tailwindcss’

通常模組的開發人員會提供這些模組應該如何在 modules 屬性來做配置,甚至一些可選用的參數來配置這些模組。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export default defineNuxtConfig({
  modules: [
    // 使用套件名稱 (推薦使用)
    '@nuxtjs/example',

    // 載入本地目錄的模組
    './modules/example',

    // 添加模組的選項參數
    ['./modules/example', { token: '123' }]

    // 在行內定義模組
    async (inlineOptions, nuxt) => { }
  ]
})

Nuxt 3 模組列表

你可以在 Explore Nuxt Modules 上尋找由 Nuxt 官方或社群生態所發展建置的模組,Nuxt 的模組通常遵循著官方指南所製,使用時只需要安裝與添加至 nuxt.config 中,基本上就能完成配置。

使用 Nuxt Icon 模組

Nuxt Icon 模組整合了 Iconify 提供多達 100,000 個以上的 Icon 圖示,只要在 Nuxt 中安裝後,我們就可以直接做使用。

Step 1. 安裝套件

1
2
3
npm install -D nuxt-icon
# or
# yarn add -D nuxt-icon

Step 2. 配置使用模組

nuxt.config.ts 中的 modules 屬性,添加 Nuxt Icon 模組的名稱 nuxt-icon

1
2
3
export default defineNuxtConfig({
  modules: ['nuxt-icon']
})

Step 3. 開始使用

依照說明,我們就可以使用 Nuxt Icon 模組,為我們所添加的元件 <Icon>,這個 Icon 元件可以傳入 name 屬性,以此來顯示不同的 Icon 圖示,size 則可以控制圖示的大小。

1
2
3
4
5
<template>
  <div class="flex justify-center">
    <Icon name="logos:nuxt" size="360" />
  </div>
</template>

如何建立 Nuxt 模組

Nuxt Kit 是 Nuxt 官方提供的一個標準和方便的 API 來定義 Nuxt 模組。

通常使用如下程式碼使用 defineNuxtModule 方法來建立一個模組:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
  meta: {
    // 模組的名稱,通常也會對應 NPM 發布的套件名稱
    name: '@nuxtjs/example',
    // 如果有配置這個模組的一些選項,會將其保存在這個設定鍵值下
    configKey: 'sample',
    // 相容性限制 `nuxt.config`
    compatibility: {
      // 為了控制模組的版本相容性,通常會在這裡配置 Nuxt 版本的需求
      nuxt: '^3.0.0'
    }
  },
  // 模組預設的選項
  defaults: {},
  hooks: {},
  async setup(moduleOptions, nuxt) {
    // Nuxt 啟動載入模組後,模組所執行的邏輯會在這裡實作
  }
})

更多 Nuxt 模組的建立指南可以參考 Nuxt 3 - Module Author Guide,這邊就不再贅述,畢竟我們比較常為模組的使用者。

模組的載入

前面我們使用了 Nuxt Icon 模組,我們也可以閱讀一下 Nuxt Icon v0.1.6 模組套件的原始碼。

以下是 Nuxt Icon 模組的 module.ts 檔案。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export default defineNuxtModule<ModuleOptions>({
  meta: {
    name: 'nuxt-icon',
    configKey: 'icon',
    compatibility: {
      nuxt: '^3.0.0-rc.9'
    }
  },
  defaults: {},
  setup (_options, nuxt) {
    const { resolve } = createResolver(import.meta.url)

    addComponent({
      name: 'Icon',
      global: true,
      filePath: resolve('./runtime/Icon.vue')
    })
  }
})

Nuxt 的模組使用了一個 defineNuxtModule 方法來定義模組,也可以視為模組的入口點,meta.name 對應的就是模組的名稱,也是我們添加至 modules 屬性所需要的模組名稱。

setup 就是模組載入後執行的函數,可以看到模組使用 addComponent 為我們 Nuxt 添加了一個元件名稱為 Icon,使用的元件檔案來自 ./runtime/Icon.vue

模組中的 runtime/Icon.vue 為我們封裝了 Iconify 圖示的使用邏輯以及取得 Nuxt 配置的參數等。

透過模組我們可以省去這些繁瑣的封裝與配置,只需要專注在配置模組與開發上面。

模組與插件其實還是存在著載入執行順序與使用情境上的差異性,以 Nuxt 來說,通常 Nuxt 的插件會是用來封裝及使用 Vue 中會使用到的插件與或套件;而 Nuxt 的模組,會將需要繁瑣配置的套件或插件來與 Nuxt 進行整合與封裝,不論是透過第三方插件或模組,這都將使 Nuxt 在開發與擴充上擁有更多的可能性。


中間件目錄 (Middleware Directory)

在 Vue 的專案內我們的頁面通常由 Vue Router 來控制路由及導航,Vue Router 提供了導航守衛 (Navigation Guards) 的 Hook API,讓我們可以在全域、路由甚至是元件中,來控制路由跳轉或取消的方式來守護我們的路由,不讓其隨意導航至特定頁面。Nuxt 3 提供了一個中間件的目錄,讓我們可以製作路由的中間件,來實作出類似導航守衛的效果。

Vue Router 的導航守衛 (Navigation Guards)

導航守衛就是在訪問頁面之前,會攔截你的路由請求並執行自訂的驗證邏輯,依據驗證的成功與否,准予放行跳轉至路由頁面,抑或是取消訪問該路由,再依照不同處理方式進行中斷或重導向至特定路由頁面。

導航守衛在實務上常見的使用情境,就是拿來做頁面訪問的權限驗證,例如,只有管理員才能訪問 /admin 路由下的頁面,我們可能就會添加攔截的 hook 來驗證使用者是否登入及夾帶的 Token 或 Role 是否有權限可以瀏覽,如果驗證成功就准予瀏覽管理員相關頁面,否則,將路由頁面導回至首頁、登入頁或錯誤頁面,如同一個守衛在路由之間進行把關驗證權限。

我們以 Vue Route v4 來舉例,Vue Route 提供了以下三種情況下可以使用的 hook,分別是在全域、路由或是元件中:

全域

全域前置守衛 (Global Before Guards)

當全域守衛 hook 添加好之後,每次導航至不同路由時,都會攔截以異步的方式執行相對應的處理邏輯。

全域守衛提供了 router.beforeEach() hook 可以在進入任何一個路由前進行攔截處理,當導航觸發時就會依照建立的順序做呼叫,因為是異步函數解析執行,所以在所有的守衛 resolve 之前,會一直處於 pending 的狀態。

全域解析守衛 (Global Resolve Guards)

而同樣屬於全域守衛的 router.beforeResolve() hook 會在所有元件內的導航守衛、路由都被解析及執行完畢後才執行,也就是說這個 router.beforeResolve() 呼叫的時間點晚於 router.beforeEach()

全域後置 Hooks (Global After Hooks)

router.beforeEach() hook 相反,全域後置 hooks 提供的 router.afterEach() Hook 會是在路由跳轉結束後才觸發,在這裡路由已經完成跳轉,路由本身也不會再被更動,這個 hook 通常用於分析類或設置頁面相關的資訊等輔助型的功能很有幫助。

路由獨有守衛 (Per-Route Guard)

router.beforeEach() 不同,我們可以為每一個路由添加 beforeEnter() hook,來達到每一個路由頁面有不同的執行方法,同時也只會在不同的路由導航中切換才會觸發。

元件內的守衛

在元件的內部中,也提供三種 hooks 分別為:

  • beforeRouteEnter(): 在路由進入並渲染這個元件之前呼叫,所以還沒有元件的實體可以操作使用。
  • beforeRouteUpdate(): 目前的路由改變,而且還處於同一個元件中時呼叫。
  • beforeRouteLeave(): 當導航準備離開時且沒有使用到這個元件時呼叫。

導航守衛 (Navigation Guards) 在導航出發後的 hook 觸發順序如下圖:


Nuxt 3 路由中間件

Nuxt 3 中提供了一個路由中間件的框架,我們可以在專案下建立名為 middleware 目錄,在這個目錄下我們可以建立中間件,並讓整個 Nuxt 頁面或特定的路由做使用,也可以在頁面中添加,這個中間件可以理解為 Vue Router 中的導航守衛 (Navigation Guards),同樣有 tofrom 參數用以在導航至特定路由之前驗證權限或執行商業邏輯等。

路由中間件格式

當我們想要建立路由中間件時,可以在 Nuxt 專案的 middleware 目錄下建立檔案,並預設匯出一個由 defineNuxtRouteMiddleware() 定義的函數,例如:

1
2
3
4
5
6
export default defineNuxtRouteMiddleware((to, from) => {
  if (to.params.id === '1') {
    return abortNavigation()
  }
  return navigateTo('/')
})

路由中間件能接收目前的路由 to 與下一個路由 from 做為參數,如同 Vue Router 的導航守衛,以此我們就可以來做一些判斷與驗證操作。

路由中間件的回傳

Nuxt 提供了兩個全域的 helpers,可以直接從中間件回傳:

在插件或中間件中重新定向到給定的路由。也可以直接呼叫它進行頁面導航。

navigateTo 參數依序為:

  • to: RouteLocationRaw | undefined | null
  • options: { replace: boolean, redirectCode: number, external: boolean }

abortNavigation(err)

可以在中間件中回傳 abortNavigation() 來中止導航,並可以選擇是否傳入錯誤訊息。

abortNavigation 參數為:

err?: string | Error

與 Vue Router 中的導航守衛稍有不同,在 Nuxt 的中間件中可以使用 navigateToabortNavigation 來決定導航至新的路由或終止導航,如果中間件沒有回傳任何東西,則表示不阻塞導航,如果有下一個中間件,則而移往下一個功能做執行,或者完成路由導航。

  • nothing - 不阻塞導航並且會移動到下一個中間件功能。
  • 如果有的話,或者完成路由導航 return navigateTo(’/’) 或 return navigateTo({ path: ‘/’ }) - 重定向到給定路徑。

如果使用 navigateTo() 重定向是發生在伺服器端 ,則將 HTTP Status Code 設置為暫時重定向狀態碼 302 Found

如果使用 navigateTo() 並夾入 options.redirectCode 屬性,例如 return navigateTo('/', { redirectCode: 301 }),發生的重定向在伺服器端,將 HTTP Status Code 設置為永久重定向狀態碼 301 Moved Permanently

路由中間件的種類

在 Nuxt 中路由中間件分為以下三種:

匿名或者是行內的路由中間件

不需要建立檔案,通常在路由頁面中使用 definePageMeta() 來定義的中間件,就屬於這種類型。

例如,直接定義一個匿名的中間件在頁面元件中使用:

1
2
3
4
5
6
7
<script setup>
definePageMeta({
  middleware: defineNuxtRouteMiddleware(() => {
    console.log(`[匿名中間件] 我是直接定義在頁面內的匿名中間件`)
  })
})
</script>

具名的路由中間件

middleware 目錄下所建立的中間件,當在頁面使用 definePageMeta() 來指定使用具名的中間件時,將透過異步來自動載入。具名的路由的名稱被規範為是烤肉串式 (Kebab case) 命名。

例如,建立一個 ./middleware/random-redirect.js 中間件檔案:

middleware/random-redirect.js
1
2
3
4
5
6
7
8
export default defineNuxtRouteMiddleware(() => {
  if (Math.random() > 0.5) {
    console.log(`[來自 random-redirect 中間件] 重新導向至 /haha`)
    return navigateTo('/haha')
  }

  console.log(`[來自 random-redirect 中間件] 沒發生什麼特別的事情~`)
})

當我們要使用這個中間件時,可以在頁面中使用 definePageMeta() 並傳入 middleware 屬性,來添加路由中間件。

1
2
3
4
5
<script setup>
definePageMeta({
  middleware: 'random-redirect'
})
</script>

如果中間件有多個,你也可以使用陣列來傳入多個中間件,並且會依序執行這些路由中間件。

1
2
3
4
5
<script setup>
definePageMeta({
  middleware: ['random-redirect', 'other']
})
</script>

當我們在頁面中添加這個中間件後,在切換到這個路由頁面時,約有一半的機會,會被導航至 /haha 頁面。

全域的路由中間件

在具名的中間件的檔名添加後綴 .global,如 auth.global.js,這個路由中間件將會被自動載入,並在每次導航變更時自動執行。

例如,我們建立一個 ./middleware/always-run.global.js 中間件檔案,內容如下:

middleware/always-run.global.js
1
2
3
export default defineNuxtRouteMiddleware((to, from) => {
  console.log(`[全域中間件] to: ${to.path}, from: ${from.path}`)
})

這個全域的路由中間件,將會在每一次導航切換頁面時執行。

動態添加路由中間件

你可以使用 addRouteMiddleware() 輔助函數來手動添加全域或命名路由中間件,例如在插件中。

1
2
3
4
5
6
7
8
9
export default defineNuxtPlugin(() => {
  addRouteMiddleware('global-test', () => {
    console.log('這個是由插件添加的全域中間件,並將在每次路由變更時執行')
  }, { global: true })

  addRouteMiddleware('named-test', () => {
    console.log('這個是由插件添加的具名中間件,並將會覆蓋任何現有的同名中間件')
  })
})

在 Nuxt 中我們可以使用所提供的中間件框架,來建立路由頁面中的中間件,而路由中間件會在到特定路由之前執行想要運行的邏輯,對於驗證權限等非常方便,也正是實現導航守衛 (Navigation Guards) 的方式。這裡我們主要講述的路由中間件,也將會與我們後面會提到的伺服器端的中間件有所不同,雖然名稱相似但與 Nitro 啟動時執行的伺服器中間件完全不同


Server API 與 Nitro Engine

這裡會先介紹一下 Nitro Engine 是什麼東西,Nitro 可能不多人聽過但與 Nuxt 3 息息相關,Nitro 伺服器引擎除了有跨平台支援與多種強大的功能外,更包含了 API 路由的支援,意思就是我們可以直接在基於 Nitro 的 Nuxt 3 上直接開發由後端處理的邏輯或與資料庫互動,再將結果回傳至前端,實作出 Server API。

Nitro Engine

在開始建置 Sever API 之前,先來介紹一下 Nitro 伺服器引擎,Nuxt 3 的一大特點就是採用了一個名為 Nitro 的伺服器引擎 (Server Engine),Nitro 基於 rollup 與 h3,為達高效能與可移植性目標而建構的最小 HTTP 框架。

Nitro 提供了以下多種功能特色,使得 Nuxt 更佳完善與強大,如同官網所說 Nitro 讓 Nuxt 直接解鎖了新的全端能力

  • 快速的開發體驗:開箱即用的特性,無需任何配置,就可以啟動具有 hot module reloading 的開發伺服器,寫完程式碼存檔後,就能讓伺服器載入新的程式邏輯。
  • 基於檔案系統的路由:我們只需要專注在建立伺服器的目錄與頁面,就能擁有自動載入與路由的效果。
  • 可移植且便攜:基本上 Nuxt 3 使用的依賴套件都在 package.json 檔案的 devDependencies 中,建構正式環境的網站時,Nitro 自動拆分的程式碼與打包出來的 .output 目錄不再需要安裝依賴套件,意味著不再有 node_modules,因此部署時更輕便好攜。
  • 混合模式:透過**混合模式 (Hybrid mode)**可以將一部分頁面預渲染產生出靜態頁面,部分頁面是動態的在伺服器或客戶端渲染,讓每個路由頁面有不同的靜態或動態甚至擁有快取規則,這將讓 Nuxt 3 的通用渲染 (Universal Rendering) 方式更進一步成混合渲染 (Hybrid Rendering) 也能結合無伺服器 (Serverless) 來配置混合模式。

看到這裡,可以發現 Nuxt 3 與 Nitro 都下了非常大的功夫,雖然還有些功能尚未穩定與需要改進的地方,但是已經為開發上提供了強大支援與良好的體驗,更多特性與細節可以參考 Nuxt 3 - Server EngineNitro 官方

Nuxt 3 的 Server 目錄

我們可以透過 Nuxt 3 專案下的 server 目錄來建立具有 hot module reloading 支援的 Server API 與後端處理邏輯。

server 目錄常用的有下面三個子目錄:

  • api

在這個目錄的檔案,將會由 Nuxt 自動載入並產生 /api 開頭的路由並對應檔案名稱,例如建立 ./server/api/hello.js,就會擁有 /api/hello 的路由對應這個 API,可以使用 http://localhost:3000/api/hello 訪問該路由。

  • routes

在這個目錄的檔案,將會由 Nuxt 自動載入並產生對應檔案名稱的路由,例如建立 ./server/routes/world.js,就會擁有 /world 的路由對應這個 API,可以使用 http://localhost:3000/world 訪問該路由。

  • middleware

在這個目錄的檔案,會被 Nuxt 自動載入,並添加至伺服器中間件,並在每個 Request 進入伺服器 API 的路由前執行。

建立第一個伺服器 API

Nuxt 會自動掃描 server 目錄中的檔案結構,建立 Server API 時通常以 .js.ts 作為副檔名,並依照官方建議,每個檔案都應該預設匯出 defineEventHandler() 函數,並在其 handler 內實作處理邏輯。

handler 接收了一個 event 參數,用來解析請求的資料,並可以直接回傳一個字串JSONPromise 或者使用 event.res.end() 送出請求回應。

舉例來說,我們建立一個檔案 ./server/api/hello.js,內容如下:

server/api/hello.js
1
2
3
4
5
6
export default defineEventHandler(() => {
  return {
    ok: true,
    data: 'Hello World!'
  }
})

如同 pages 頁面路由一樣,在 ./server/api 目錄下會基於檔案系統結構來產生出相對應的路由,並且會添加一個路由前綴 /api,現在,可以使用 http://localhost:3000/api/hello 訪問該路由,看見回傳的 JSON 資料。

伺服器路由

基於檔案的路由

前面的例子有提到,Server API 的路由是基於檔案結構來自動產生,如果你不想要有 /api 的前綴,可以將 API 處理邏輯檔案,放置在 ./server/routes 目錄中。

舉例來說,以下的檔案結構會產生兩個可以訪問的伺服器 API 路由,分別為 /api/hello/api/world

1
2
3
4
5
6
nuxt-app/
└── server/
    ├── api/
    │   └── hello.js
    └── routes/
        └── world.js

匹配路由參數

將檔案名稱添加中括號 [],其中放入欲設定的參數名稱,以此來處理動態路由匹配。 舉例來說,建立 ./server/api/hello/[name].js 檔案,內容如下:

server/api/hello/[name].js
1
2
3
4
export default defineEventHandler((event) => {
  const { name } = event.context.params
  return `Hello, ${name}!`
})

在 handler 內就能使用 event.context.params 來訪問 name 路由參數。

匹配 HTTP 請求方法 (HTTP Request Method)

我們可以添加 .get.post.put.delete 等檔案名稱後綴,來匹配對應的 HTTP request methods。

新增 server/api/test.get.js,內容如下:

server/api/test.get.js
1
2
3
4
5
6
export default defineEventHandler(() => {
  return {
    ok: true,
    message: '測試 [GET] /api/test'
  }
})

新增 server/api/test.post.js,內容如下:

server/api/test.post.js
1
2
3
4
5
6
export default defineEventHandler((event) => {
  return {
    ok: true,
    message: '測試 [POST] /api/test'
  }
})

我們使用 Postman 來打這兩隻 API,可以看到使用不同的 HTTP Request Method,就會匹配至對應後綴檔案中的 handler 進行處理。

[GET] /api/test

[POST] /api/test

從上面的例子,我們建立了 test.get.jstest.post.js 檔案,分別對應了 GETPOST 方法,如果同一個路由使用了其他方法而無法匹配處理的檔案時,則會回傳 HTTP 狀態碼 405 Method Not Allowed 表示錯誤。

匹配包羅萬象的路由 (Catch-all Route)

你可以建立 […].js 的檔案,來將所有不匹配的路由交由這個 handler 作處理。

例如,建立 ./server/api/catch-all/[…].js,將可以匹配 /api/catch-all/x/api/catch-all/x/y…等 /catch-all 下所有層級的路由。

server/api/catch-all/[…].js
1
2
3
4
5
6
7
8
9
export default defineEventHandler((event) => {
  return {
    ok: true,
    data: {
      url: event.req.url
    },
    message: '/api/catch-all 下不匹配的路由都會進入這裡'
  }
})

建立 ./server/api/[…].js 檔案如下,將可以接手所有 /api 下無法匹配的路由。

server/api/[…].js
1
2
3
4
5
6
7
8
9
export default defineEventHandler(() => {
  return {
    ok: true,
    data: {
      url: event.req.url
    },
    message: '/api 下不匹配的路由都會進入這裡'
  }
})

下圖示範中,當我們輸入的路由如果沒有辦法處理,將會被 [...].js 所匹配,以此我們可以來實作返回、重新導向或錯誤頁面。

伺服器中間件

Nuxt 會自動載入 ./server/middleware 中的檔案,並添加至伺服器中間件,伺服器的中間件與路由中間件不同的是,pages 路由頁面的請求是不會執行伺服器中間件。

伺服器的中間件會在每個 Request 進入伺服器 API 的路由前執行,通常用來添加或檢查請求的標頭 (headers)、記錄請求或擴展調整請求的物件。

伺服器中間件的處理邏輯,不應該回傳任何內容,也不應中斷或直接回應請求,伺服器中間件應該僅檢查、擴展請求上下文或直接拋出錯誤。

舉理來說,你可以新增 ./server/middleware/log.js 用來記錄每個請求的 URL。

server/middleware/log.js
1
2
3
export default defineEventHandler((event) => {
  console.log('New request: ' + event.req.url)
})

或者,新增 ./server/middleware/auth.js 用來解析請求或擴展請由物件。

server/middleware/auth.js
1
2
3
export default defineEventHandler((event) => {
  event.context.auth = { username: 'ryan' }
})

伺服器插件

Nuxt 會自動掃描並載入 ./server/plugins 目錄下的檔案,並將他們註冊為 Nitro 的插件,在 Nitro 啟動時,這些插件將會在伺服器載入並執行,插件允許擴展 Nitro 執行時的行為及連接到生命週期的事件。

更多細節可以參考 Nitro Plugins

伺服器通用功能

Nuxt 中伺服器的路由,是由 unjs/h3 所提供,h3 內建一些方便實用的 helpers,可以參考 Available H3 Request Helpers

伺服器路由常用的 HTTP 請求處理

處理 HTTP 請求中的 Body

1
2
3
4
5
6
7
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  return {
    ok: true,
    data: body
  }
})

可以使用 readBody(event) 來解析請求中的 Body,注意 readBody() 是一個異步函數,記得 await 等待解析完成。

處理 URL 中的查詢參數 (Query Parameters)

可以使用 getQuery(evnet) 來解析查詢參數。

1
2
3
4
5
6
7
8
9
export default defineEventHandler((event) => {
  const query = getQuery(event)
  return {
    ok: true,
    data: {
      name: query.name
    }
  }
})

當請求 URL 為 /api/query?name=ryan,可以解析出查詢參數 query.name

可以使用 parseCookies(event) 來解析請求所夾帶的 Cookie。

1
2
3
4
5
6
7
8
9
export default defineEventHandler((event) => {
  const cookies = parseCookies(event)
  return {
    ok: true,
    data: {
      cookies
    }
  }
})

我透過瀏覽器的開發工具,手動添加了一組 cookie 名為 token,Server API 可以透過 parseCookies() 來解析出瀏覽器自動夾帶的 cookie

進階使用範例

Nitro 配置

你可以在 nuxt.config.ts 中使用 nitro 屬性來配置 Nitro 設定

nuxt.config.ts
1
2
3
4
export default defineNuxtConfig({
  // https://nitro.unjs.io/config
  nitro: {}
})

使用巢狀路由

你可以直接使用 h3createRouter() 方法來建立巢狀路由。

1
2
3
4
5
6
7
import { createRouter } from 'h3'

const router = createRouter()

router.get('/', () => 'Hello World')

export default router

伺服器儲存

Nitro 提供了一個跨平台的儲存層,你可以 Nitro 的配置中設定 storage 屬性,來配置額外的儲存掛載位置,官網提供了一個使用 Redis 的範例

更多 Nitro 伺服器的設定與使用可以參考官網

Nitro 伺服器引擎將 Nuxt 3 提升至另一個境界,不僅功能強大且讓開發中擁有不錯的體驗,伺服器路由更是能在後端直接處理邏輯實作 Server API,而接下來將介紹如何在 Nuxt 打 API 取得資料。


資料獲取 (Data Fetching)

Nuxt 提供了 $fetch 及四種組合式函數 (Composables),來進行資料獲取,也就是說,我們不需要再額外安裝任何 HTTP Client ,如 axios 來發送 HTTP 請求,因為 Nuxt 本身就自帶了打 API 的方法,而且在頁面、元件或插件中都能直接呼叫做使用,非常方便。

首先,我們先介紹一下 $fetch 這個由 Nuxt 提供使用 ohmyfetch 套件所封裝的 helper,$fetch 可以在 Nuxt 中用於發送 HTTP 請求。

如果在伺服器端渲染的期間,呼叫 $fetch 打內部 API 路由,也就是打我們自己在 ./server 下實作的後端 API,那麼因為使用 $fetch 的關係,Nuxt 會模擬請求,改由直接呼叫內部 API 的處理函數,這樣就能節省額外的 API 呼叫。

使用的方法,如下:

1
$fetch(url, options)

我們可以使用 $fetch(’/api/count’) 建立一個 GET 請求,發送至 /api/count 後會返回一個 Promise,完成後我們就可以接收回傳的資料。

$fetchoptions 的參數及建立攔截器等功能可以參考 ohmyfetch,不過呢,我們還會使用 Nuxt 提供的組合函數結合 $fetch 來打 API。

接下來我們就來依序介紹,如何使用 Nuxt 提供的四種組合函數來從 API 獲取資料。

useAsyncData

這個 useAsyncData() 組合函數,其實不是傳入 URL 直接呼叫就會發出 API 請求,而是 Nuxt 可以透過這個函數來添加異步請求資料的邏輯。

useAsyncData 組合函數能接收 keyhandleroptions,其中 handler 會來添加請求異步資料的邏輯。當我們在頁面、元件和插件中呼叫 useAsyncData,並等待回傳的 Promise,我們的頁面或元件的渲染將會阻塞路由載入handler 異步邏輯處理完畢後才會繼續執行,也就是說,整個頁面元件將會等待所有使用 useAsyncData 呼叫的 API 回傳完成後才會開始進行渲染。

舉個例子,我們新增一個 Server API,並稍微添加一下延遲,模擬 API 約需要處理 2 秒才回傳資料,./server/api/count.js 內容如下:

server/api/count.js
1
2
3
4
5
6
7
8
9
let counter = 0

export default defineEventHandler(async () => {
  await new Promise((resolve) => setTimeout(resolve, 2 * 1000)) // 等待 2 秒

  counter += 1

  return JSON.stringify(counter)
})

新增一個路由頁面,./pages/count/useAsyncData.vue 內容如下:

pages/count/useAsyncData.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div class="my-24 flex flex-col items-center">
    <p class="text-4xl text-gray-600">瀏覽次數</p>
    <span class="mt-4 text-6xl font-semibold text-sky-400">{{ data }}</span>
  </div>
</template>

<script setup>
const { data } = await useAsyncData('count', () => $fetch('/api/count'))
</script>

當我們瀏覽 /count/useAsyncData 時,會打 /api/count 這隻 API,並等待返回後才開始渲染元件。

因為瀏覽 http://localho:3000/count/useAsyncData 時,第一次都是由後端渲染處理,看不太出導航有被阻塞的效果,建議可以添加一下路由連結來進行導航,就可以發現差異。

當我們從首頁,由客戶端導航至 /count/useAsyncData 頁面時,會發現網址的路由已經變化,但是頁面約等了一會兒才渲染出現,這就是因為頁面中使用了 useAsyncData() 來獲取資料,await 將阻塞整個頁面元件的載入與渲染,直至 API 處理完畢回傳後才開始載入路由渲染元件。

useAsyncData() 共有兩種呼叫時使用參數差異,可以選擇是否傳入第一個參數 key,所傳入參數的類型如下

 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
function useAsyncData(
  handler: (nuxtApp?: NuxtApp) => Promise<DataT>,
  options?: AsyncDataOptions<DataT>
): AsyncData<DataT>

function useAsyncData(
  key: string,
  handler: (nuxtApp?: NuxtApp) => Promise<DataT>,
  options?: AsyncDataOptions<DataT>
): Promise<AsyncData<DataT>>


type AsyncDataOptions<DataT> = {
  server?: boolean
  lazy?: boolean
  default?: () => DataT | Ref<DataT> | null
  transform?: (input: DataT) => DataT
  pick?: string[]
  watch?: WatchSource[]
  initialCache?: boolean
  immediate?: boolean
}

interface RefreshOptions {
  _initial?: boolean
}

type AsyncData<DataT, ErrorT> = {
  data: Ref<DataT | null>
  pending: Ref<boolean>
  execute: () => Promise<void>
  refresh: (opts?: RefreshOptions) => Promise<void>
  error: Ref<ErrorT | null>
}

useAsyncData() 傳入的參數

  • key:唯一鍵,可以確保資料不會重複的獲取,也就是如果 Key 相同便不會再發送相同的請求,除非重新整理頁面由後端再次渲染獲取,或呼叫 useAsyncData 回傳的 refresh() 函數重新取得資料。
  • handler:回傳異步請求資料的處理函數,打 API 或加工的異步邏輯都可以在這裡處理。
  • options
    • server:是否在伺服器端獲取資料,預設為 true
    • lazy:是否於載入路由後才開始執行異步請求函數,預設為 false,所以會阻止路由載入直到請求完成後才開始渲染頁面元件。
    • default:當傳入這個 factory function,可以將異步請求發送與回傳解析前,設定資料的預設值,對於設定 lazy: true 選項特別有用處,至少有個預設值可以使用及渲染顯示。
    • transform:修改加工 handler 回傳結果的函數。
    • pickhandler 若回傳一個物件,只從中依照需要的 key 取出資料,例如只從 JSON 物件中取的某幾個 key 組成新的物件。
    • watch:監聽 refreactive 響應式資料發生變化時,觸發重新請求資料,適用於資料分頁、過濾結果或搜尋等情境。
    • initialCache:預設為 true,當第一次請求資料時,將會把有效的 payload 快取,之後的請求只要是相同的 key,都會直接回傳快取的結果。
    • immediate:預設為 true,請求將會立即觸發。

useAsyncData() 的回傳值

  • data:傳入異步函數的回傳結果。
  • pending:以 truefalse 表示是否正在獲取資料。
  • refresh / execute:一個函數,可以用來重新執行 handler 函數,回傳新的資料,類似重新整理、重打一次 API 的概念。預設情況下 refresh() 執行完並回傳後才能再次執行。
  • error:資料獲取失敗時回傳的物件。

看到這裡,我們再重新閱讀與解釋 useAsyncData() 的範例

1
2
3
4
5
6
<script setup>
const { data, pending, error, refresh } = await useAsyncData(
  'count',
  () => $fetch('/api/count')
)
</script>

呼叫 useAsyncData() 並不是直接幫我們送出 HTTP 請求,而是在 handler 內使用 $fetch 來打 API,只是 useAsyncData() 組合式函數,封裝了更多打 API 時可以使用的方法與參數,來因應不同的使用情境。當然如果想要,你也可以使用其他套件來替換 $fetch 但可能就沒辦法享受它所帶來的好處。

useFetch

這個組合式函數將 useAsyncData$fetch 進行包裝,當使用這個函數時它會根據 URL 和 fetch 的選項來自動產生 useAsyncData 需要的參數 key,如果呼叫的 API 是伺服器端所提供的,也會自動根據伺服器 API 路由來為請求提供類型提示,並推斷 API 的回傳類型。

useFetch() 所傳入參數的類型如下

 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
function useFetch(
  url: string | Request | Ref<string | Request> | () => string | Request,
  options?: UseFetchOptions<DataT>
): Promise<AsyncData<DataT>>

type UseFetchOptions = {
  key?: string
  method?: string
  params?: SearchParams
  body?: RequestInit['body'] | Record<string, any>
  headers?: { key: string, value: string }[]
  baseURL?: string
  server?: boolean
  lazy?: boolean
  immediate?: boolean
  default?: () => DataT
  transform?: (input: DataT) => DataT
  pick?: string[]
  watch?: WatchSource[]
  initialCache?: boolean
}

type AsyncData<DataT> = {
  data: Ref<DataT>
  pending: Ref<boolean>
  refresh: () => Promise<void>
  execute: () => Promise<void>
  error: Ref<Error | boolean>
}

useFetch() 傳入的參數

  • url:要獲取資料的 URL 或 API Endpoint。
  • options:(繼承自 unjs/ohmyfetch 選項與 AsyncDataOptions)
    • method:發送 HTTP 請求的方法,例如 GET、POST 或 DELETE 等。
    • params:查詢參數 (Query params)。
    • body:請求的 body,可以傳入一個物件,它將自動被轉化為字串。
    • headers:請求的標頭 (headers)。
    • baseURL:請求的 API 路徑,基於的 URL。
  • options:(繼承自 useAsyncData 的選項)
    • key:唯一鍵,可以確保資料不會重複的獲取,也就是如果 Key 相同便不會再發送相同的請求,除非重新整理頁面由後端再次渲染獲取,或呼叫 useAsyncData 回傳的 refresh() 函數重新取得資料。
    • server:是否在伺服器端獲取資料,預設為 true
    • lazy:是否於載入路由後才開始執行異步請求函數,預設為 false,所以會阻止路由載入直到請求完成後才開始渲染頁面元件。
    • immediate:預設為 true,請求將會立即觸發。
    • default:當傳入這個 factory function,可以將異步請求發送與回傳解析前,設定資料的預設值,對於設定 lazy: true 選項特別有用處,至少有個預設值可以使用及渲染顯示。
    • transform:修改加工 handler 回傳結果的函數。
    • pickhandler 若回傳一個物件,只從中依照需要的 key 取出資料,例如只從 JSON 物件中取的某幾個 key 組成新的物件。
    • watch:監聽 refreactive 響應式資料發生變化時,觸發重新請求資料,適用於資料分頁、過濾結果或搜尋等情境。
    • initialCache:預設為 true,當第一次請求資料時,將會把有效的 payload 快取,之後的請求只要是相同的 key,都會直接回傳快取的結果。

useFetch() 的回傳值

  • data:傳入異步函數的回傳結果。
  • pending:以 truefalse 表示是否正在獲取資料。
  • refresh / execute:一個函數,可以用來重新執行 handler 函數,回傳新的資料,類似重新整理、重打一次 API 的概念。預設情況下 refresh() 執行完並回傳後才能再次執行。
  • error:資料獲取失敗時回傳的物件。

舉個例子,我們新增一個 Server API,./server/api/about.js 內容如下:

server/api/about.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let counter = 0

export default defineEventHandler(() => {
  counter += 1

  return {
    name: 'Ryan',
    gender: '男',
    email: '[email protected]',
    counter
  }
})

新增一個路由頁面,./pages/about/useFetch.vue 內容如下:

pages/about/useFetch.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
  <div class="my-24 flex flex-col items-center">
    <p class="text-2xl text-gray-600">
      請求狀態:
      {{ pending ? '請求中' : '完成' }}
    </p>
    <span v-if="error" class="mt-4 text-6xl text-gray-600">是否錯誤: {{ error }}</span>
    <span class="mt-4 text-2xl text-gray-600">回傳資料:</span>
    <p class="mt-4 text-3xl font-semibold text-blue-500">{{ data }}</p>
    <button
      class="mt-6 rounded-sm bg-blue-500 py-3 px-8 text-xl font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2"
      @click="refresh"
    >
      重新獲取資料
    </button>
  </div>
</template>

<script setup>
const { data, pending, error, refresh } = await useFetch('/api/about', {
  pick: ['name', 'counter']
})
</script>

透過 useFetch() 我們能更簡單的發送 API 請求,並能得到狀態與重新獲取資料的函數,甚至在第一次進入頁面時,利用 $fetch 可以直接呼叫伺服器 API 函數的特性來降低 API 的請求次數。

攔截器

我們也可以透過 $fetch 提供的選項來設置攔截器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const { data, pending, error, refresh } = await useFetch('/api/auth/login', {
  onRequest({ request, options }) {
    // 設定請求時夾帶的標頭
    options.headers = options.headers || {}
    options.headers.authorization = '...'
  },
  onRequestError({ request, options, error }) {
    // 處理請求時發生的錯誤
  },
  onResponse({ request, response, options }) {
    // 處理請求回應的資料
    return response._data
  },
  onResponseError({ request, response, options }) {
    // 處理請求回應發生的錯誤
  }
})

useLazyAsyncData

預設的情況下,useAsyncData()options.lazyfalse,意思是,預設得情況下,當進入路由後,會開始執行異步請求函數,並會會阻止路由載入元件等,直到請求完成後才開始渲染頁面元件。

useLazyAsyncData() 則是 options.lazy 預設為 true 的封裝,也就是請求資料時它將不會阻塞,並讓頁面繼續渲染元件。

舉個例子,我們新增一個 Server API,並稍微添加一下延遲,模擬 API 約需要處理 2 秒才回傳資料,./server/api/count.js 內容如下:

server/api/count.js
1
2
3
4
5
6
7
8
9
let counter = 0

export default defineEventHandler(async () => {
  await new Promise((resolve) => setTimeout(resolve, 2000)) // 等待 2 秒

  counter += 1

  return JSON.stringify(counter)
})

新增一個路由頁面,./pages/count/useLazyAsyncData.vue 內容如下:

pages/count/useLazyAsyncData.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div class="my-24 flex flex-col items-center">
    <p class="text-6xl text-gray-600">瀏覽次數</p>
    <span class="mt-4 text-9xl font-semibold text-sky-400">{{ data }}</span>
    <div class="mt-8">
      <NuxtLink to="/count">回首頁</NuxtLink>
    </div>
  </div>
</template>

<script setup>
const { data } = useLazyAsyncData('count', () => $fetch('/api/count'))
</script>

可以發現,我們使用 useLazyAsyncData() 後,會與前面使用 useAsyncData() 的效果不一樣,會先渲染出元件,即看到的文字「瀏覽次數」,並再 API 回傳後才響應資料重新渲染數值。

透過 default 選項可以來建立 API 回傳前的預設值,在 options.lazytrue 的情況下,都建議設定一下預設值,可以讓使用者體驗更好一些。

添加 default 選項,./pages/count/useLazyAsyncData.vue 內容如下:

pages/count/useLazyAsyncData.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div class="my-24 flex flex-col items-center">
    <p class="text-6xl text-gray-600">瀏覽次數</p>
    <span class="mt-4 text-9xl font-semibold text-sky-400">{{ data }}</span>
    <div class="mt-8">
      <NuxtLink to="/">回首頁</NuxtLink>
    </div>
  </div>
</template>

<script setup>
const { data } = useLazyAsyncData('count', () => $fetch('/api/count'), {
  default: () => '-'
})
</script>

在 API 請求回來前,預設值會是 -

useLazyFetch

如同 useLazyAsyncData 所描述,useLazyFetch 則是 useFetchoptions.lazy 選項預設為 true 的封裝。

重新獲取資料

前面有提到我們可以使用 refresh() 來重新獲取具有不同查詢參數的資料。

refreshNuxtData

你也可以透過 refreshNuxtData 來使 useAsyncDatauseLazyAsyncDatauseFetchuseLazyFetch 的快取失效,再觸發刷新資料。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<template>
  <div>
    {{ pending ? 'Loading' : count }}
  </div>
  <button @click="refresh">Refresh</button>
</template>

<script setup>
const { pending, data: count } = useLazyAsyncData('count', () => $fetch('/api/count'))

const refresh = () => refreshNuxtData('count')
</script>

Nuxt 已經為我們封裝好可以打 API 的組合函數,用起來也相當簡單方便,照著官網範例ohmyfetch 套件的說明,相信很快就能上手。


狀態管理 (State Management)

Vue 3 父子元件間資料傳遞與讀寫或是跨元件間的狀態共享,可以選擇使用 Props / EmitProvide / InjectVuex store 來處理,這三種資料流都不大一樣,我們也會依據情境來決定狀態管理的方式。這邊會針對 Nuxt 3 所提供的組合式函數 useState 來講述元件間的共享狀態,應該如何做定義,以及使用 Pinia 替代 Vuex 來做狀態管理。

Hydration

首先,我們先來看一個例子,建立 ./pages/random.vue 內容如下:

pages/random.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <span class="text-9xl font-semibold text-sky-400">{{ count }}</span>
    </div>
  </div>
</template>

<script setup>
const count = ref(Math.round(Math.random() * 1000))
</script>

頁面呈現出,每次重新整理頁面,頁面會顯示新的亂數:

細心你的可能會發現,每次重整頁面,數字好像變化了兩次,圖中數字停留在 517,重新整理網頁後竟然先變成了 163 後再變成 101

這個現象其實是伺服器端渲染後與客戶端再次渲染所導致的。講的白話一點就是,因為 Nuxt 預設的渲染模式 Universal Rendering 在 SSR 時期,將 random.vue 內容於伺服器端渲染完成後亂出產生出 163 作為初始值,意即 const count = ref(163),所以網頁先顯示了 163 這個數字,同時,瀏覽器的背景也正在下載客戶端所需要的 JS 準備接手做 CSR,當 JS 載入完成後又再一次的執行 Vue 元件的 const count = ref(Math.round(Math.random() * 1000)) 這段程式碼,此時亂數產生了 101 這個數字,客戶端也就重新渲染出了 101 於頁面上,這也就是為什麼每次重新整理數字會變化兩次的原因。

Nuxt 預設的通用渲染 (Universal Rendering) 模式,是結合了 SSR 與 CSR 的技術,在 Nuxt 收到網頁請求後,會在伺服器渲染出 HTML 回傳至瀏覽器渲染顯示出靜態頁面,同時開始載入需要的 Vue 程式碼,讓客戶端接手為 SPA 使得網頁具有互動性,接手後的渲染行為都是在客戶端進行的 CSR,這也就讓通用渲染同時兼具 SSR 對 SEO 的友善以及 CSR 良好互通性的使用者體驗。

這種在瀏覽器中使後端渲染出的靜態頁面具有交互性,稱之為「Hydration」。

依據官網所提供的圖片,我添加了一些文字來幫助理解。前述提到了在伺服器渲染網頁 HTML 給瀏覽器時,使用可以正常的看見網頁,但是在 JS 下載完成之前,網頁是不具有互動性的,也就是還不具有路由跳轉等 Vue 互動邏輯,直至 JS 下載完後會 Hydrate Vue 程式碼,這時客戶端就完全接手後續的互動與 CSR,到這邊 Hydration 完成,我們也就能與網站完整的互動了。

回到一開始的例子,我們在瀏覽器打開開發者工具的主控台 (Console),可以發現到其實開發的過程,也出現了錯誤提示「Hydration completed but contains mismatches.」,告訴我們 Hydration 完成了但是包含了不匹配,正是前端與後端初始值不同的錯誤。

接下來我們將介紹 Nuxt 提供的組合式函數 useState,來可以解決這個問題。

Nuxt 3 的狀態管理 (State Management)

Nuxt 提供了一個組合式函數 useState 用來建立具有響應式及對於 SSR 友善的共享狀態。

在前面的例子我們提到因為 Hydration 而導致前後端的初始值可能不一致,而 useState 是一個對 SSR 友善的 ref 替代品,使用 useState 建立的響應式變數,它的值會在伺服器端渲染後與客戶端 Hydration 期間的得以被保留。

useState 有兩種接收不同數量參數的呼叫方式:

1
2
useState<T>(init?: () => T | Ref<T>): Ref<T>
useState<T>(key: string, init?: () => T | Ref<T>): Ref<T>
  • key:唯一鍵,用於確保資料能被正確請求且不重複。
  • init:用於提供的初始值給 State 的函數,這個函數也可以回傳一個 ref

舉個例子,新增 ./pages/count.vue,內容如下:

pages/count.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <span class="text-9xl font-semibold text-emerald-400">{{ count }}</span>
    </div>
  </div>
</template>

<script setup>
const count = useState('count', () => Math.round(Math.random() * 1000))
</script>

可以發現,使用 useState 初始化 count 的值後,瀏覽器重整頁面,就不像前面的例子會發生兩次的數值變動。

當我們使用 useState 並以 count 當作 key,在網頁請求進入伺服器端執行時,還沒有這個 count 狀態,所以執行了初始化函數產生出一個亂數,例如 888 就會回傳給 count 當作響應式變數的初始值,此時這個網頁請求,已經有一個 count 的響應式狀態,當前端於 Hydration 步驟再次的執行了下面這段程式碼,useState 一樣是以 count 當作 key,但是存在了一個由伺服器端建立好的 count,就會直接使用該狀態,也就不會在執行初始化函數,而導致前後端的初始狀態不一致的問題。

1
const count = useState('count', () => Math.round(Math.random() * 1000))

useState 的基本用法

新增 ./pages/counter/increment.vue,內容如下:

pages/counter/increment.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
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <span class="text-9xl font-semibold text-sky-600">{{ counter }}</span>
      <div class="mt-8 flex flex-row">
        <button
          class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
          @click="counter++"
        >
          增加
        </button>
        <button
          class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
          @click="counter--"
        >
          減少
        </button>
      </div>
      <p class="mt-4 text-slate-500">如果是第一次進入這個頁面數值初始設定為 0</p>
      <div class="mt-8">
        <NuxtLink to="/">回首頁</NuxtLink>
      </div>
    </div>
  </div>
</template>

<script setup>
const counter = useState('counter', () => 0)
</script>

我們使用 useState 並以 counter 當作 key,首次於後端渲染時會初始化為 0,當前端Hydration 步驟載入 Vue 或跳轉頁面,因為使用相同的 key 所以這個狀態也會繼續被保留,直至我們下一次重新整理網頁。

useState 的共享狀態

前面 useState 的基本用法以 counter 當作 key,我們可以再建立不同的頁面元件並使用 useState('counter') 就可以把這個狀態拿做出來用,也就達到了在任何元件中可以共享相同的響應式狀態。

新增 ./pages/counter/surprise.vue,內容如下:

pages/counter/surprise.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
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <span class="text-9xl font-semibold text-sky-600">{{ counter }}</span>
      <div class="mt-8 flex flex-row">
        <button
          class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
          @click="counter++"
        >
          增加
        </button>
        <button
          class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
          @click="counter--"
        >
          減少
        </button>
      </div>
      <p class="mt-4 text-slate-500">如果是第一次進入這個頁面數值初始設定為亂數</p>
      <div class="mt-8">
        <NuxtLink to="/">回首頁</NuxtLink>
      </div>
    </div>
  </div>
</template>

<script setup>
const counter = useState('counter', () => Math.round(Math.random() * 1000))
</script>

我們可以任意導航至 /counter/increment/counter/surprise 頁面,可以發現兩個頁面可以共享相同的 counter 狀態,當首次進入或重新整理 increment.vue 頁面,會將 counter 初始化為 0;而首次進入或重新整理 surprise.vue 頁面則是產生一個亂數給予 counter

當然你也可以直接在其他元件中使用 useState('counter'),就可以取得共享的響應式裝態,但如果這個元件是初次進入而沒有預設值的建立函數,可能會引發一些錯誤,要特別的注意。

使用組合式函數建立共享狀態

如下例子,我們可以建立組合式函數 (Composables) 來搭配 useState

新增 ./composables/states.ts,內容如下:

composables/states.ts
1
export const useColor = () => useState<string>('color', () => 'green')

新增 ./pages/color.vue,內容如下:

pages/color.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <span class="text-9xl font-semibold text-emerald-400">{{ color }}</span>
    </div>
  </div>
</template>

<script setup>
const color = useColor()
</script>

如此一來我們定義好的組合式函數就可以被自動的導入及建立具有類型安全的狀態,在各個元件之間就可以呼叫這個組合函數來取得共享狀態。

在通用渲染的模式之下,瀏覽器於 Hydration 完成前,網頁雖然能瀏覽但是尚不具有互動性,直至 Hydration 完成後,Vue 的頁面元件會重新載入與綁定,因此我們對於響應式的變數,儘量使用 useState 來替代 ref 以確保 Hydration 前後的初始值得以被保留,而且 useState 因為可以使用 key 來使狀態於元件間共享。


狀態管理 - Store & Pinia

上述我們介紹了如何在 Nuxt 3 使用 useState 來建立一個元件間的共享狀態,隨著專案的健壯增大,我們就需要一個更好的方式來管理與儲存這些狀態,例如在 Vue 中使用 VuexPinia 來建立一個 Store 管理這些狀態就是一個解決方案。如果你還不了解 Pinia,可以理解為是 Vuex v5。因為目前 Pinia 已經成為 Vue 官方推薦的狀態管理解決方案,本篇將針對 Nuxt 使用 Pinia 做一個簡單的介紹。

Pinia

如果你使用過 Vuex 大概會知道 Vue 如何建立 Store 來做狀態管理,隨著時間 Vuex 很積極的蒐集社群及使用者的意見來規劃 Vuex v5。Pinia 的作者 Eduardo 是 Vue.js 核心團隊的成員之一,也參與著 Vuex 的開發,當時他正測試著 Vuex v5 的提案,而 Pinia 成為探索這些意見及可能性的先驅,實現了 Vuex v5 可能的樣子,現在 Pinia 的 API 已經進入穩定狀態,也成為 Vue 官方推薦使用的狀態管理解決方案,並遵循著 Vue 生態的 RFC 流程。

Pinia 相較於 Vuex 有以下差異:

  • 沒有 mutation,只需要使用 action 就可以改狀態。
  • 不再有 modules 巢狀的結構,也不再需要為模組定義命名空間,因為在 Pinia 中,可以定義多個 Store 而且每個都是獨立的也都具有自己的命名空間。
  • 更完整的支援 TypeSctipt,也不再需要使用多餘的 types 來封裝,所有的內容都是類型化的,Pinia API 的設計方式盡可能使用 TypeSctipt 類型推斷。
  • 非常輕巧,約僅有 1 KB,而且可以自定義插件。
  • 支援伺服器端渲染 (SSR) 與程式碼自動拆分。

Nuxt 3 安裝 Pinia

1
2
3
npm install -D pinia @pinia/nuxt --force
# or
# yarn add -D pinia @pinia/nuxt --force

目前照著官方安裝 Pinia,會發生一些問題,所以我們在安裝時加上 –force 參數

添加 @pinia/nuxtnuxt.config.tsmodules 屬性中。

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

建立第一個 Pinia 的 Store

Pinia 提供了一個函數 defineStore 用來定義 store,呼叫時需要一個唯一的名稱來當作第一個參數傳遞,也稱之為 id,Pinia 會使用它來將 store 連接到 devtools。

建議將回傳的函數命名為 use...,例如 useCounterStoreuse 作為開頭是組合式函數命名的約定,來符合使用上的習慣。

defineStore 的第二個參數,可以傳入 Options 物件或是 Setup 函數,例如我們使用 Options 來定義一個 Store,新增 ./stores/counter.js,內容如下:

stores/counter.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  actions: {
    increment() {
      this.count += 1
    },
    decrement() {
      this.count -= 1
    }
  },
  getters: {
    doubleCount: (state) => state.count * 2
  }
})

可以發現到與 Vue 的 Options API 非常類似,我們可以傳遞帶有 stateactionsgetters 屬性的物件。這些屬性正好讓 Store 與 Options API 呼應彼此的關係,如 state 對應 dataactions 對應 methodsgetters 對應 computed

還有另一種方式可以來定義 Store,與 Vue Composition API 的 setup 函數類似,我們可以傳入一個函數,這個函數裡面定義響應式屬性、方法等函數,最後回傳我們想公開的屬性和方法所組成的物件。

以 setup 函數定義 counter store,內容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)

  const increment = () => {
    count.value += 1
  }
  const decrement = () => {
    count.value -= 1
  }

  const doubleCount = computed(() => count.value * 2)

  return {
    count,
    increment,
    decrement,
    doubleCount
  }
})

開始使用 Store

我們只需要在元件中,如下程式碼匯入並呼叫 useCounterStore() 就可以操作 store 裡面的方法或屬性囉!

1
2
3
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()

我們新增一個頁面元件 ./pages/counter.vue,內容如下:

pages/counter.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
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <span class="text-9xl font-semibold text-sky-600">{{ counterStore.count }}</span>
      <div class="mt-8 flex flex-row">
        <button
          class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
          @click="counterStore.increment"
        >
          增加
        </button>
        <button
          class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
          @click="counterStore.decrement"
        >
          減少
        </button>
      </div>
      <div class="mt-8">
        <NuxtLink to="/">回首頁</NuxtLink>
      </div>
    </div>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

這樣我們就完成了一個 store 的顯示狀態值,透過呼叫 counterStore 內定義的 incrementdecrement 來改變狀態。

在不同的元件間,你也可以使用 useCounterStore 取得已經建立好的 store 來共享這些狀態或進行操作。

Pinia Store 的 State

預設情況下,可以直接對 store 的實例來取得狀態,而使用 Pinia 定義的 store 比較特別的是,我們可以不用透過呼叫函數來修改狀態,也可以直接對 store 的狀態進行修改。

1
2
3
const counterStore = useCounterStore()

counterStore.count += 10

改變狀態

除了直接使用 counterStore.count += 10 修改 store,你也可以使用 store 提供的 helper $patch 來修改部分的狀態。

1
2
3
4
userStore.$patch({
  name: 'Ryan'
  money: '88888888',
})

對於集合類型的修改,例如陣列的新增、刪除或指定修改某一個元素等操作,你可以使用 $patch 傳入一個函數,這個函數會接收一個 state 讓你可以修改,對於比較複雜的操作會很方便。

1
2
3
4
cartStore.$patch((state) => {
  state.items.push({ name: 'shoes', quantity: 1 })
  state.hasChanged = true
})

如果你需要,也可以將 store 的整個 state 重新設置成一個新的物件。

1
2
3
4
cartStore.$state = {
  items: [],
  hasChanged: false,
}

重置狀態

store 的實例提供了一個 $reset() 的 helper,呼叫它就可以將 store 的狀態重置至初始值,不過目前只在使用 Option 物件定義的 store 才有實作。

1
2
3
const counterStore = useCounterStore()

counterStore.$reset()

Pinia Store 的 Getters

使用同一個 store 中的其他 getter

在 store 內你可以組合多個 getter,在 Option 物件下,可以透過使用 this 來呼叫使用其他的 getter。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export const useStore = defineStore('main', {
  state: () => ({
    counter: 0,
  }),
  getters: {
    doubleCount: (state) => state.counter * 2,
    doubleCountPlusOne() {
      return this.doubleCount + 1
    },
  }
})

使用其他 store 的 getter

在 store 內你也可以組合其他 store 的 getter,只要建立出其他 store 實例就可以呼叫使用了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { useOtherStore } from './other-store'

export const useStore = defineStore('main', {
  state: () => ({
    // ...
  }),
  getters: {
    otherGetter(state) {
      const otherStore = useOtherStore()
      return state.localData + otherStore.data
    },
  },
})

Pinia Store 的 Actions

Actions 相當於元件中的方法,也是修改狀態的商業邏輯定義的位置,action 可以是同步也可以是異步的,因此,我們也能在 action 中打後端 API 來取得資料後更新狀態。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    profile: {
      name: '',
      gender: '',
      email: ''
    }
  }),
  actions: {
    async getUserProfile() {
      try {
        const { data } = await useFetch('/api/profile')
        this.profile = data
      } catch (error) {
        return error
      }
    }
  }
})

Store 的解構

有些情況,你可能需要將 Store 中的屬性或方法獨立的提取出來,但為了保持屬性的響應性,你需要使用 storeToRefs 來建立屬性的參考,就像使用 toRefs 來建立 props 的參考一樣。

1
2
3
4
5
6
7
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()

const { count } = storeToRefs(counterStore)
const { increment, decrement } = counterStore

Pinia 持久化插件 - Pinia Plugin Persistedstate

Pinia 是個非常輕量的狀態管理解決方案,而且也提供底層 API 使得 Pinia 能夠自定義插件來擴展功能,舉例來說,我們有些狀態需要儲存在使用者瀏覽器中,下次再瀏覽時可以取的當時儲存的狀態資料,我們就需要將 store 的狀態持久化。

我們可以使用 Pinia Plugin Persistedstate 這個插件,來做到持久化這件事,這對於儲存使用者資訊或登入狀態非常的方便。

在 Nuxt 3 中配置使用 Pinia Plugin Persistedstate

Step 1. 安裝套件

1
2
3
npm install -D pinia-plugin-persistedstate --force
# or
# yarn add -D pinia-plugin-persistedstate --force

目前照著官方安裝 Pinia,會發生一些問題,所以我們在安裝時加上 –force 參數

Step 2. 在 Nuxt 3 為 Pinia 添加 Persist 插件

建立 ./plugins/pinia-plugin-persistedstate.client.js,內容如下:

plugins/pinia-plugin-persistedstate.client.js
1
2
3
4
5
import piniaPersistedstate from 'pinia-plugin-persistedstate'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.$pinia.use(piniaPersistedstate)
})

Step 3. 為你的 Store 添加持久化配置

在現有的 store 定義中添加,persist 屬性,來配置 store 持久化,將狀態儲存在瀏覽器的 localStorage

 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
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  actions: {
    increment() {
      this.count += 1
    },
    decrement() {
      this.count -= 1
    }
  },
  getters: {
    doubleCount: (state) => state.count * 2
  },
  persist: {
    enabled: true,
    strategies: [
      {
        key: 'counter',
        storage: process.client ? localStorage : null
      }
    ]
  }
})

如果是使用 setup 函數定義 store,你可以在 defineStore 傳入第三個參數並添加 persist 屬性。

 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 useCounterStore = defineStore(
  'counter',
  () => {
    const count = useState('count', () => 0)

    const increment = () => {
      count.value += 1
    }
    const decrement = () => {
      count.value -= 1
    }

    const doubleCount = computed(() => count.value * 2)

    return {
      count,
      increment,
      decrement,
      doubleCount
    }
  },
  {
    persist: {
      enabled: true,
      strategies: [
        {
          key: 'counter',
          storage: process.client ? localStorage : null
        }
      ]
    }
  }
)

Step 4. 持久化效果

當我們設置好 counterStore 的持久化後,我們的狀態就會被儲存在瀏覽器的 localStorage 之中,就算關閉瀏覽器或重新整理網頁,store 的狀態都會再從 localStorage 讀取出來。

在小型的專案中,你可以使用 useState 來管理,但大專案你就需要一個更好的方式來管理這些狀態,如 Pinia 來為我們管理這些狀態,甚至定義多個 store,Pinia 支援的插件能協助我們擴展 Pinia 的功能,Pinia Plugin Persistedstate 就是一個很常用的插件,能協助我們將 Pinia 的狀態持久化至瀏覽器的 localStoragesessionStorage 中。


Runtime Config & App Config

在 Nuxt 3 中提供了兩種可以方式設定環境變數或前端需要使用的共用設定,分別是在 Nuxt 啟動時會在後端載入使用的 Runtime Config 及可以在前端被使用的 App Config 這兩者間的區別這邊做一些講解。

Runtime Config

在開發網站或部署時,我們總是有一些環境變數需要做設置,dotenv 就是一個很好用的套件,能幫助我們將專案下的 .env 檔案載入到 Node.js 的 process.env 之中,尤其在後端伺服器的 API 開發,這些不能公開或敏感的 Key 或設定值,通常不會與整個專案一起進行版本控制,而是針對不同環境與機器,配置於 .env 或環境變數之中。

這個 .env 設定檔內的環境變數,例如:資料庫的帳號密碼、第三方服務的 Token 或 API Key …等,通常只會在伺服器被讀取做使用,也不會洩漏這些設定給使用者知道,我們也稱之為執行時的設定 (Runtime Config)

Nuxt 3 提供了可以設定 Runtime Config 的方式,我們可以很方便的來設定這些環境變數給予伺服器執行時使用。

配置 runtimeConfig

我們可以在 nuxt.config.ts 中添加 runtimeConfig 屬性,就可以來設定只有伺服器端可以使用的環境變數。

例如,在 nuxt.config.ts 檔案中,添加一個 apiSecretruntimeConfig 屬性內:

nuxt.config.ts
1
2
3
4
5
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: '怎麼可以讓你知道呢 :P'
  }
})

我們就可以在 Server API 使用 useRuntimeConfig() 獲得執行時的設定,再從中取得 apiSecret 環境變數。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const runtimeConfig = useRuntimeConfig()

export default defineEventHandler((event) => {
  const { apiSecret } = runtimeConfig

  console.log(`接收到了一個 Server API 請求: ${event.req.url}`)
  console.log(`執行時的環境變數 [apiSecret]: ${apiSecret}`)

  return 'ok'
})

因為我們是定義在 Server API,所以可以在測試伺服器啟動的 Terminal 看見 console.log 的結果。

此外你也可以在插件或 Vue 中使用 useRuntimeConfig() 來取得執行時的設置,但也僅在 setupNuxt Lifecycle Hooks 中有效。

客戶端使用 runtimeConfig

通常一些密鑰或敏感資訊,我們都會定義在 runtimeConfig 僅供伺服器端做使用,而 runtimeConfig 也可以配置一個 public 的屬性,來把一些環境變數於伺服器端或客戶端做使用,例如,API 的 Base URL 這類在伺服器端打 API 時會需要使用,而客戶端的操作流程也會打同樣的 API 位置,我們就可以使用 public 的屬性。

例如,在 nuxt.config.ts 檔案中,添加一個 apiBaseruntimeConfig.public 屬性內:

nuxt.config.ts
1
2
3
4
5
6
7
8
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: '怎麼可以讓你知道呢 :P',
    public: {
      apiBase: '/api'
    }
  }
})

新增 ./pages/profile.vue 頁面:

pages/profile.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div class="my-24 flex flex-col items-center">
    <span class="mt-4 text-2xl text-gray-600">回傳資料:</span>
    <p class="mt-4 text-3xl font-semibold text-blue-500">{{ data }}</p>
  </div>
</template>

<script setup>
const runtimeConfig = useRuntimeConfig()
const { apiBase } = runtimeConfig.public

console.log(toRaw(runtimeConfig))

const { data } = await useFetch(`${apiBase}/hello`)
</script>

添加在 runtimeConfig.public 屬性的環境變數,在伺服器端與客戶端都可以讀取得到。

下圖中可以發現,在瀏覽器中的 Console 所印出的 runtimeConfig 是不會包含僅有在伺服器端能使用的 apiSecret

使用 .env 建立環境變數

Nuxt 在開發模式或執行時,已經有內建 dotenv,如果在專案目錄下添加了 .env,Nuxt 會在開發期間、建構時或產生靜態網站時,自動載入 .env 內的環境變數。

例如建立 .env 檔案,內容如下:

.env
1
2
NUXT_API_SECRET=api_secret_token
NUXT_PUBLIC_API_BASE=https://nuxtjs.org

這兩個值,將被 dotenv 自動載入至 process.env 中,作為環境變數。

環境變數的覆蓋

不論是透過 dotenv 自動載入 .env 或其他方式配置的環境變數,只要環境變數命名是 NUXT_ 開頭,這個環境變數將會覆蓋 runtimeConfig 的設置。

舉例來說,當我們 runtimeConfig 設置如下:

1
2
3
4
5
6
7
8
export default defineNuxtConfig({
  runtimeConfig: {
    apiSecret: '怎麼可以讓你知道呢 :P',
    public: {
      apiBase: '/api'
    }
  }
})

建立 .env 檔案或其他方式設置下列環境變數:

1
2
NUXT_API_SECRET=api_secret_token
NUXT_PUBLIC_API_BASE=https://nuxtjs.org

那麼 NUXT_API_SECRET 環境變數,將會覆蓋 runtimeConfig.apiSecret,而 NUXT_PUBLIC_API_BASE 將會覆蓋 runtimeConfig.public.apiBase,最終 runtimeConfig 的設定會變成如下:

1
2
3
4
5
6
{
  apiSecret: "api_secret_token",     // 被 NUXT_API_SECRET 環境變數覆蓋
  public: {
    apiBase: "https://nuxtjs.org"    // 被 NUXT_PUBLIC_API_BASE 環境變數覆蓋 
  }
}

會有這樣子的特性是因為 Nuxt 會在啟動時,先載入 nuxt.conf.ts 內的 runtimeConfig,建立出呼叫 useRuntimeConfig() 所得到的執行時設定,例如,先建構出了 _runtimeConfig 物件。

1
2
3
4
5
6
const _runtimeConfig = {
  apiSecret: '怎麼可以讓你知道呢 :P',
  public: {
    apiBase: '/api'
  }
}

接著會走訪這個 _runtimeConfig 物件裡面的 key,逐一將 key 的名稱轉換蛇形命名法 (Snake case),並轉成全大寫再加上 NUXT_ 前綴後取得對應的環境變數,如果存在就會以新值來覆蓋 _runtimeConfig 內的屬性。

例如 apiSecret 經過轉換變成 api_secret,接著轉大寫 API_SECRET 最後加上前綴變成 NUXT_API_SECRET,如此環境變數 NUXT_API_SECRET 的值就覆蓋 runtimeConfig.apiSecret

而在 public 下的設置,也會先轉為 public_apiBase 再經過蛇行命名成 public_api_base 等步驟,最後變成 NUXT_PUBLIC_API_BASE 來載入環境變數並覆蓋。

最後小提醒,當建構出生產環境的網站,如 .output 目錄後,dotenv 並不會包含在建構的網站內, 你需要再自己載入或配置環境變數才能正常運作哦,例如在 PM2 配置 env。

App Config

配置 appConfig

nuxt.config.ts 檔案中,可以在 appConfig 屬性內添加設置,例如,通常我們會添加像網站主題的主色等這類可以公開的配置,讓網站可以使用這個設置。

1
2
3
4
5
6
7
export default defineNuxtConfig({
  appConfig: {
    theme: {
      primaryColor: '#0ea5e9'
    }
  }
})

當建立好 appConfig 後,就可以使用組合式函數 useAppConfig() 來取得設置。

例如,建立 ./pages/config.vue,內容如下:

pages/config.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<template>
  <div class="my-24 flex flex-col items-center">
    <span class="mt-4 text-2xl text-gray-600">theme.primaryColor:</span>
    <p class="mt-4 text-3xl font-semibold text-blue-500">{{ theme.primaryColor }}</p>
  </div>
</template>

<script setup>
const appConfig = useAppConfig()
const { theme } = appConfig
</script>

app.config 檔案

你也可以在專案目錄下建立 app.config.ts 來配置 App Config,這個檔案的副檔名可以是 .ts.js.mjs

app.config.ts
1
2
3
4
5
export default defineAppConfig({
  theme: {
    primaryColor: '#3b82f6'
  }
})

當建立了 app.config.ts 檔案,該設定會與 nuxt.config.ts 檔案中的 appConfig 屬性結合,如果具有相同的命名,則以 app.config.ts 檔案內的設置為主。

具有響應式的設定

當設定好的 App Config 在使用時,解構出的變數是具有響應性的,也就是說在其他頁面修改主題顏色的設定,可以響應至所有使用這個設定的元件。

舉個例子,新增一個 darkMode 的屬性至 app.config.ts 檔案內的 theme:

app.config.ts
1
2
3
4
5
6
export default defineAppConfig({
  theme: {
    primaryColor: '#3b82f6',
    darkMode: false
  }
})

建立 ./pages/index.vue,內容如下:

pages/index.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-6xl font-semibold text-gray-800">這裡是首頁</h1>
      <div class="my-4 flex flex-col space-y-4">
        <NuxtLink to="/config">前往 /config</NuxtLink>
      </div>

      <p class="mt-4 text-2xl text-gray-600">theme.darkMode:</p>
      <span class="mt-4 text-3xl font-semibold text-blue-500">{{ theme.darkMode }}</span>
    </div>
  </div>
</template>

<script setup>
const appConfig = useAppConfig()
const { theme } = appConfig
</script>

建立 ./pages/config.vue,內容如下:

pages/config.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
<template>
  <div class="my-24 flex flex-col items-center">
    <p class="mt-4 text-2xl text-gray-600">theme.primaryColor:</p>
    <span class="mt-4 text-3xl font-semibold text-blue-500">{{ theme.primaryColor }}</span>

    <p class="mt-4 text-2xl text-gray-600">theme.darkMode:</p>
    <span class="mt-4 text-3xl font-semibold text-blue-500">{{ theme.darkMode }}</span>
    <button
      class="mt-6 rounded-sm bg-blue-500 py-2 px-4 text-base font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2"
      @click="theme.darkMode = !theme.darkMode"
    >
      {{ `${theme.darkMode ? '取消' : '啟用'}深色模式` }}
    </button>

    <div class="mt-8">
      <NuxtLink to="/">回首頁</NuxtLink>
    </div>
  </div>
</template>

<script setup>
const appConfig = useAppConfig()
const { theme } = appConfig
</script>

如下圖,我們的首頁取得了 App Config 中的 theme.darkMode 預設為 false,接著我們切換至 /config 頁面,可以將從 useAppConfig() 解構出的 theme 進行變更,我們使用按鈕來設置 theme.darkModetruefalse 表示啟用或取消深色模式。當變更完成後,回至首頁,可以發現 theme.darkMode 發生了響應。

小結

Nuxt 3 提供了 Runtime ConfigApp Config 來讓我們將常用或預設設定應用在不同的情境,使用時,我們僅需記得,不能公開的金鑰敏感訊息,僅放置在 runtimeConfig 中而且不在 public 屬性內runtimeConfig.public 通常放的是前後端會使用到且不常修改的常數。而 App Config 則是當伺服器端與客戶端需要使用的設置,如主題顏色、是否啟用深色模式等這類可以被使用者調整變動的且需要具有響應性,就可以放置在 appConfig 之中。


實作練習

這裡介紹網站的會員系統常會使用到第三方登入,將以 Google OAuth 為例來實際於 Nuxt 3 中做串接。

串接 Google OAuth 登入

首先,我們需要有一組 Google OAuth 使用的 Client ID,你可以到 Google Console 新增一個「OAuth 2.0 用戶端 ID」,這裡我就不再贅述網頁應用程式用的申請過程。

這邊小提醒一下,在建立 OAuth Client ID 時,已授權的 JavaScript 來源,記得填寫上您的正式環境或開發環境的 Domain,且建議使用 HTTPS。

完成後,記得保管好用戶端密碼用戶端 ID (Client ID) 是我們稍後會需要的,用戶端編號格式大概如:

1
168152363730-b37gnijdpa2rdvvbq0qc29cjh4082t3b.apps.googleusercontent.com

我們將這組 Client ID,放置在 Nuxt 的 Runtime Config 之中。調整 nuxt.config.ts 內容,在 runtimeConfig.public 添加 googleClientId

nuxt.config.ts
1
2
3
4
5
6
7
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      googleClientId: '這邊放上你的 Google Client ID'
    }
  }
})

接下來,安裝 Vue 的 Google OAuth 插件,這邊使用的是 vue3-google-login,也有詳細的說明文件可以參考。

安裝 vue3-google-login

1
2
3
npm install -D vue3-google-login
# or
# yarn add -D vue3-google-login

建立 Nuxt 3 插件來使用 vue3-google-login,新增 ./plugins/vue3-google-login.client.js,內容如下:

plugins/vue3-google-login.client.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import vue3GoogleLogin from 'vue3-google-login'

export default defineNuxtPlugin((nuxtApp) => {
  const runtimeConfig = useRuntimeConfig()
  const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public

  nuxtApp.vueApp.use(vue3GoogleLogin, {
    clientId: GOOGLE_CLIENT_ID
  })
})

接著我們在元件中可以直接使用 <GoogleLogin> 元件,並添加一個 callback 屬性;此外,我使用了 Nuxt 3 提供的 <ClientOnly> 元件,將 <GoogleLogin> 包裹起來,以確保該元件僅在客戶端做渲染,以免登入按鈕在初始化發生問題。

pages/login.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<template>
  <div>
    <ClientOnly>
      <GoogleLogin :callback="callback" />
    </ClientOnly>
  </div>
</template>

<script setup>
const callback = (response) => {
  console.log(response)
}
</script>

接著我們啟動 Nuxt 伺服器,這邊我會習慣使用 yarn run dev -- --https 來啟用 HTTPS 做測試,就能發現使用 Google 帳號登入成功後,所返回的 Credential。

One Tap prompt

你可以在 <GoogleLogin> 元件添加 prompt 屬性並設為 true,這樣就能同時啟用 Google 一鍵登入 (One Tap prompt) 的功能囉!

1
<GoogleLogin :callback="callback" prompt />

或者也可以在 onMounted 中呼叫 vue3-google-logingoogleOneTap() 方法,來單獨使用 One Tap prompt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script setup>
import { googleOneTap } from 'vue3-google-login'

onMounted(() => {
  googleOneTap()
    .then((response) => {
      console.log(response)
    })
    .catch((error) => {
      console.error(error)
    })
})

</script>

自訂按鈕

如果你想自訂登入按鈕的樣式,可以在 <GoogleLogin> 的預設插槽 (Slot) 做建立。

1
2
3
4
5
<template>
  <GoogleLogin :callback="callback">
    <button>使用 Google 進行登入</button>
  </GoogleLogin>
</template>

使用自訂按鈕會讓 OAuth 流程稍微有點不一樣,當你登入成功後預設會回傳 Auth Code

如果設定屬性 popup-type="TOKEN",則回傳 Access Token

1
2
3
4
5
<template>
  <GoogleLogin :callback="callback" popup-type="TOKEN">
    <button>使用 Google 進行登入</button>
  </GoogleLogin>
</template>

使用 googleTokenLogin()

在元件中我們也可以自己建立 handleGoogleLogin 點擊事件,呼叫 googleTokenLogin() 方法並傳入設定在 Runtime Config 中的 Google Client ID,這樣點擊登入按鈕就能處理 Google 登入取得 Access Token

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script setup>
import { googleTokenLogin } from 'vue3-google-login'

const runtimeConfig = useRuntimeConfig()
const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public

const handleGoogleLogin = () => {
  googleTokenLogin({
    clientId: GOOGLE_CLIENT_ID
  }).then((response) => {
    console.log(response)
  })
}
</script>

建立一個登入按鈕來呼叫 handleGoogleLogin 點擊事件。

1
2
3
4
5
6
7
8
9
<template>
  <div>
    <button
      type="button"
      @click="handleGoogleLogin"
    >
      使用 Google 繼續
    </button>
</template>

使用 vue3-google-login 提供的 googleTokenLogin() 方法,我們就能取得 Google 使用者的 Access Token 囉!

使用 googleAuthCodeLogin()

我們也可以使用 googleAuthCodeLogin() 來取得 Auth Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<script setup>
import { googleAuthCodeLogin } from 'vue3-google-login'

const runtimeConfig = useRuntimeConfig()
const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public


const handleGoogleLogin = () => {
  googleAuthCodeLogin({
    clientId: GOOGLE_CLIENT_ID
  }).then((response) => {
    console.log(response)
  })
}
</script>

伺服器端驗證

當使用者於前端成功登入後,通常會傳至後端進行登入或記錄使用者,再產生使用於網站的 Token、Cookie 或 Session 等,以供後續的網站驗證做使用。

我們可使用 google-auth-library 於後端進行一系列的驗證或取得使用者資訊。

安裝 google-auth-library

1
2
3
npm install -D google-auth-library
# or
# yarn add -D google-auth-library

接下來,我們就能依照不同的登入方式取得的 CredentialAccess TokenAuth Code 送至後端做驗證。

驗證 Access Token

新增一個 Server API,只接受 POST 方法,在 Body 中夾帶 accessToken 發送至後端。

建立 ./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
import { OAuth2Client } from 'google-auth-library'

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'
    })
  }

  return {
    id: userInfo.sub,
    name: userInfo.name,
    avatar: userInfo.picture,
    email: userInfo.email,
    emailVerified: userInfo.email_verified,
  }
})

調整元件內的登入流程。

 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
<script setup>
import { googleTokenLogin } from 'vue3-google-login'

const runtimeConfig = useRuntimeConfig()
const { googleClientId: GOOGLE_CLIENT_ID } = runtimeConfig.public

const userInfo = ref()

const handleGoogleLogin = async () => {
  const accessToken = await googleTokenLogin({
    clientId: GOOGLE_CLIENT_ID
  }).then((response) => response?.access_token)

  if (!accessToken) {
    return '登入失敗'
  }

  const { data } = await useFetch('/api/auth/google', {
    method: 'POST',
    body: {
      accessToken
    },
    initialCache: false
  })

  userInfo.value = data.value
}
</script>

當我們使用 Google OAuth 登入成功後,會取得 Access Token,並將其傳至 Server API,/api/auth/google 接收 Access Token 並使用 Google API 取得使用者的資訊,最後回傳給前端。

驗證 Credential

在元件中,我們使用的登入方式如果是 Google 渲染的預設按鈕One Tap prompt,回傳值就會包含 Credential,我們將就可使用下面修改後的 Server API 進行驗證。

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
import { OAuth2Client } from 'google-auth-library'

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

  const ticket = await oauth2Client.verifyIdToken({
    idToken: body.credential,
  })

  const payload = ticket.getPayload()

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

  return {
    id: payload.sub,
    name: payload.name,
    avatar: payload.picture,
    email: payload.email,
    emailVerified: payload.email_verified
  }
})

驗證 Auth Code

我們使用的登入方式如果是呼叫 vue3-google-logingoogleAuthCodeLogin(),回傳值就會包含 Auth Code,我們就可使用下面修改後的 Server API 進行驗證。

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
import { OAuth2Client } from 'google-auth-library'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const oauth2Client = new OAuth2Client({
    clientId: '你的 Google Client ID',
    clientSecret: '你的 Google Client Secret',
    redirectUri: '你的 Google Redirect Uri'
  })

  let { tokens } = await oauth2Client.getToken(body.authCode)
  client.setCredentials({ access_token: tokens.access_token })

  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'
    })
  }

  return {
    id: userInfo.sub,
    name: userInfo.name,
    avatar: userInfo.picture,
    email: userInfo.email,
    emailVerified: userInfo.email_verified,
  }
})

以上實現了串接 Google OAuth 登入,並將 Access Token 資訊發送至後端進行驗證,大家可以在依照使用情境自己挑選登入方式及驗證方式,後續也能將使用者資訊儲存到資料庫中,有了資料庫我們就能依照使用者資訊,來比對資料庫進行註冊、驗證登入及產生後續的 Session 或 Cookie 等。


Cookie 在瀏覽網站時多會使用到,不論是用來儲存臨時的資訊或是辨識使用者等,這一個儲存在瀏覽器的一小段文字資料,會在每次發送 HTTP 請求時自動夾帶,所以 Cookie 最常見的用途就包含了登入狀態、驗證身份等。

以下將講述在 Nuxt 3 如何設置 Cookie,並結合 JWT (JSON Web Token) 來做一個實際使用者驗證。

useCookie

Nuxt 3 提供了一個組合式函數 useCookie() 來讓我們可以讀寫 Cookie,並且對於 SSR 也有支援,在頁面、元件或插件中,都可以使用 useCookie() 來建立一個 cookie 具有響應性的參考。

使用方式:

1
const cookie = useCookie(name, options)
  • name:對應的就是 cookie 的 key。
  • options:傳入一個物件來設置多個 cookie 屬性:
    • maxAge:指定 Max-Age 屬性的值,單位是秒。如果沒有設置,則這個 cookie 將會是 Session Only,意即網頁關閉後就會消失。
    • expires:指定一個 Date 物件來作為過期的時間,通常是要相容比較舊的瀏覽器做使用,如果 maxAgeexpires 屬性都有設定,則過期時間應該要設定為一樣。
    • httpOnly:是一個布林值,預設為 false,當設置為 true 時,表示客戶端的 JavaScript 將無法使用 document.cookie 來查看這個 cookie。通常是比較敏感或機密的訊息,如 Token 或 Session Id 會設定為 true,只讓瀏覽器發出請求時自動夾帶。
    • secure:是一個布林值,預設為 false,當設置為 true 時瀏覽器得是 HTTPS 的加密傳輸協定的情境下,才會自動夾帶這個 cookie。
    • domain:指定 cookie 可以適用的 Domain,通常會保持預設,表示適用於自己的 Domain 之下。
    • path:指定 cookie 適用的路徑。
    • sameSite:為一個布林值或是字串,用於設定安全策略
    • encode:由於 cookie 的值只能使用有限的字元集,所以這個設置可以將 cookie 編碼成合法的字串值,預設的編碼是使用 JSON.stringify + encodeURIComponent()
    • decode:cookie 會經過一個解碼的過程,預設的解碼是使用 decodeURIComponent + destr
    • default:為一個函數,可以用於回傳 cookie 的預設值,也可以是回傳一個 Ref

舉個例子,新增 pages/cookie.vue,內容如下:

pages/cookie.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
<template>
  <div class="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
    <div class="w-full max-w-md">
      <div class="flex flex-col items-center">
        <h2 class="mt-2 text-center text-3xl font-bold tracking-tight text-gray-700">Cookie</h2>
      </div>
      <div class="mt-2 flex w-full max-w-md flex-col items-center">
        <button
          type="button"
          class="mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2"
          @click="setNameCookie"
        >
          設置 name
        </button>
        <div class="mt-2 flex">
          <label class="text-lg font-semibold text-emerald-500">name:</label>
          <span class="ml-2 flex text-lg text-slate-700">{{ name }}</span>
        </div>
      </div>
      <div class="mt-2 flex w-full max-w-md flex-col items-center">
        <button
          type="button"
          class="mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2"
          @click="setCounterCookie"
        >
          設置 counter
        </button>
        <div class="mt-2 flex">
          <label class="text-lg font-semibold text-emerald-500">counter:</label>
          <span class="ml-2 flex text-lg text-slate-700">{{ counter }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
const name = useCookie('name')
const counter = useCookie('counter', { maxAge: 60 })

const setNameCookie = () => {
  name.value = 'Ryan'
}

const setCounterCookie = () => {
  counter.value = Math.round(Math.random() * 1000)
}
</script>

我們就可以設置一個只有目前網頁有效的 cookie 名為 name 及一個過期時間為 60 秒後的 counter

伺服器端使用 getCookie 與 setCookie

你可以在伺服器端使用 getCookie() 來取得前端夾帶過來的 cookie,也可以使用 setCookie 來設置 cookie 回應給前端。

舉個例子,新增 server/api/coookie.js,內容如下:

server/api/coookie.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export default defineEventHandler((event) => {
  let counter = getCookie(event, 'counter')

  counter = parseInt(counter, 10) || 0
  counter += 1

  setCookie(event, 'counter', counter)

  return { counter }
})

當前端打 /api/cookie 這隻 Server API 時,就會自動夾帶瀏覽器中的 cookie,伺服器端收到請求解析 cookie 後得到 counter,將其轉為數值或預設為 0 後增加 1,再重新設定回去給前端。

我們可以將 Cookie 的運作機制應用在會員系統當中,使用者登入成功後,後端產生的 Token 或 Session 回傳並儲存在使用者的瀏覽器中,之後的請求將會自動夾帶可以辨識出使用者的 cookie,我們就可以在後端解析或比對 cookie 來驗證使用者的資訊,並依照策略給予不同的處理邏輯。

我們延續上述的使用 Google OAuth 登入,我們可以在後端實作產生我們自己系統使用的 Token,並設置在 access_token 這個 cookie 之中,後端可以寫如下程式碼,來設置 httpOnlymaxAge 過期時間等參數。

server/api/auth/google.post.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export default defineEventHandler(async (event) => {
  // ...

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

  // ...
})

我們使用 jsonwebtoken 產生 JWT,裡面的 Payload 放置了使用者資訊,其中 jwtSignSecret 作為核發 JWT 的簽署金鑰,我們定義在 nuxt.config.ts 中的 runtimeConfig.jwtSignSecret

安裝 jsonwebtoken

1
2
3
npm install jsonwebtoken
# or
# yarn add jsonwebtoken

完整的 ./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
import { OAuth2Client } from 'google-auth-library'
import jwt from 'jsonwebtoken'

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'
    })
  }

  const jwtTokenPayload = {
    id: userInfo.sub,
    nickname: userInfo.name,
    email: userInfo.email
  }

  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: userInfo.id,
    nickname: userInfo.name,
    avatar: userInfo.picture,
    email: userInfo.email
  }
})

JWT 在核發時使用的加密金鑰,建議使用非對稱式的金鑰進行加密,這邊僅是為了範例展示方便而使用相同的 Secret 進行加解密。

接著,我們可以實作一個 Server API,./server/api/whoami.js 來從 cookie 得到 access_token,再用 jwt.verify() 方法,來驗證 JTW 後的到使用者資訊。

server/api/whoami.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import jwt from 'jsonwebtoken'

const runtimeConfig = useRuntimeConfig()

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

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

    return {
      id: userInfo.id,
      nickname: userInfo.nickname,
      email: userInfo.email
    }
  } catch (e) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Unauthorized'
    })
  }
})

我們在前端,就可以使用 /api/whoami 這隻 API 來得到使用者的資訊囉!

上圖的流程如下:

  1. 前往登入頁面,並使用 Google 進行登入
  2. 登入成功後,打 /api/auth/google API,Body 夾帶 Google OAuth 回傳的 access_token
  3. /api/auth/google API 回傳後端產生的時效七天的 JWT,並設置於 cookie 的 access_token,並將前端頁面導航至 /whoami 頁面。
  4. /whoami 頁面,點擊「打 /api/whoami API」按鈕,送出 /api/whoami API 請求,瀏覽器會自動夾帶 cookie 至後端。
  5. 後端 API /api/whoami 收到請求後,從 cookie 中解析出 access_token 的值,並驗證解析出 JWT 內含的使用者資訊,並將其回傳至前端渲染。

pages/whoami.vue 完整程式碼:

pages/whoami.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
<template>
  <div class="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
    <div class="w-full max-w-md">
      <div class="flex flex-col items-center">
        <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-700">我是誰</h2>
      </div>
    </div>

    <div class="mt-6 flex w-full max-w-md flex-col items-center">
      <button
        type="button"
        class="mt-2 w-fit rounded-sm bg-emerald-500 py-2 px-4 text-sm text-white hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2"
        @click="whoami"
      >
         /api/whoami API
      </button>
    </div>

    <div v-if="userInfo" class="mt-8 flex w-full max-w-md flex-col">
      <div v-for="key in Object.keys(userInfo)" :key="key" class="mt-1 flex flex-wrap break-all">
        <label class="text-lg font-semibold text-emerald-500"> {{ key }}:</label>
        <span class="ml-2 flex flex-1 text-lg text-slate-700">{{ userInfo[key] }}</span>
      </div>
    </div>
  </div>
</template>

<script setup>
const userInfo = ref()
const whoami = () => {
  useFetch('/api/whoami').then((response) => {
    userInfo.value = response.data.value
  })
}
</script>

至此,我們就實作出一個 Google OAuth 的登入及驗證機制,後續可以再將這些資訊儲存至資料庫之中做後續的使用。

這個範例的完整程式碼可以參考 Nuxt 3 - 使用 Cookie 與 JWT 做使用者驗證

小提醒,如果是在伺服器端打 API,可以使用 useRequestHeaders 就可以從伺服器端訪問和代理 cookie 到 API。

1
2
3
4
5
<script setup>
const { data: userInfo } = await useFetch('/api/whoami', {
  headers: useRequestHeaders(['cookie'])
})
</script>

Cookie、LocalStorage、Token 與 JWT

在做使用者驗證的時候,Cookie、LocalStorage、Token 與 JWT 是個重要的概念,這邊稍微簡述一下之間的差異與使用情境。

通常採用 Cookie 來驗證使用者時,當使用者登入成功,會由伺服器端產生一組 cookie 來當作後續驗證使用的依據,這個依據會是一個字串形式,瀏覽器每次發送請求時會自動夾帶符合 Domain、路徑設定等的 cookie,伺服器端就可以依據 cookie 來解析驗證這個字串所代表的涵意,例如是不是匹配代表某位使用者字串。

如同前面的例子,我們將一組能夠代表 Ryan 的 JWT 由登入後產生,並在後續請求中夾帶,以達到驗證使用者的效果。

這串字串,不一定是要 JWT,只要伺服器端能回推或從資料庫比對出所代表的使用者或特定資訊,那麼也可以是隨意產生的字串。

LocalStorage

LocalStorage 是現今瀏覽器基本上都支援的一個儲存空間,同樣具有 Domain 的寫入與讀取的概念,在 cookie 我們最多只能儲存 4KB 左右的字串資料,但是在 LocalStorage 通常有 5MB 所以能夠儲存更多的資訊。

也有網站會把驗證使用者的 Token 或憑證依據儲存在 LocalStorage,之後發送請求或需要時再從中拿取出來並手動夾帶出去,同樣也可以實作出驗證使用者的流程。

在瀏覽器的儲存空間,還有一個 SessionStorage,儲存在這裡面的資料,當網頁關閉時,就會自動清除。與 LocalStorage 不同,除非手動或使用 JS 清除,不然資料永遠存在,也不會有過期時間。

Token

用於提供給使用者後續夾帶給後端的憑據,相當於一個身分證,Token 通常由字串組成,且需要夠長不容易被暴力嘗試破解,Token 字串通常不具有任何意義,直至後端與資料庫或其他方式比對後,會對應出 Token 所代表的意義,例如使用者資訊。

通常請求夾帶的 Token,需要每次往快取或資料庫做比對,才能知道 Token 所代表的使用者。

JWT(JSON Web Token)

JWT 是一種開放的標準 RFC 7519,如同名字 JWT 是基於 JSON Object 所編碼出來的,JWT 是由三個部分 HeaderPayloadSignature 組合而成。

其中最大的特色就是 Payload 這個部分,當後端伺服器收到 JWT 時,可以從 Payload 解析出當初簽發 JWT 所包含的資訊,通常會在 Payload 放置使用者的相關資料,如 Id、姓名或信箱等,因為這個特色我們不需要再比對快取或資料庫,就可以直接解析出使用者資料。

Payload 這個欄位,因為可以被解碼出來,再任意的修改後重新編碼,所以我們就會借助 HeaderSignature 這兩個部分,來確認當初加密的演算法及驗證簽章是否符合,以防止 Payload 被任意的竄改。

Token 或 JWT 選哪一個?

這兩者之間各有優缺點,雖然自產 Token 需要每次比對資料庫,但是能有效的記錄使用者 Token 核發使用的位置及註銷特定的 Token,因為 JWT 在簽發後,一定得等到過期才會失效無法使用,也就導致 JWT 在核發之後是無法註銷的,除非自己在實作一個黑名單或白名單的機制。

可以依據使用的情境來決定 Token 或 JWT,只要記得使用 Token 作為解決方案,要保證 Token 足夠複雜或隨機,不容易被推測或暴力嘗試,如果真的要使用比較短的 Token 也確保過期的時間不要設定太長,要頻繁的更換這組 Token 或添加其他驗證機制。

而 JWT 的利於可以包含 Payload,也切記在後端核發時不要將敏感資料或密鑰夾帶在 Payload 之中,因為 JWT 的 Payload 就算不知道加密的私鑰還是可以被解碼的。而簽發 JWT 選擇加密的方式也盡量採用非對稱式的家姐密,例如使用 ES256 (ECDSA-SHA256) 演算法產生的非對稱金鑰來進行加密。

後端產生的 Token 或 JWT,不管存放在 Cookie 或 LocalStorage 只要注意其特性及安全,那麼想存在哪裡,都是可以被接受的。

  • 放在 Cookie

將 Token 存放在 Cookie 的好處,可以用來控制網站的 Domain 或 SubDomain 進行存取,也可以設定過期時間來控制前端是否要重新問後端產生新的 Token,最大的特點就是自動夾帶在每個請求當中,也因為這項特性,有一些資安風險就需要稍微注意一下。

跨站請求偽造 (Cross Site Request Forgery, CSRF) 就是一個 cookie 使用時所需要注意的資安問題,所以當使用 cookie 作為驗證機制時,建議在敏感的操作上多添加驗證的機制,或使用 CSRF Token 來保護你的 cookie 不被隨意偽造請求的夾帶出去。

將 Token 或敏感資料儲存在 cooike 時,還是可以透過 JavaScript 的 document.cookie 做存取,所以如果網站有存在跨網站指令碼 (Cross-site scripting, XSS) 的弱點,你的 cookie 很可能就會直接被偷走,為了防止 XSS 弱點導致 cookie 外洩,你可以將 cookie 設值時的屬性 httpOnly 設置為 true,讓客戶端瀏覽器無法直接存取,僅有在發送請求時,由瀏覽器自動夾帶至後端,此外,也可以設置 secure 讓 cookie 只在 HTTPS 下傳輸。

  • 放在 LocalStorage

關於 Token 是否儲存在 LocalStorage 其實有不同的看法,因為 cookie 存在著 CSRF 或 XSS 等問題需要解決,而存放在 LocalStorage 不僅有更大的空間,還不自帶免疫 CSRF,難道不香嗎?

但是別忘了 LocalStorage 也是透過 JS 來做儲存與讀取,所以就算使用 LocalStorage 了,但 XSS 弱點如果存在,你的 Token 一樣有外洩的可能性。

而且 LocalStorage 不具有過期自動刪除的特性,除非自己實作或刪除否則將永遠的存在瀏覽器之中。

所以?

網路上有許多使用 Cookie 或 LocalStorage 儲存 Token 說法,有興趣也可以看看兩派各自論述的優缺點,所以,不管是 Cookie 還是 LocalStorage 沒有說一定不能使用誰,而是要依據特性及情境來做使用,並做好定期重新產生及相關的安全配置。

我自己大概會依照這個網站或服務,是相對簡單也比較不會有會造成悲劇的操作或內容管理為主的服務,我可能就會採用 cookie 的方案處理驗證,因為相對來說自動夾帶與有效期限及 httpOnly 來控制客戶端是否能直接存取,對我來說還是挺方便與安全的,可能在適當的時機再添加 CSRF Token 或多道驗證機制,就能安全的使用 cookie。

而在多為內部系統使用或是 API 在 Mobile App 甚至是不支援 Cookie 環境之下需要使用 Token 來驗證,我就會採納將 Token 儲存在 LocalStorage。


搜尋引擎最佳化 (SEO) 與 HTML Meta Tag

選擇使用 Nuxt 3 作為網站開發框架的開發者,多數都是為了要使用 SSR 或 SSG 來加強對 SEO 的優化設置,這篇將會講述 Nuxt 提供的幾個組合式函數,來協助我們設置網頁的標題、內文、Meta 等,以此來設置 SEO 或外部連結可能會解析到的標籤與數值內容。

搜尋引擎最佳化 (SEO) - 網站的標題 (Title) 與描述 (Description)

當網站需要做搜尋引擎最佳化 (SEO) 的時候,Meta Tag 這個名詞你一定不能忽略,Meta Tag 稱之為元標籤描述標籤,顧名思義,是來額外描述網頁的資料使用;Meta Tag 通常放置在 HTML 的 中,雖然 Meta Tag 不會直接顯示在網頁上,但對於搜尋引擎的爬蟲來說,是相當重要的一項識別資訊,也是 Facebook 提出的 Open Graph 放置的位置,讓網頁的標題、描述或縮圖等,能正確的被解析顯示出來。

那麼網頁標題與描述,如果正確設定會有什麼樣子的效果呢?舉例來說,前面的【串接 Google OAuth 登入】我們可以透過網頁原始碼來查看,可以發現 <title><meta name="description" … /> 都有設定。

搜尋引擎爬蟲在收錄網頁時,就會解析這些標籤來建立索引,我們在使用搜尋資料時,網頁的資訊也就對應著顯示出來,如下圖。

各家的搜尋引擎爬蟲,雖然都有自己解析的規則,不過根據 SEO 的經驗都有大方向能夠依循做建立,此外就是在針對特定的搜尋引擎加強識別標記,遵照官方指引就能讓網站的內容能見度有顯著提升。

網站的 Open Graph (OG)

OG 是由 Facebook 所提出的設定,全名為 Open Graph Protocol,官方翻譯為「開放社交關係圖」,在設定網站的 Meta 時通常也稱 OG Tag,Facebook 提供了網站管理員分享指南,當初設計的目的是,當網頁中設定了 OG Tag,能讓網頁被分享至社群媒體時,能有呈現更為豐富的內容,例如標題、描述及縮圖等。

舉例來說,當網頁設定了 OG Tag,以連結形式被分享至 Facebook,會呈現如下的資訊,Facebook 會解析網頁內的 OG Tag 將對應的如標題及縮圖等資訊顯示出來。

其中縮圖依照 Facebook 的指引,即是對應網頁中的 <meta property="og:image" ... />

不只是在 Facebook 上分享連結會有連結預覽的效果,也因為大家都遵循著 OG Tag 進行設定,所以在其他主流的社群媒體或通訊軟體等也都有跟進解析。例如,我們使用 LINE 傳送一條連結,也會因為設置了 OG Tag 也有成功解析出連結預覽的效果。

LINE 開發者也有提供一些問答指引可以做參考,連結預覽og:titleog:descriptionog:image 有著相應的設定關係。

useHead

我們可以使用 Nuxt 3 提供的 useHead() 組合式函數,來設定網站的一些標籤或標記,使用方式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<script setup>
useHead({
  title: 'Nuxt 3 Blog',
  viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
  charset: 'utf-8',
  meta: [
    { name: 'description', content: '這裡是 Nuxt 3 學習筆記 實戰部落格' }
  ],
  bodyAttrs: {
    class: 'test'
  }
})
</script>

useHead() 函數接受參數的類型如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
useHead(meta: Computable<MetaObject>): void

interface MetaObject extends Record<string, any> {
  charset?: string
  viewport?: string
  meta?: Array<Record<string, any>>
  link?: Array<Record<string, any>>
  style?: Array<Record<string, any>>
  script?: Array<Record<string, any>>
  noscript?: Array<Record<string, any>>
  titleTemplate?: string | ((title: string) => string)
  title?: string
  bodyAttrs?: Record<string, any>
  htmlAttrs?: Record<string, any>
}
  • charset:指定 HTML 的字元編碼,預設為 utf-8
  • viewport:設定網頁的可見區域,預設為 width=device-width, initial-scale=1
  • meta:接受一個陣列,陣列中的每個元素,都將會建立一個 <meta> 標記,元素中物件的屬性將對應至 的屬性。
  • link:接受一個陣列,陣列中的每個元素,都將會建立一個 <link> 標記,元素中物件的屬性將對應至 的屬性。
  • style:接受一個陣列,陣列中的每個元素,都將會建立一個 <style> 標記,元素中物件的屬性將對應至 的屬性。
  • script:接受一個陣列,陣列中的每個元素,都將會建立一個 <script> 標記,元素中物件的屬性將對應至 的屬性。
  • noscript:接受一個陣列,陣列中的每個元素,都將會建立一個 <noscript> 標記,元素中物件的屬性將對應至 的屬性。
  • titleTemplage:接受一個字串或函數,用來動態的設定該頁面元件的網頁標題
  • title:在頁面元件設置靜態的網頁標題
  • bodyAttrs:接受一個物件,設置網頁中 標籤的屬性,物件中的屬性將對應至 的屬性。
  • htmlAttrs:接受一個物件,設置網頁中 標籤的屬性,物件中的屬性將對應至 的屬性。

自訂 Metadata

使用 useHead() 函數設定屬性所傳入的字串或物件可以是具有響應性的變數,當數值變動時,相對應的數值也會一同響應,例如下面的程式碼,可以動態的變更標題或描述。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<script setup>
  const title = ref('Nuxt 3 Blog')
  const description = ref('這裡是 Nuxt 3 學習筆記 實戰部落格')

  useHead({
    title,
    meta: [
      {
        name: 'description',
        content: description
      }
    ]
  })
</script>

添加動態網頁的標題

我們可以在 app.vue 添加如下程式碼,titleTemplage 中的 %s 將會帶入目前頁面 title 或更上層頁面的 title 屬性:

app.vue
1
2
3
4
5
6
7
8
<script setup>
const title = ref('Nuxt 3 Blog')

useHead({
  title,
  titleTemplate: '%s - 首頁',
})
</script>

可以看到網頁的標題變為「Nuxt 3 Blog - 首頁」。

你也可以傳入一個函數來動態的添加網頁的標題。

app.vue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<script setup>
const title = ref('Nuxt 3 Blog')

useHead({
  title,
  titleTemplate: (title) => {
    return title ? `${title} - 首頁` : '首頁'
  }
})
</script>

添加外部 CSS

我可以使用 useHead() 來添加 Google 字體至 link 屬性之中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<script setup>  
  useHead({
    link: [
      { 
        rel: 'preconnect', 
        href: 'https://fonts.googleapis.com'
      },
      { 
        rel: 'stylesheet', 
        href: 'https://fonts.googleapis.com/css2?family=Roboto&display=swap', 
        crossorigin: '' 
      }
    ]
  })
</script>

添加第三方的 JavaScript

你也可以使用 useHead() 來插入第三方的 JS。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<script setup>
  useHead({
    script: [
      {
        src: 'https://third-party-script.com',
        body: true
      }
    ]
  })
</script>

在模板中使用相對應的元件設置屬性及標籤

Nuxt 提供 <Title><Base><Script><NoScript><Style><Meta><Link><Body><Html><Head>,我們可以直接在元件中模板 (Template) 使用這些元件進行設定網頁的屬性及標籤。

因為這些元件名稱與原生 HTML 標記相似,所以記得在模板中使用要開頭為大寫

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <div>
    <Head>
      <Title>{{ title }}</Title>
      <Meta name="description" :content="description" />
      <Style type="text/css" children="body { background-color: green; }"></Style>
    </Head>
    <h1>{{ title }}</h1>
  </div>
</template>

<script setup>
const title = ref('Nuxt 3 Blog')
const description = ref('這裡是 Nuxt 3 學習筆記 實戰部落格')
</script>

definePageMeta()

我們其實在前面就有使用過 definePageMeta() 來設置頁面的布局或中間件,在各個頁面之中,我們也可以使用 definePageMeta() 來設置一些屬性與 useHead() 進行搭配。

例如,我們的 app.vue 使用 useHead 設定的一個 meta,並且是根據 route.meta.title 所動態的設定。

app.vue
1
2
3
4
5
6
7
<script setup>
const route = useRoute()

useHead({
  meta: [{ name: 'og:title', content: `${route.meta.title} | Nuxt 3 Blog` }]
})
</script>

我們在 pages/articles.vue 添加如下程式碼:

pages/articles.vue
1
2
3
4
5
<script setup>
definePageMeta({
  title: '所有文章'
})
</script>

當我們瀏覽 /articles 頁面時,Nuxt 在伺服器端就幫我們渲染好 og:titlemeta 了。我們可以透過檢視原始碼的方式來看看效果。

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


Public 與 Assets 資源目錄(Nuxt 3 的靜態資源)

在網站的開發過程中,多少會使用到圖片、樣式或設定字體,而當這些檔案若沒有外部連結或某些需求下需要包含在專案內做使用,最後連帶這些檔案一起部署。在使用 create-vue 建立的 Vue 的專案下包含了 public 與 src 內的 assets 目錄,這兩個目錄都是可以放置這類不常變動的靜態資源,但這兩個目錄各自因為一些特性,建議放置的檔案類型依據使用目的有所區別。

Nuxt 3 使用專案下的兩個目錄來提供使用者處理圖片、樣式或字體,這兩個目錄分別為 publicassets

public 目錄

在 Nuxt 3 的專案根目錄下,存在一個名為 public 的目錄,這個目錄如同 Vue 中的 public 目錄或 Nuxt 2 中的 static 目錄。這個目錄下的檔案,將會由 Nuxt 直接於網站的根路徑,例如 / 提供存取。

例如建立 public/robots.txt 將可以使用 http://localhost:3000/robots.txt 存取。

通常我們會在 public 目錄放置不常更動的檔案,或是需要保留檔案的名稱,例如 robots.txt 就需要一個固定的名稱,才能正確的被搜尋引擎的爬蟲所解析再決定檢索的規則,抑或 sitemap.xmlfavicon.ico 檔案等,都很適合放置在 public 目錄。

你可能也會想,那圖片或 CSS 樣式,我也不常變動,難道就不能放置在 public 目錄嗎?

行,當然行,哪次不行!

舉個例子,我們將這張圖片放置於 public 目錄下並命名為 bg.png,專案目錄下其他檔案就先不列,整個結構大概長的像下面這樣。

1
2
3
4
nuxt-app/
├── public/
│   └── bg.png
└── app.vue

根據規則,我可以使用 http://localhost:3000/bg.png 存取,如下圖我們確實成功的能存取到圖片。

所以,我們在 app.vue 新增如下程式碼,使用 /bg.png 來等同訪問 public/bg.png 檔案:

app.vue
1
2
3
4
5
<template>
  <div>
    <img src="/bg.png" />
  </div>
</template>

也確實能在網頁中使用 <img src="/bg.png" /> 來顯示圖片。

既然 public 目錄已經能提供靜態資源的連結,那麼為什麼還有一個名為 assets 的目錄呢?接下來我們先介紹 assets 目錄,最後再來總結一下差異。

assets 目錄

Nuxt 3 使用 ViteWebpack 來建構專案進行打包,這些建構工具主要功能是用來處理 JavaScript 檔案將其編譯、轉換或壓縮等,但它們可以透過各自的插件Loader 來處理其他檔案類型的資源,例如樣式、字體或 SVG 等。

舉例來說,我們在 assets 下建立一個 Sass 的樣式,當這個 Sass 檔案被載入使用,就會經過插件Loader 來進行 CSS 的預處理及編譯,最終產生一個 CSS 檔案,也可以針對單純的 CSS 檔案進行壓縮。又或者說,當使用 <img>src 屬性設定載入我們放置在 assets 的圖片或圖示,最終需要轉換為 Base64 編碼而不是產生一個連結,我們也需要依賴建構工具插件的擴展來幫助。

其主要的目的,不外乎就是為了效能與解決瀏覽器的快取。放置在 assets 目錄下的檔案,可能會被插件Loader 進行轉換或壓縮,最終產生出來的檔案也具備連結可以進行存取。

如果使用 public 目錄下導出的 /bg.png,就算我們替換了 bg.png 圖片,可能就會因為檔案根據檔案名稱被瀏覽器快取住,導致前端還是看到舊的檔案。我們在使用 assets 目錄下的檔案時雖然檔名都是 bg.png,但在建構時產生的檔案通常會夾帶 Hash,例如:bg.16a2f98c.png,如此一來我們每次更新圖片,都都會隨機的產生一組 Hash 就能防止瀏覽器快取,導致好像網站更新失效的錯覺。

舉例來說,我們以相同的圖片,改放置於 assets/bg.png

1
2
3
4
nuxt-app/
├── assets/
│   └── bg.png
└── app.vue
app.vue
1
2
3
4
5
<template>
  <div>
    <img src="~/assets/bg.png" />
  </div>
</template>

在開發環境 ~/assets/bg.png 會轉換為 /_nuxt/assets/bg.png,實際上也能使用 http://localhost:3000/_nuxt/assets/bg.png 進行訪問。

除了 ~/assets 可以對應 assets 目錄外,也可以參考官方的 alias 更多或自訂別名來使用相對路徑。

但當你的網站部署完成或啟動建構出來的專案,會發現圖片檔名包含了 Hash,例如 /_nuxt/bg.0a299ea1.png

最後我們以最終建構出來的專案結構對比如下圖,在 public 目錄下的檔案,會原封不動的照搬至 .output/public 下,也就可以使用 /bg.png 存取;而放置在 assets 下的檔案,可能會被處理加工,最後檔案名稱會加上一個 Hash 並放置在 .output/public/_nuxt 下,也就需要使用 /_nuxt/bg.0a299ea1.png 存取。

總結來說多數情況建議把靜態資源放置在 assets,也因為靜態資源多為 SFC 所使用,通常也會放置最多檔案,最後建構時也會因為建構工具的設定來進行轉換或壓縮,最後添加 Hash 來提供存取,也因為每次建構產生的檔案都不一樣,所以不適合直接以完整的 URL 供外部連結使用。而當有些例外情況不適合,例如 robots.txtsitemap.xml 這類的檔案不需要經過額外處理且需要絕對路徑保持檔案名稱,那就得放置在 public 目錄下提供存取。


發布網站前的建構打包 (Build) 與靜態網站生成 (Static Site Generation)

當網站開發完成或有導入 CI/CD,在準備發布網站前,我們會將 Nuxt 網站的開發專案透過 Nuxt 提供的指令,我們可以來建構出正式環境所需要的版本,這個建構的過程你可以理解為專案將會打包需要的依賴套件、編譯與轉換相關的 Vue SFC 與樣式等,甚至幫你壓縮這些檔案等許多步驟,這些繁瑣的過程都透過 Nuxt 的建構指令來幫你完成,你可以結合一些設定參數與指令,來幫助你建構出正式環境所需要的網站專案,最後再進行部署的動作。

Nuxt 3 專案如何建構正式環境版本?

當我們建立一個乾淨的 Nuxt 3 專案,可以使用 package.jsonscripts 內的指令來進行建構或預渲染產生靜態頁面:

1
2
3
4
5
npm run build
npm run generate
# or
# yarn run build
# yarn run generate

或者你也可以使用 Nuxt CLI 來達到一樣的效果:

1
2
npx nuxi build
npx nuxi generate

generate 指令會觸發 build 的指令並帶有 prerendertrue 的參數,最終產生出來的目錄可以直接進行部署,不需要再執行一次 build 的指令。

使用預設配置進行建構 - 建構通用渲染 (Universal Rendering) 的網站

這邊我們以 【Nuxt3】實戰練習 - 實作部落格 所建立的部落格作為範例,來嘗試以預設的配置進行建構,目前 Nuxt 3 預設 nuxt.config.tsssr 屬性為 true,也就使用通用渲染 (Universal Rendering)

我們進入專案目錄,執行下列指令:

1
2
3
npm run build
# or
# yarn run build

不需要在額外的配置或步驟,Nuxt 就會自動幫我們打包並建構出可以部署的網站。

建構完成後會在專案目錄下多出一個 .output 目錄,public 就是在網站根目錄直接公開打包後的 JS、CSS 圖片等相關檔案。由於通用渲染使用 Nitro 作為服務引擎,所以會需要使用 Node.js 來啟動我們的服務,所以 server 則是會放置伺服器端的 Nitro、Server API 處理邏輯等。

我們可以先在本地執行下列指令來預覽建構出來的網站與 Nitro 是否能運作正常。

1
node .output/server/index.mjs

當確認沒問題後,就能以 .output 目錄進行網站部署。

使用預渲染建置全靜態頁面網站

我們可以在 Nuxt 3 建構時期來進行預渲染 (pre-rendering),將專案內需要打 API 請求及動態渲染元件的地方,先渲染生成出 HTML 網頁,這個過程也稱之為靜態網站生成 (Static Site Generation),進而建構出全靜態頁面的網站。

執行下列指令進行靜態頁面生成:

1
2
3
npm run generate
# or
# yarn run generate

generate 指令會觸發 build 的指令並帶有 prerendertrue 的參數,最終會提示產生的目錄 .output/public 可以用來部署至靜態託管伺服器。

當建構完成後,會在專案目錄下多出一個 .output 目錄同時也會有 dist 目錄,可以發現 .output/public 目錄內容與 dist 目錄相同。

靜態頁面的生成因為 Nuxt 使用基於爬蟲的技術為每個頁面產生 HTML 與 Payload 檔案,可以發現產生的靜態頁面目錄結構,也對應著我們專案的路由頁面,而頁面元件資料夾內會由一個 index.html_payload.js 組成。

下圖所產生的結構,是因為我們的部落格資料庫內已經有三筆文章資料,/articles 頁面會取得所有的文章,並列出對應著 /articles/1/articles/2/articles/3 目錄,每個頁面內也都包含預先從 Server API 請求好的文章資料並存於 _payload.js 之中。

至此我們就可以將 .output 目錄部署至伺服器或靜態託管服務,而且也不需要在使用 Node.js 伺服器來服務這些靜態檔案。

手動設定預渲染的路由規則

我們使用 generate 指令所產生的全靜態頁面網站,是基於 Nuxt 的爬蟲技術來分析頁面,如同前個例子文章頁面的產生所描述,如果我們的資料庫存在著 200 筆資料,但 /articles 頁面所打的 API 總是只回傳最新的 10 筆,那麼那些沒有路由連結可以連結過去的文章頁面,就無法被 Nuxt 的爬蟲所分析到,也就無法產生對應的靜態頁面。

為了解決這個問題,我們可以藉由配置 Nitro 的預渲染路由,來手動的設定哪些頁面要進行預渲染產生靜態頁面,或者忽略產生靜態頁面。

nuxt.config.ts
1
2
3
4
5
6
7
8
9
export default defineNuxtConfig({
  nitro: {
    prerender: {
      ignore: [],       // 忽略特定路由不進行預渲染
      routes: [],       // 指定路由進行預渲染
      crawlLinks: true  // 啟用 Nuxt 爬蟲蒐集頁面連結來進行預渲染
    }
  }
})

當然,也可以搭配 buildgenerate 指令做使用,但會稍微有些不一樣。

build

例如:設置如下,並使用 npm run build 指令。

1
2
3
4
5
6
7
export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/articles', '/articles/2']
    }
  }
})

那麼建構出來的通用渲染網站,就會包含部分頁面是已經預渲染好的靜態頁面。

generate

當我們使用 generate 時,因為是進行全站的靜態頁面生成,所以基本上涵蓋了所有路由頁面,並且 nitro.prerender.crawlLinks 屬性預設為 true 會啟用 Nuxt 爬蟲來蒐集整的網站的路由頁面,進而開始預渲染產生靜態頁面。

例如:設置如下,並使用 npm run generate 指令。

1
2
3
4
5
6
7
8
9
export default defineNuxtConfig({
  nitro: {
    prerender: {
      ignore: ['/login', '/register'],
      routes: ['/articles/2'],
      crawlLinks: true
    }
  },
})

那麼建構出來的 .output 目錄,就會因為設置了 nitro.prerender.ignore 而忽略 /login/register 頁面的靜態生成 ,而 /articles/2 在網站中因為沒有任何頁面能連結到此,所以 Nuxy 爬蟲無法蒐集到,所以可以手動添加進 nitro.prerender.routes 之中。

如果我們將 nitro.prerender.crawlLinks 屬性設為 false,那麼在產生靜態頁面的時候,就算 /articles 包含了所有文章的連結,Nuxt 爬蟲也就不會蒐集連結來產生靜態頁面,而是僅依照專案目錄下的 pages 所產生出的路由但不包含 [id].vue 這類動態匹配的路由來產生靜態頁面。

建構僅在客戶端渲染的網站

同樣的使用部落格作為範例,來嘗試將 nuxt.config.tsssr 屬性設為 false,也就讓網站僅在客戶端渲染 (Client-side Only Rendering)

執行下列指令進行建構:

1
2
3
npm run build
# or
# yarn run build

建構完成後,會在專案目錄下多出一個 .output 目錄,可以發現 public 目錄結構與通用渲染建構出的不大一樣,多出了像 Vue 專案建構完成後的 index.html 檔案,來作為顯示的容器。

這邊比較需要留意的地方是,當我們將 ssr 屬性設為 false,也還是會在 .output 建構出 server 目錄,也擁有著 .output/server/index.mjs

那這不就意謂著我還是得有 Node.js 服務才能部署嗎?其實你可以這麼理解,當我們設定 Nuxt 僅在客戶端進行渲染,但 Nuxt 專案中仍然可能有 Server API,這個屬於伺服器端的 API,總不可能一同打包至前端去做架設吧!所以使用 npm run build 建構出來的專案,都會使用 Nitro 引擎來啟動服務,若有 Server API 的處理邏輯,則會連帶打包進 .output/server 目錄之下。

如果你確定 Nuxt 的專案內沒有自己實作的 Server API,全部是依賴非專案內的 Server API,那麼你可以直接將 public 目錄,視為 Vue 建構出的 dist 目錄來進行部署,因為選擇不再需要 Nitro 引擎來為我們提供 Web Server 的服務,且專案內的前端也都打包完在 public 下可以視為 SPA 網站。

整理這些排列建構參數的組合

相信看到這裡,可能對建構與產生靜態頁面有一點混亂了,這邊稍微整理了一下,大家可以再依據需求來啟用相對應的配置:

npm run build + ssr: true

  • build + ssr: true

Nuxt 3 預設的建構參數,使用通用渲染 (Universal Rendering) 模式

  • build + ssr: true + nitro.prerender.crawlLinks: true

使用通用渲染模式,頁面中需要打 API 動態產生的連結而被 Nuxt 爬蟲所蒐集到的頁面將被預渲染成靜態頁面。

  • build + ssr: true + nitro.prerender.routes + nitro.prerender.crawlLinks: false

使用通用渲染模式,但指定頁面預渲染成靜態頁面。

npm run build + ssr: false

  • build + ssr: false

僅在客戶端渲染 (Client-side Only Rendering) 的模式,部署時同樣需要使用 server 中的 index.mjs 來啟動 Nitro 伺服器,這樣才能正確的運作 Server API;除非確認專案內完全無依賴專案內的 Server API,則可以直接將 .output/public 視為 Vue 的 dist 目錄進行部署。

npm run generate + ssr: true

  • generate + ssr: true

使用預渲染產生靜態頁面,預設產生全站靜態頁面,generatenitro.prerender.crawlLinks 屬性預設變為 true,整個網站包含 Nuxt 爬蟲所蒐集到的頁面將被預渲染成靜態頁面。

  • generate + ssr: true + nitro.prerender.ignore

使用 generate 已經包含整個網站,如果想要忽略某些頁面進行預渲染,可以添加路由至 nitro.prerender.ignore 中。但是需要注意,這些被忽略的頁面如果有使用專案內的 Server API 需求,可能就無法正確的在靜態託管平台運作,而外部的倒是不受影響。

  • generate + ssr: true + nitro.prerender.routes

雖然使用 generate 已經包含整個網站,但 Nuxt 爬蟲所蒐集到的頁面僅為頁面中產生的,如果連結不存在這些頁面內,只能直接從瀏覽器網址列輸入進入,那麼我們可以將路由添加至 nitro.prerender.routes 來額外補充需要預渲染的頁面。

  • generate + ssr: true + nitro.prerender.routes + nitro.prerender.crawlLinks: false

同樣會產生全站靜態頁面,但不使用 Nuxt 爬蟲所蒐集到的連結頁面,所以這些動態匹配或需要打 API 獲取資料再渲染的頁面,將不會是靜態頁面,因此也需要注意,如果這些頁面有使用專案內的 Server API 需求,可能就無法正確的在靜態託管平台運作,而外部的倒是不受影響。

其它跟建構有關的參數屬性可以再參考官方文件Vitewebpack 建構工具也都能額外設定參數屬性。

Nuxt 的建構指令 build 與產生靜態頁面的 generate 指令,讓我們可以依據不同的情境來決定渲染模式與預渲染的頁面,最終產生的 .output 目錄也會有不一樣的結構。預渲染的頁面與單純的 SPA 也要注意使否有使用到 Nuxt 專案建立的 Server API,再決定是否需要 Nitro 伺服器來啟動正式環境的網站服務。當一切就緒之後最後就能將輸出的目錄打包進行部署。


就剩最後一步了 - 部署 (Deployment)

Nuxt 3 的專案應用程式,可以部署在 Node.js 的伺服器上面,也可以將預渲染的靜態網站由靜態託管平台來服務,或這部署至無伺服器 (serverless) 或 CDN 環境上。

使用 Node.js 伺服器

Nuxt 3 的專案預設使用 Nitro 來作為服務引擎,所以我們在任何 Node.js 伺服器環境之下,基本上都可以啟動 Nuxt 建置出來的 Nitro Server。

當我們使用 npm run build 建構專案後,輸出的 .output/server/index.mjs 檔案,即是一個準備啟動 Node 伺服器的入口點。

我們可以透過下列指令來啟動我們建構好的專案。

1
node .output/server/index.mjs

執行後如下面圖示顯示,網站將於 Prot: 3000 進行服務監聽。

你可以設定下列環境變數來調整預設監聽的 PortHost

  • NITRO_PORTPORT:監聽的 Port (預設為 3000)
  • NITRO_HOSTHOST:服務的 Host (沒有預設值,但預設監聽所有的網路介面包含 IPv4 與 IPv6 的位址 )
  • NITRO_SSL_CERTNITRO_SSL_KEY:如果兩者皆設定,將以 HTTPS 模式下啟動伺服器,通常僅作為測試用途,因為建議將 SSL 憑證等設定於提供反向代理的服務,例如 NGINX 或 CloudFlare,Nitro 伺服器則執行在反向代理後面。

部署時,你可以將整個 .output 上傳至正式環境的機器上,並使用 Node.js 做執行,但為了防止我們的服務因為異常,導致 Node.js 服務意外崩潰,我們需要一個 ProcessDaemon,來將服務常駐,意外崩潰時能自動重啟,來維持整個網站的正常服務。

通常我們會在正式環境使用 PM2 來管理 Node.js 的服務,PM2 是維持一個 Process 執行的管理器,我們可以藉由 PM2 來啟動我們的 Nitro Server,當服務崩潰時能自動的重新啟動,以維持服務的正常運作,除此之外 PM2 可以啟用叢集 (Cluster) 的功能結合請求的附載平衡,來讓多核心的機器提升資源的利用率與效能,更還有監測多項數據等功能可供正式環境做使用。

使用 PM2

使用的方式也很簡單,首先使用 NPM 安裝 PM2,可以執行下列指令:

1
npm install -g pm2

在 Nuxt 專案目錄下建立 ecosystem.config.js 檔案,內容如下:

ecosystem.config.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module.exports = {
  apps: [
    {
      name: 'NuxtAppName',
      exec_mode: 'cluster',
      instances: 'max',
      script: './.output/server/index.mjs'
    }
  ]
}

接著我們就能使用 PM2 來執行我們的服務:

1
pm2 start ecosystem.config.js

你也可以在 ecosystem.config.js 中添加環境變數。

ecosystem.config.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
module.exports = {
  apps: [
    {
      name: 'NuxtAppName',
      exec_mode: 'cluster',
      instances: 'max',
      script: './.output/server/index.mjs',
      env: {
        NITRO_PORT: 3001,
        NITRO_HOST: '127.0.0.1'
      }
    }
  ]
}

製作 Docker Image

目前使用 Docker 來部署服務的也不在少數,個人也是採用容器化來啟動網站服務並整合 CI/CD 自動化的流程來建構與部署 Nuxt 3 專案,以下將列出自己使用的 Dockerfile,有興趣的可以再參考看看。

專案目錄下建立 Dockerfile 檔案,內容如下:

Dockerfile
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM node:16-alpine AS builder

RUN mkdir -p /nuxt-app
WORKDIR /nuxt-app
COPY . .

RUN npm ci && npm cache clean --force
RUN npm run build


FROM keymetrics/pm2:16-alpine

RUN mkdir -p /nuxt-app/.output
WORKDIR /nuxt-app/.output

COPY --from=builder /nuxt-app/.output .
COPY ./ecosystem.config.js /nuxt-app

ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000

EXPOSE 3000 

ENTRYPOINT ["pm2-runtime", "start", "/nuxt-app/ecosystem.config.js"]

打開終端機 (Terminal),並於專案目錄下執行下列指令:

1
docker build -t nuxt-app .

當開始製作 Docker Image 時,會依照 Dockerfile 來建置,主要分成兩個部分,首先我會先在 node:16-alpine 容器環境中執行建構 (build) 的指令,並將需要部署的目錄 .output 複製至 keymetrics/pm2:16-alpine 容器環境中,這樣最終使用 PM2 執行服務的 Image 大小就會稍微小一些。

當 Docker Image 製作完畢後,可以再藉由 docker run 或 docker compose 甚至 k8s 來啟動服務後,再由如 NGINX 提供反向代理服務連接到內部的 Nitro Server。

部署至 CloudFlare Workers

我們也可以將 Nuxt 部署到無伺服器 (Serverless) 的環境,官方提供了多種的預設部署可以做使用,我們就簡單以一個乾淨的 Nuxt 3 專案來做範例,嘗試部署到 CloudFlare Workers

首先,我們先準備一個 Nuxt 3 專案,在此我使用下列指令建立一個乾淨的 Nuxt 3 專案,並進入專案目錄安裝套件:

1
2
3
npx nuxi init nuxt-app
cd nuxt-app
npm install

使用 NPM 安裝 wrangler,它是用於建構 Cloudflare Workers 的命令列工具 (command line tool)。

1
npm install -g wrangler

你可能需要註冊一個 CloudFlare 的帳號,並使用下列指令來登入 CloudFlare 帳號並授權 Wrangler 來建立 CloudFlare Workers,我們選擇允許 (Allow) 即可。

1
wrangler login

於專案目錄下建立 wrangler.toml 內容如下:

wrangler.toml
1
2
3
4
5
6
7
name = "nuxt-app-hello"
main = "./.output/server/index.mjs"
workers_dev = true
compatibility_date = "2022-10-15"

[site]
bucket = ".output/public"

我們將 NITRO_PRESET 環境變數,設置為 cloudflare 並開始建構我們的專案。

1
NITRO_PRESET=cloudflare npm run build

接下來,可以選擇執行終端機提示的指令來啟動伺服器進行相關測試。

1
npx wrangler dev .output/server/index.mjs --site .output/public --local

當確認專案服務運作上沒問題後,可以執行下列指令進行發布。

1
npx wrangler publish

完成發布後,就會提示我們發布完成及服務的網址,例如 https://nuxt-app-hello.ryanchien8125.workers.dev,我們就可以瀏覽我們部署好的網站囉。

靜態的網站部署至 CloudFlare Pages

我們使用 Nuxt 3 可以預渲染全靜態的網站,因此也可以將專案部署至靜態託管平台,以 CloudFlare Pages 為例,部署的步驟如下:

首先,先執行下列指令進行全站的預渲染:

1
2
3
npm run generate
# or
# yarn run generate

使用 NPM 安裝 wrangler

1
npm install -g wrangler

你可能需要註冊一個 CloudFlare 的帳號,並使用下列指令來登入 CloudFlare 帳號並授權 Wrangler,我們選擇允許 (Allow) 即可。

1
wrangler login

建立一個 CloudFlare Pages 名為 nuxt-app-blog

1
wrangler pages project create nuxt-app-blog

使用下列指令,將預渲染的網站目錄進行部署,並指定專案名稱為 nuxt-app-blog

1
wrangler pages publish ./.output/public --project-name nuxt-app-blog

當部署完成後,就會得到一個連結來查看部署的靜態網頁,你也可以從 CloudFlare Pages 後台,看見網站網址的別名 Aliases 來使用自訂的網址。

以上是介紹 Nuxt 3 的幾個部署方式的例子,更多雲端平台上面的部署,也可以參考 NuxtNitro 官方文件。


上線前的測試項目

最後與大家分享一下網站專案上線前的幾個我會特別注意的幾個小細節,也歡迎大家一起來交流。

前端細節

  • 網頁的標題 (Title)
  • 使用之資源授權 (使用權、智財權)
  • 圖片替代文字
  • Favicon
  • HTML Meta data
  • Google Analytics

瀏覽器相容性測試

  • RWD
  • 跨瀏覽器
    • BrowserStack
    • Comparium
    • TestingBot

API 測試

  • 單元測試
  • 不同場景測試 (測試環境、正式環境)
  • 效能測試
    • JMeter
    • k6
  • 安全性測試

壓力測試

  • 模擬使用者或連線數
  • 觀察機器資源使用量
  • 資料庫連線數觀察
  • 持續提高模擬數量觀察分析
  • 分析每個請求於併發的平均響應時間
  • 是否請求中夾雜非預期之狀態碼 (HTTP Status Code)

API 或網站安全性

  • 參考 OWASP Top 10
  • SQL 參數化查詢
  • 資安公司滲透測試、紅隊演練

開發或除錯用的訊息

  • 警告訊息或異常訊息移除(console.log 或警告訊息等)
  • JavaScript Source Maps 記得移除
  • 敏感資訊記得移除 (開發用 Key、帳號密碼等)

網站相依性檢查

  • 使用到第三方網站資源及穩定性
  • 檢查所有網址皆能正常運作
  • 當第三方網站異常時應對措施

部署相關

  • 是否有高可用性 (HA)
  • Docker 或 Kubernetes 整合
  • 網域時效
  • SSL 證書日期
  • 防火牆或 WAF 設定
  • 內網或外網區隔

備份機制

  • 靜態有狀態資料 (使用者上傳的)
  • 資料庫資料

這邊主要與大家分享幾種 Nuxt 的部署方式,不管是部署在自己的機房主機或雲端,大家可以在挑選合適的解決方案,Nitro 也支援無伺服器 (Serverless) 等部署方式非常的方便,只是要特別注意到 Serverless 執行一些 binary 的問題,再挑選自己適合的部署與服務方式。

經過本篇文章,大家應該都對於 Nuxt 3 有初步的理解,接下來下一篇文章我們將會進入實戰練習,將會以 Nuxt 3 來實作一個部落格網站!


comments powered by Disqus