筆記首頁

Published

- 212 min read

Nuxt3研究

img of Nuxt3研究

Nuxt3 研究

一、Nuxt3 基礎介紹

Nuxt 的出現主要是解決原 Vue cli 專案只支援 SPA 而犧牲了 SEO 以及部份的使用者體驗以及效能 ( 需要等 Vue 在瀏覽器重新渲染出網頁畫面 ) 除此之外他也支援 通用渲染 ( universal rendering ) 簡單來說可以理解為 SSR +CSR,透過如此既可以解決純 CSR 的 SEO 差以及顯示慢的問題,也繼承了 CSR 原本的優點。

1-1. 甚麼是通用渲染 (Universal Rendering)

在了解甚麼是通用渲染前,必須先了解甚麼是 SSR 以及 CSR

  • 服務器渲染 ( SSR )

    在伺服器端 ( 後端 ) 將 HTML 模板以及 DB 的資料預先結合好並進行渲染後再丟到客戶端呈現

    也因為網頁已在 html 渲染好,所以伺服器在丟內容給前端時就是完整結合資料與 html 的內容 ( 可以在開發者工具中,network 分頁 Doc 類別內查到 伺服器丟來的 html 檔案資料 )

    特點: 由於所有渲染工作在伺服器內完成,所以對於伺服器的壓力較大 ( 比如同時有 10 個使用者同時發起瀏覽,就算是同一分頁,伺服器也要渲染 10 次 ),且每每轉換分頁或者只是變動局部數據,伺服器都得全部重新渲染後丟給瀏覽器,也佔帶寬,但相較於 CSR,SSR 的特色是顯示快

  • 客戶端 ( 瀏覽器 ) 渲染 ( CSR )

    利用 Ajax 這種非同步的模式,請求數據後再到客戶端拼接對應的頁面 ( 相較於伺服器於 SSR 會回傳完整個 HTML 結構與內容,CSR 的部分 HTML 內只有 head 而 body 內只會有一個 app tag,不會有其他內容 ) 再來依據該份檔案的依賴 js 再與伺服器請求對應的數據後,伺服器再回傳其所請求的數據,最後在客戶端拼接成完整的內容

    特點: 每次伺服器回傳的數據量不多,依照使用者瀏覽到哪再請求對應的數據,如此對於伺服器的壓力也會較小,但缺點是初次載入網站時的速度會較慢,且不利於爬蟲或搜尋引擎搜尋其內容 ( 也就是不利於 SEO )

  • 通用渲染 ( Universal Rendering )

    Nuxt 所採用的渲染方式

    • 第一次請求 ( 含刷新 ),伺服器回傳的是 SSR 伺服器已渲染好的頁面 ( 不限制首頁,而是第一次的請求對象 ),此階段 伺服器所回傳的只是渲染好的 HTML 網頁不具有互動性
    • 第二次請求時,則會下載 Vue 以及對應的 JS 邏輯程式碼並 hydration 完成讓網頁有更好的互動性,而之後使用者在網頁上的操作 (請求) 則使用 CSR 渲染,也就是 SPA

    特點: 解決了 CSR 首次渲染慢、SEO 差等問題,並結合了 SSR 與 CSR 的優點

    https://i.imgur.com/fVj1o8c.png[/img]

1-2. Nuxt2 與 Nuxt3 的差異

對於已使用過 Nuxt2 的開發者來說或許更在意的問題是 Nuxt3 雖說技術較新,那相較於 Nuxt2 來說有甚麼優勢呢? 或者準確來說他與 Nuxt2 相比具體有甚麼差異呢?

為說明這問題,筆者整理了以下對照表,並會在後續進行進一步的說明

特性名稱Nuxt2Nuxt3
底層技術Vue2、javaScriptVue3、typescript
渲染模式支援 通用渲染 ( CSR +SSR )、CSR支援通用渲染、混合渲染、邊緣渲染、CSR
狀態管理工具Vuex ( 內建預設 )useState ( 內建 ) 或 Pinia ( 需安裝 )
組件寫法只支援 option api ( Vue2 )支援 option api、composition api
全端框架否,只能撰寫前端的畫面邏輯是,因為導入 Nitro 使其可以寫前後端邏輯
打包工具WebpackVite
開發速度較慢 ( webpack 需 reload )較快 ( vite 支援 hot module replacement )

Nuxt3 相較於 Nuxt2 的優勢

  • Nuxt3 因為使用 Vue3 作為底層技術,因此可以享受到 Vue3 的新特性 & 功能紅利 ex: Composition api 的寫法、能夠使用 composables ( 組合式函式 ) 等
  • Nuxt3 使用 Vite 作為其打包的工具,支援 HMR,增加開發的效率以及減小打包後的檔案大小
  • Nuxt3 支援 typescript,由於 Nuxt3 底層就是使用 typescript 所撰寫,所以對於 typescript 也會有更好的支援度
  • Nuxt3 導入 Nitro 這 server engine 使 Nuxt3 成為全端的框架

註: 開發者若從 Nuxt2 要轉換成 Nuxt3 建議也需注意原習慣套件是否有支援 Nuxt3

二、安裝 Nuxt3 說明

  • 要使用 Node.js v18.0.0 以上

2-1. 安裝方法一 ( 官網建議的方式 )

官網: https://nuxt.com/docs/getting-started/installation

   npx nuxi@latest init <project-name>

註: 筆者安裝的是 nuxi@3.10.0 版本

  • cd 進該檔案後,安裝依賴

在 nuxt3 起始專案中的 tsconfig 中,其 extends 的 ts 設定內容可以自 nuxt 專案中的 .nuxt 資料夾中找到

https://i.imgur.com/vLz2cjG.png[/img]

  • 其繼承的 tsconfig.json 內容

       // ./.nuxt/tsconfig.json
    // Generated by nuxi
    {
      "compilerOptions": {
        "forceConsistentCasingInFileNames": true,
        "jsx": "preserve",
        "jsxImportSource": "vue",
        "target": "ESNext",
        "module": "ESNext",
        "moduleResolution": "Node",
        "skipLibCheck": true,
        "isolatedModules": true,
        "useDefineForClassFields": true,
        "strict": true,
        "noImplicitThis": true,
        "esModuleInterop": true,
        "types": [],
        "verbatimModuleSyntax": true,
        "allowJs": true,
        "noEmit": true,
        "resolveJsonModule": true,
        "allowSyntheticDefaultImports": true,
        "paths": { // 有各項 nuxt 中的符號以及所代表的意思定義
          "~": [
            ".."
          ],
          "~/*": [
            "../*"
          ],
          "@": [
            ".."
          ],
          "@/*": [
            "../*"
          ],
          "~~": [
            ".."
          ],
          "~~/*": [
            "../*"
          ],
          "@@": [
            ".."
          ],
          "@@/*": [
            "../*"
          ],
          "assets": [
            "../assets"
          ],
          "public": [
            "../public"
          ],
          "public/*": [
            "../public/*"
          ],
          "#app": [
            "../node_modules/nuxt/dist/app"
          ],
          "#app/*": [
            "../node_modules/nuxt/dist/app/*"
          ],
          "vue-demi": [
            "../node_modules/nuxt/dist/app/compat/vue-demi"
          ],
          "#vue-router": [
            "./vue-router-stub"
          ],
          "#imports": [
            "./imports"
          ],
          "#build": [
            "."
          ],
          "#build/*": [
            "./*"
          ],
          "#components": [
            "./components"
          ]
        }
      },
      "include": [
        "./nuxt.d.ts",
        "../**/*",
        "../node_modules/@nuxt/devtools/runtime",
        "../node_modules/@nuxt/telemetry/runtime",
        ".."
      ],
      "exclude": [
        "../node_modules",
        "../node_modules/nuxt/node_modules",
        "../node_modules/@nuxt/devtools/runtime/server",
        "../node_modules/@nuxt/telemetry/runtime/server",
        "../dist",
        "../.output"
      ]
    }

2-2. 安裝方法二

  • 新增專案檔案夾後,下以下指令

       yarn init
    yarn add nuxt

    註: 此方法的缺點所有配置以及預設資料都沒有,要全部自行手動配置 nuxt.config, tsconfig 以及自己配置 scripts 運行指令,好處是下載的版本相對較穩定

  • 自行於專案根目錄創建 nuxt.config.ts 補入以下代碼

       export default defineNuxtConfig({
      // My Nuxt config
      devtools: { enabled: true }
    })
  • 自行於專案根目錄創建 tsconfig.ts 補入以下代碼

       {
      // https://nuxt.com/docs/guide/concepts/typescript
      "extends": "./.nuxt/tsconfig.json"
    }
  • package.json 中配置 scripts 指令

       {
      "name": "nuxt3_test2",
      "version": "1.0.0",
      "main": "index.js",
      "license": "MIT",
      "scripts": { // 加上 scripts 並放置 dev 指令,
    		// 另外這邊的指令與上面 nuxi 不同的是 nuxi 建立的 nuxt 這邊是 "dev": "nuxt dev"
    		// 我們在這邊要用的是 "nuxi"
        "dev": "nuxi dev --open"
      },
      "dependencies": {
        "nuxt": "^3.8.2"
      }
    }

    執行 yarn dev 後,此專案內就會自行新增 .nuxt 的資料夾

  • 自行新增入口文件 app.vue

三、路由 ( router ) 設置 ( Pages 目錄 )

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

Nuxt3 的 pages 目錄內其會依照其資料結構自動產生對應的路由

藉由設定 nuxt.config.ts 的擴展 ( valid extension ) ,Nuxt 也可以解析在 pages 資料夾下的 .vue, .js , .jsx, .mjs, .ts ,.tsx ( 預設已經是可以解析了,所以不用另外自行設置 )

範例: https://nuxt.com/docs/examples/advanced/jsx

3-1. 基本路由介紹

  • 建立項目根組件 在 app.vue 中編輯

       <template>
      <div>
        <h1>項目根組件</h1>
        <!-- 加入 nuxt-page 等於 router-view -->
        <nuxt-page />
      </div>
    </template>
  • 路由組件

    • 在根目錄新建 pages 資料夾,並放入想要的頁面名稱

         // pages/about.vue
      <template>
        <div>
          <h1>我是 about 頁面</h1>
        </div>
      </template>
    • 此時回到項目本地運行的網頁,會是 500 的錯誤畫面,是因為 路由對應的 / 目前我們尚未設置,但我們只要更改路由到 about 頁面就會呈現 ex: http://localhost:3000/about

  • 帶目錄的路由組件

    創建 pages/users/create-or-edit.vue 對應的路由地址為 /users/create-or-edit

       //  pages/users/create-or-edit.vue
    <template>
      <div>
        <h1>我是 創建與編輯 頁面</h1>
      </div>
    </template>
  • 默認路由組件

    創建 pages/index.vue 作為對應路徑 / 的默認組件

       // pages/index.vue
    <template>
      <div>
        <h1>我是 默認組件</h1>
      </div>
    </template>
  • 父子路由組件

    父組件: 創建 pages/roles.vue,內部要放 <nuxt-page / > 作為父層範例組件

       // pages/roles.vue
    <template>
      <div>
        <h1>我是 roles 父組件</h1>
        <!-- 等於 router-view -->
        <nuxt-page />
      </div>
    </template>

    子組件: 創立與父組件同名的資料夾在 pages 下,以範例這邊是 pages/roles ,在這邊創立子組件,這邊用 admin.vue 作為範例,此時也就可以使用對應路由地址 /roles/admin 看到此子路由的元件範例

       // pages/roles/admin.vue
    <template>
      <div>
        <h2>我是 管理員</h2>
      </div>
    </template>

3-2. 路由導航

使用 nuxt-link 這個 tag 來替代原本的 a 超連結標籤,但 nuxt-link 經過編譯後仍會變成 a 標籤,所以在 css 設定時,可以直接指定 a 標籤來替 nuxt-link 作為樣式設定,之所以不建議在 nuxt 使用 a 標籤,使用 nuxt-link 可以完美的實現 CSR 的 SPA 設定,只會局部變化使用者需要的部分,但若直接使用 a 標籤的話,除了同樣轉換路由外整體頁面也會重新刷新/載入

  • nuxt-link 範例程式碼

       // app.vue
    <template>
      <div>
        <h1>項目根組件</h1>
        <!-- 等於 router-view -->
        <!-- nuxt-link 在解析後會變成 a 標籤 -->
        <nuxt-link to="/"> 首頁 </nuxt-link>
        <nuxt-link to="/users"> 用戶 </nuxt-link>
        <nuxt-link to="/users/create-or-edit"> 用戶添加或更新 </nuxt-link>
        <nuxt-link to="/roles/admin"> 角色-管理員 </nuxt-link>
        <nuxt-link to="/roles/normal"> 角色-普通用戶 </nuxt-link>
        <nuxt-link to="/about"> 關於 </nuxt-link>
        <nuxt-page />
      </div>
    </template>
    
    <style>
    /* 所以用 a 就可以針對 nuxt-link 進行樣式的設定 */
    a { 
      margin-right: 1rem;
    }
    
    </style>

3-3. 動態路由

假設我想以 id 來設定產品的話,就需要動態路由,其路由設定的方式是以 [id].vue 表示,以此產品範例為例,我就需要創建 pages/product/[id].vue 的檔案,而當使用路由 pages/product/1 或 pages/product/2 其都會導到同樣的 [id].vue 的組件

而其動態的 id 值,可以在 vue 組件使用 useRoute 創建的實體中取到

  • 動態路由 & useRoute 範例程式碼

       <script setup lang="ts">
    // 可以用 useRoute 創建的實體取到路徑上的 params 內含對應的 id 值
      const route = useRoute();
      console.log('route',  route)
    </script>
    
    <template>
      <div>
        <h1>
          產品頁面 id: {{ route.params.id  }}
        </h1>
      </div>
    </template>
  • 打印 useRoute 的實體內容

    https://i.imgur.com/NScfUU7.png[/img]

3-4. 自定義路由

若對於 nuxt3 依照 pages 資料夾的結構生成的路由不滿意,或是需要額外的路由需要自訂,可以參考以下的方式

  • 在專案根目錄新建一個 app 資料夾,並新建 router.options.ts,以下是該文件的代碼範例

       // app/router.options.ts
    const customRoutes = [
      {
        path: '/xxx',
        name: 'xxx',
        component:() => import('@/pages/users/index.vue')
      },
      {
        path: '/yyy',
        name: 'yyy',
        component:() => import('@/pages/users/create-or-edit.vue')
      },
      {
        path: '/zzz',
        redirect: '/users',
      }
    ]
    
    export default {
      // _routes 原本的路由信息
      routes:(_routes:any[]) => _routes.concat(customRoutes)
    }

四、組件 ( component ) 目錄

4-1. 元件的自動載入 ( 預設 )

在 Nuxt3 中,放在根目錄的 components 資料夾中的組件會自動載入,此功能不用特別設定預設就是如此

這邊舉個範例,若我有個專案目錄結構如下

   | components/
--| user/
----| dialog.vue
  • components/user/dialog.vue 組件
   //components/user/dialog.vue
<template>
  <div>
    <h3>我是 UserDialog 彈窗</h3>
  </div>
</template>
  • 實際使用方式是以其檔案路徑以及該檔案名稱進行拼接,並用大駝峰寫法呈現組件名就可以呼叫出該組件
   // pages/users/index.vue

<template>
  <div>
    <h1>我是 users 默認組件</h1>
		// 直接以大駝峰以及路徑名方式結合舊是組件名
    <UserDialog /> 
  </div>
</template>

補充1: Nuxt3 官方推薦的組件命名方法是元件的檔案命名與其實際調用的名稱相同,也就是說以上面的範例來說官方建議的元件命名方式如下 ( 若前綴路徑與檔案名稱有重複,則該重複部分會被 Nuxt 自動刪除 )

   | components/
--| user/
----| UserDialog.vue #官方建議

補充2: Nuxt3 的自動載入( auto-import ) 與全域註冊的差異

官網說明: https://nuxt.com/docs/guide/concepts/auto-imports

Nuxt3 的 auto-import 是兩個不同的概念,但共同的點是不需要再手動引入

  • 自動載入 ( auto-import )

    說明: 是指 Nuxt 會在編譯成 production 時將開發者有實際有使用的套件導入到對應的組建中 ( 也就是只引入有實際使用到的組件 )

    • auto-import 只適用於特定的目錄,例如 components, composables, utils 等,你不能在其他的目錄中使用 auto-import ( 在 server 目錄,Nuxt 會自動引入 server/utils 資料夾下的函式與變數 )
    • auto-import 只適用於特定的範圍,例如 pages, components, layouts 等,你不能在其他的範圍中使用 auto-import。
  • 全域註冊 ( global declaration )

    說明: 是將組件與函式全域掛載的方式,也就是不管有沒有用到的元件全都會在編譯階段打包進 production 中,其有常見的以下缺點

    • 全域註冊會增加應用的大小,因為所有的組件或函數都會被打包到最終的檔案中,即使有些沒有被使用到。
    • 全域註冊會減少專案的可讀性,因為你無法從代碼中看出哪些組件或函數是從哪裡來的,也無法使用 IDE 的自動完成或提示功能。
    • 全域註冊會增加應用的命名衝突的風險,因為你需要確保所有的組件或函數的名稱都是唯一的,否則可能會覆蓋或混淆其他的組件或函數。

4-2. 拿掉元件自動載入的前綴名稱

在稍早有提到,在 Nuxt 中元件的自動載入是由檔案存放路徑以及檔案名結合而成 ( 會刪掉重複的部分 ),但若不想要檔案路徑作為該元件的名稱前綴,可以使用以下設定

   // nuxt.config.ts
export default defineNuxtConfig({
  components: [
    {
      path: '~/components',
	    pathPrefix: false, // 加上這段就行
    },
  ],
});

當設定如此後,在引用該檔案時就不用加上該元件所在路徑作為前綴,直接引用檔案名即可 ex: components/layouts/default/header.vue 引用時就直接以 Header 就行,不用加上路徑作為前綴

4-3. 使用動態元件 ( is )

如果我們想在 Nuxt 中使用 Vue 提供的動態元件功能 \<component :is=”someCompoent”\>

這邊有兩個官方建議方法皆可以協助引入而不報錯

  1. 使用 Vue 提供的工具函式 resolveCompoent()

       // pages/index.vue
    <script setup lang="ts">
    	// 若使用 resolveComponent 須注意傳入的參數是字串非變數
    	const Header = resolveComponent('LayoutsDefaultHeader')
    	const clickable = ref<boolean>(false)
    </script>
    <template>
    	<component :is="clickable? 'div': Header" />
    </template>
  2. 將需要使用的元件自 #components 中引入來使用

       // pages/index.vue
    <script setup lang="ts">
    	// 直接使用元件變數傳入 & 使用
    	import { UserDialog } from '#components'
    </script>
    <template>
    	<component :is="UserDialog" />
    </template>

補充: 使用全域註冊來做為動態元件解法的替代

當然若是使用全域註冊的方式也可以不用使用官方建議的方法,從而達到讓元件動態載入的效果,但有鑑於全域註冊的弊病 ( 肥大、全域汙染、IDE 提示無法追蹤 ),這方法是官方相對不推薦的,如下

  • 全部元件全域註冊

    則需要在 nuxt.config.ts 中做以下設定

       // nuxt.config.ts
    export default defineNuxtConfig({
        components: {
    +     global: true, // 加上這
    +     dirs: ['~/components'] // 指定全部元件全域掛載
        },
      })
  • 部份元件全域註冊

    當然也可以只註冊部份元件做為全域註冊,這邊有兩個方式可以達到這效果

    1. 將需要全域註冊的元件放到 ~/components/global 這資料夾下
    2. 將需要全域註冊的元件 vue 檔加上後綴 .global.vue

4-4. 動態引入 ( lazy import )

官網: https://nuxt.com/docs/guide/directory-structure/components

若此某個元件使用者不一定會使用到,則可以考慮使用動態引入的方式引入元件 ( lazy-loading a component ),在 Nuxt3 使用 lazy import 只要在引用的元件檔名前面加上 Lazy 作為前綴

( 註: 使用動態引入可以有效的降低首次進入網頁時所需下載的 js 程式碼大小 )

   // pages/index.vue
<script setup>
const show = ref(false)
</script>

<template>
  <div>
    <h1>Mountains</h1>
    <LazyMountainsList v-if="show" />
    <button v-if="!show" @click="show = true">Show List</button>
  </div>
</template>

4-5. 直接引用

雖說 Nuxt3 中已有 auto-import 元件的功能,但也可以直接使用 #components 並從其引入目標元件,引入方式如下

   // pages/index.vue
<script setup lang="ts">
import { NuxtLink, LazyMountainsList } from '#components'

const show = ref(false)
</script>

<template>
  <div>
    <h1>Mountains</h1>
    <LazyMountainsList v-if="show" />
    <button v-if="!show" @click="show = true">Show List</button>
    <NuxtLink to="/">Home</NuxtLink>
  </div>
</template>

4-6. 自訂元件資料夾位置

Nuxt3 的自動引入元件是預設在 ~/components 資料夾底下,但若想要自訂元件自動引入的位置,也可以在 nuxt.config.ts 中設定 nuxt 所需掃描的位置

   // nuxt.config.ts
xport default defineNuxtConfig({
  components: [
    // ~/calendar-module/components/event/Update.vue => <EventUpdate />
    { path: '~/calendar-module/components' },// 預設名稱是 component 下路徑+檔名

    // ~/user-module/components/account/UserDeleteDialog.vue => <UserDeleteDialog />
    { path: '~/user-module/components', pathPrefix: false }, // 加上 pathPrefix: false 讓元件拿掉路徑前綴只剩原檔名

    // ~/components/special-components/Btn.vue => <SpecialBtn />
    { path: '~/components/special-components', prefix: 'Special' }, // 加上 prefix ,讓元件名稱加上指定前綴

    // It's important that this comes last if you have overrides you wish to apply
    // to sub-directories of `~/components`.
    //
    // ~/components/Btn.vue => <Btn />
    // ~/components/base/Btn.vue => <BaseBtn />
    '~/components'
  ]
})

4-7. Client Components

若該元件只希望其只在瀏覽器端被渲染,只要在該元件的字尾接上 .client 就行

   | components/
--| Comments.client.vue
   // pages/index.vue
<template>
  <div>
    <!-- 此元件只有在 client side 會被 render 出來 -->
    <Comments />
  </div>
</template>

注意: .client 的元件渲染會在元件被掛載 ( mounted ) 之後,也就是若要取用該元件的 DOM 或相關方法,記得在 onMounted 中使用 await nextTick 以確保能順利拿到

4-8. Server Components

Server Components 允許伺服器渲染指定元件在你的客戶端應用程式上,就算是產生靜態網站也能夠使用 server component,也正因如此讓 Nuxt 可以建立更複雜更動態的元件、伺服器渲染的 HTML 或是靜態標示

Server Components 可以搭配 Client Components 一同使用或是獨自使用都行

  • 4-8-1. 獨立伺服器元件 ( Standalone server components )

    獨立伺服器元件會在伺服器端被渲染 ( 也被稱為 Islands components ),而與他互動的邏輯或資料有變化時 ( ex: props 更新 ) 則此時會產生 request 給 server,server 在回傳渲染好的內容並更新給瀏覽器端

    目前伺服器元件尚在實驗階段,若想使用他們可在 nuxt.config 中做以下設定

       // nuxt.config.ts
    export default defineNuxtConfig({
      experimental: {
        componentIslands: true
      }
    })

    註: 這邊的只在伺服器渲染,只是指渲染的位置不同,但一樣會呈現在瀏覽器顯示上

    當設定好後,就可以在目標元件檔名加上後綴 .server.vue 那此元件就會是只在伺服器端渲染的元件了,範例如下

       //components/testServer/example.server.vue
    <template>
      <div>
        確認這段文字是否只有在 server 端渲染,爬蟲是否能爬到
      </div>
    </template>
    <script lang='ts' setup>
    console.log('確認這段文字是否只有在 server 端渲染')
    </script>
    <style lang='scss' scoped></style>

    然後掛載在頁面上 ( 為方便區分這邊掛載的對象是 CSR 的頁面 )

       // pages/roles/admin.vue
    <template>
    	...
      <TestServerExample />
    	...
    </template>

    實際操作時如下畫面,可以從瀏覽器 devtool 看到,此 CSR 頁面在拿此元件時,是透過伺服器端拿取 ( 從 VS Code terminal 窗口也可以看到此元件已在伺服器端被渲染 & 執行 )

    https://i.imgur.com/1GT2iVy.png

    補充1: 在 server component 中放置 client component

    注意: 此功能只有在 Nuxt3.9 上才能夠實現 ( 詳情請參考 連結 )

    需先在 nuxt.config.ts 做以下設置

       // nuxt.config.ts
    export default defineNuxtConfig({
      experimental: {
        componentIslands: {
          selectiveClient: true
        }
      }
    })

    經由設定後,你就可以在 server component 中,加入你想在客戶端渲染的元件,藉由在該元件的 html tag 屬性加入 nuxt-client,如下

       // components/SomeComponent.server.vue
    <template>
    	...
      <NuxtLink :to="/" nuxt-client />
    	...
    </template>

    補充2: Server Component Context

    在伺服器渲染 server component ( island component ) 也可以說是由該 server component ( <NuxtIsland> ) 發給伺服器一個 request fetch,伺服器再回傳 NuxtIslandResponse 的回覆 ( 若同在 SSR 頁面會看不到,因為此行為都會在 APP 內部完成; 但若在 CSR 頁面,則可以通過 devtool 的 network 頁簽看到其回復的內容 )

    這同時也表示了其有如下特性:

    • 一個新的 Vue app 會在伺服器端被建立以便建立 NuxtIslandResponse 的回覆
    • 一個新的 “island context” 會在渲染該元件時被建立且此 context 無法從我專案 app 的其他部分進行獲取 ( 也就是此 server component 與我們的 app 是彼此隔離的狀態 ) ,若要獲取該元件的 context 只能從該 server component 的內部使用 nuxtApp.ssrContext.islandContext 這方法獲取
    • 當渲染該 server component 時,我們安裝在專案的插件( plugins ) 也會再執行一次 ( 除非我們有設定 env: { islands: false } )
    • 若要在其中使用響應式的 slot 必須包在 div tag 中並使用 display: contents

    NuxtIslandRespond 示意

    https://i.imgur.com/Q04tW7e.png

    https://i.imgur.com/aLFPPBn.png

  • 4-8-2. 與 Client component 共同使用

    比如有個元件他某些部分有與 瀏覽器的 web api 有互動,但在 server 端我們又需要在那元件位置呈現需求資料或是架構,此時就可以使用 server component 與 client component 的組合,從另一個層面來看,也可以說其是同一個元件在 server 與 client 端的不同呈現方式,如下範例

    資料結構

       | components/
    --| Comments.client.vue
    --| Comments.server.vue

    使用時

       // pages/example.vue
    <template>
      <div>
        <!-- 這 comments 元件就會如我們在 components 資料夾內所定義的 .server .client 檔案,在 server 或 client 端各自呈現該元件的定義內容 -->
        <Comments />
      </div>
    </template>

4-9. <ClientOnly> tag

<ClientOnly> 是 Nuxt 提供另一種只在客戶端渲染的方法,比如有些共用元件在功能上在 server 與 client 上渲染都沒有問題 ( 此情況就不適合在該元件後綴加上 .client.vue 做區隔 ),但是只有在部分內容上,我們只要客戶端呈現就好,此時就可以用 <ClientOnly> 這 Nuxt 提供的元件包裝,或者是單純不想直接在該元件檔案上加上後綴 .client.vue 也可以使用這個功能,使用上如下範例

   // pages/example.vue
<template>
  <div>
    <Sidebar />
    <ClientOnly>
      <!-- 這 comments 只會在 client side 被渲染 -->
      <Comments />
    </ClientOnly>
  </div>
</template>

或者搭配使用 Nuxt 提供給 <ClinetOnly> 的屬性 fallbacktag 與 slot #fallback ( 連結 )

   // pages/example.vue
<template>
  <div>
    <Sidebar />
    <!-- fallbackTag 可以指定在 server 端對應此 clientOnly 欄位外層 html tag -->
    <ClientOnly fallbackTag="span">
      <!-- Comments 這元件只會在客戶端被渲染 -->
      <Comments />
      <template #fallback>
        <!-- fallback slot 的定義內容是在 server 端替代 Comments 所渲染的內容 -->
        <p>Loading comments...</p>
      </template>
    </ClientOnly>
  </div>
</template>

4-10. <DevOnly> tag

Nuxt 提供的只在開發環境 ( developement ) 渲染指定內容的方法,在 <DevOnly> 內部定義的內容會在打包 ( build ) 時拿掉

   // pages/example.vue
<template>
  <div>
    <Sidebar />
    <DevOnly>
      <!-- 被 DevOnly 包的 LazyDebugBar 元件只會在開發模式中被渲染顯示 -->
      <LazyDebugBar />

      <!-- 如果我們希望打包完的 production 環境能有替代的內容,可以參考下方寫法 -->
      <!-- 註: 這部份功能可用 指令 nuxt preview 去測試看是否有設定成功 -->
      <template #fallback>
        <div>我只在 production 出現</div>
      </template>
    </DevOnly>
  </div>
</template>

4-11. <NuxtClientFallback> tag

官網: https://nuxt.com/docs/api/components/nuxt-client-fallback

但若是你原是用 CSR 沒有顧慮到 SSR 的寫法,但後續被要求轉成 SSR 的寫法就很適合用此方法

Nuxt 提供 \<NuxtClientFallback\> 這方法,而只要在此 tag 所包的底下所有子層或內容只要在 SSR 時出現錯誤時,就可以觸發 NuxtClientFallback 的方法以跳過其內容的渲染或是替換成指定對應的 html tag 或是指定內容 ( 如此就不會因為出錯而無法顯示 )

需先設定 nuxt.config.ts

   // nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    clientFallback: true
  }
})

實際使用範例

   // pages/example.vue
<template>
  <div>
    <Sidebar />
    <!-- 使用 fallback-tag 可以替換在 ssr 出錯的替代 html tag -->
    <NuxtClientFallback fallback-tag="div" >
      <Comments />
      <BrokeInSSR />
			<!-- 同樣,若使用 #fallback 可以定義替換的內容 -->
			<template #fallback>
      <p>Hello world</p>
    </template>
    </NuxtClientFallback>
  </div>
</template>

4-12. npm 套件元件註冊 ( addComponent )

官網: https://nuxt.com/docs/guide/directory-structure/components#npm-packages

如若我們想使用在 node_modules 所定義的元件並希望其仍然被 auto-import ,而在其官網無與 Nuxt 搭配使用的描述可以參考官網所提供的以下範例

注意: 此方法是在 Nuxt 專案中的 modules 目錄下註冊

   // modules/register-components.ts
import { addComponent, defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
  setup() {
    // import { MyComponent as MyAutoImportedComponent } from 'my-npm-package'
    addComponent({
      name: 'MyAutoImportedComponent',
      export: 'MyComponent',
      filePath: 'my-npm-package',
    })
  },
})

使用範例

   // pages/index.vue
<template>
  <div>
    <!--  the component uses the name we specified and is auto-imported  -->
    <MyAutoImportedComponent />
  </div>
</template>

五、佈局 ( layout ) 目錄

5-1. 單一佈局

  • 若目前開發的網站只有一個 layout 全部頁面都會套用到,則建議直接在 app.vue 這入口檔案內 搭配 \<NuxtPage /\> 組件直接設置就好

       // app.vue
    
    <template>
    	<div>
    		<AppHeader />
    	    <NuxtPage /> // 或使用 <nuxt-page />
    	  <AppFooter />
    	</div>
    </template>

5-2. 多佈局

  • 但若有多個 layout,則建議在專案根目錄上建立 layouts 資料夾,並將所有 layout 都放在這裡,而最常被使用的 layout 文件則命名為 default.vue 作為專案的默認佈局

    • default.vue

         // layouts/default.vue
      <template>
        <div>
          <AppDefaultHeader />
          <h1>我是 default layout 默認組件</h1>
          <slot /> // 注意這邊是 slot
          <AppDefaultFooter />
        </div>
      </template>

    而這邊再見另一個 layout 作為範例,這邊將新增的 layout 命名為 custom

    • custom.vue

         // layouts/custom.vue
      <template>
        <div>
          <AppDefaultHeader />
          <h1>我是 custom layout 客製組件</h1>
          <slot />
          <AppDefaultFooter />
        </div>
      </template>
  • 筆者的 nuxt 是 3.8.2 要在入口文件 app.vue 處加上 NuxtLayout 元件 ( 舊版本的 nuxt3 不用 ) 如此 layout 才會正常顯示

    • NuxtLayout 直接使用則默認使用 layouts/default.vue

         // app.vue
      
      <template>
        <div>
      		// 沒有命名就是預設的 default layout
          <NuxtLayout> 
            <NuxtPage />
          </NuxtLayout>
        </div>
      </template>
      
    • NuxtLayout 搭配 name 也可以指定其他 layout

         // app.vue
      
      <template>
        <div>
          <NuxtLayout name="custom"> 
            <NuxtPage />
          </NuxtLayout>
        </div>
      </template>

六、SEO & Meta 設置

官網: https://nuxt.com/docs/getting-started/seo-meta

6-1. 使用默認設置

位置: 在 nuxt.config.ts 中全局設置

缺點: 無法使用動態數據 ( reactive data ) 設定

  • head 內可以配置的項目

    官網: https://nuxt.com/docs/getting-started/seo-meta#types

       interface MetaObject {
      title?: string
      titleTemplate?: string | ((title?: string) => string)
      templateParams?: Record<string, string | Record<string, string>>
      base?: Base
      link?: Link[] // 樣式 libraries 的連結
      meta?: Meta[]
      style?: Style[] //插入 css 代碼
      script?: Script[] // 外部的 js cdn 連結
      noscript?: Noscript[];
      htmlAttrs?: HtmlAttributes;
      bodyAttrs?: BodyAttributes;
    }
  • 實際使用範例 ( 範例是在 nuxt.config.ts 設定 )

       export default defineNuxtConfig({
      devtools: { enabled: true },
    	app: {
    	    head: {
    	      title: 'nuxt3 研究',
    	      meta: [
    	        {name:'keywords', content: 'Frontend, 前端, nuxt3' },
    	        {name: 'description', content: 'nuxt3 研究'}
    	      ],
    	      script:[ // 外部的 js cdn 連結
    	        {src: 'https://awesome-lib.js'}
    	      ],
    	      link:[ // 樣式 libraries 的連結
    	        {rel: 'stylesheet',href: 'https://awesome-lib.css'}
    	      ],
    	      style: [ //插入 css 代碼
    	        {children: ':root {color:red}', type: 'text/css'},
    	      ],
    	      charset: 'utf-8',
    	      viewport: 'width=device-width, initial-scale=1',
    	    }
    	  }
    })

6-2. 使用 useHead 設置

位置: 可全域設置 ( app.vue ) 或是在個別 vue 元件

優點: 可全局可個別元件設置,且可搭配動態數據 ( reactive data )

若在 nuxt.config.ts 與 vue元件中有重複配置屬性 ex: title ,則 vue 元件中的設定會覆蓋掉原 nuxt.config.ts 的設定,也就是單獨配置優先性會高於全局的配置

使用: useHead

   // app.vue
<script setup lang="ts">
useHead({
  title: 'My App',
  meta: [
    { name: 'description', content: 'My amazing site.' }
  ],
  bodyAttrs: { // 也可設定 body tag 的樣式
    class: 'test'
  },
  script: [ { innerHTML: 'console.log(\'Hello world\')' } ]
})
</script>
  • 若有各頁面標題都要套用的共同格式,則可在公用組件內設置 useHead 搭配 titleTemplate 使其他 title 直接套用該 title 格式

       // app.vue 
    <script setup lang="ts">
    useHead({
      // 1. 使用 string 作為直
      // `%s` 會被你在各頁面的 title 值替換
      titleTemplate: '%s - Site Title',
      // 2. 或者用 return 的 function 格式也可以
      titleTemplate: (productCategory) => {
        return productCategory
          ? `${productCategory} - Site Title`
          : 'Site Title'
      }
    })
    </script>
    • 範例程式碼

      比如我希望在我的專案的 title 都要套上 ‘nuxt3 研究 - ’ 作為前綴

         // app.vue
      <script setup lang="ts">
        useHead({
          titleTemplate: 'nuxt3 研究 - %s',
        })
      </script>

      其他元件範例 ( 這範例呈現的 title 就會是 ’nuxt3 研究 - 關於我 ’ )

         // pages/about.vue
      <script setup lang="ts">
        useHead({
          title: '關於我'
        })
      </script>
      
      <template>
        <div>
          <h1>我是 about 頁面</h1>
        </div>
      </template>

6-3. 使用元件式設置

位置: 可全域設置 ( app.vue ) 或是在個別 vue 元件

優點: 可全局可個別元件設置,且可搭配動態數據 ( reactive data )

同樣是單獨配置優先性會高於全局的配置

Nuxt 提供的預定義的元件有: \<Title\>\<Base\>\<NoScript\>\<Style\>\<Meta\>\<Link\>\<Body\>\<Html\> and \<Head>

   // app.vue
<script setup lang="ts">
const title = ref('Hello World')
</script>

<template>
  <div>
    <Head>
      <Title>{{ title }}</Title>
      <Meta name="description" :content="title" />
      <Style type="text/css" children="body { background-color: green; }" />
    </Head>

    <h1>{{ title }}</h1>
  </div>
</template>

6-4. useSeoMeta & useServerSeoMeta

  • useSeoMeta

    雖說在 Nuxt3 中我們可以使用 useHead 來設定 SEO 常用的 Meta tag 屬性內容,但是依照每個平台的繁雜性,在實際撰寫時難免會有寫錯,而一但寫錯 Meta 就無法正確顯示,為避免這類型的問題,Nuxt 善用 ts 的提示特性,收錄 100 多種 SEO 的型別內容,並將原本 name 與 content 的 meta 寫法簡化成 object 的 key 與 value,如此可以減少開發者設定錯誤的機會,以下將原本 useHead 的寫法以及使用 useSeoMeta 的寫法作為比較

    • useHead
       <script setup>
    useHead({
      meta: [
        { name: 'title', content: '網站標題' },
        { name: 'description', content: '網站描述' },
        { name: 'keywords', content: 'Nuxt,Vue' },
        { property: 'og:title', content: '網站標題' },
        { property: 'og:description', content: '網站描述' },
        { property: 'og:image', content: '/social.jpg' }
        { property: 'twitter:card', content: 'summary_large_image' }
      ],
    })
    </script>
    • useSeoMeta
       <script setup>
    useSeoMeta({
      title: 'Nuxt 3 學習網站',
      ogTitle: 'Nuxt 3 學習網站', // 原本的 name 為 og:title 這邊直接以小駝峰的方式簡寫成 object 的 key
      description: '使用 Nuxt 3 來開發一個網站',
      ogDescription: '使用 Nuxt 3 來開發一個網站',
      ogImage: '/social.jpg',
      twitterCard: 'summary_large_image',
    })
    </script>

    也可以改成動態資料寫法

       <script setup lang="ts">
    const data = useFetch(() => $fetch('/api/example'))
    useSeoMeta({
      ogTitle: () => `${data.value?.title} - My Site`,
      description: () => data.value?.description,
      ogDescription: () => data.value?.description,
    })
    </script>
  • useServerSeoMeta

    在大多數的情況下,Meta Tag 是不需要具有響應性的,因為提供的 SEO 標籤,搜尋引擎的爬蟲機器人也僅會掃描初始的設定值,所以如果你非常在意性能,可以使用 useServerSeoMeta 來進行 Meta 標籤得設定,如同字面上的意思僅會在 Server 伺服器端上執行,在客戶端不會有執行任何操作或回傳 head 物件,函式使用上的參數與 useSeoMeta 組合式函式完全一致

       <script setup>
    useServerSeoMeta({
      title: 'Nuxt 3 學習網站',
      ogTitle: 'Nuxt 3 學習網站',
      description: '使用 Nuxt 3 來開發一個網站',
      ogDescription: '使用 Nuxt 3 來開發一個網站',
      ogImage: '/social.jpg',
      twitterCard: 'summary_large_image',
    })
    </script>
  • 補充: 觀看 SEO og tag 的好用工具 Nuxt DevTools

    Nuxt DevTools 與 Chrome 插件的 Vue devtool 相似,或者說進階版更來的準確,基本上在安裝 Nuxt 時便會自動安裝 ( 依其官網介紹,其支援的是 Nuxt 3.1 以後的版本 ) ,正常情況下預設是啟用的狀態,我們可以在 nuxt.config.ts 中進行設置,如下

       // nuxt.config.ts
    export default defineNuxtConfig({
      devtools: { enabled: true }, // 在這邊開啟或關閉
    })

    而具體開啟 Nuxt DevTool 的方式是在開啟專案的中下方位置,會有 nuxt logo 的按鈕,點下就可以看到 Nuxt DevTools 的操作介面,如下方紅色箭頭所指處

    https://i.imgur.com/WLOt2nn.png[/img]

    開啟後會得到以下各種資訊的視窗,包含 vue 版本、頁面、元件、插件數等等

    https://i.imgur.com/1U4MOo2.png[/img]

    左方工具列代表的意思

    https://i.imgur.com/4z6Eobf.png[/img]

    參考來源: https://ithelp.ithome.com.tw/m/articles/10316577

6-5. Body Tags

可以利用 tagPosition: ‘bodyClose’ 屬性,方便有些需要放在 body 底部的第三方組件 cdn 做設置,範例程式碼如下

   <script setup lang="ts">
useHead({
  script: [
    {
      src: 'https://third-party-script.com',
      // valid options are: 'head' | 'bodyClose' | 'bodyOpen'
      tagPosition: 'bodyClose'
    }
  ]
})
</script>

七、靜態文件存放位置 Public & assets 目錄

Nuxt 有兩個地方可以放置靜態文件 public 與 assets 資料夾

7-1. public

  • public 此資料夾的內容功能同等於伺服器的根目錄 ( server root )
    • 假設我們在網站的根目錄有放置 img/vue.jpg 要取,我們可以直接使用 http://localhost:3000/vue.jpg 類似這樣的路徑直接取得

    • 若我們專案內要使用,也是以同樣的邏輯,是靜態路徑設置如下

         <img src="/img/vue.jpg" alt="vue.jpg"/>

7-2. assets

  • assets 此資料夾如之前在使用 vue 或 nuxt2 框架一樣,都是放我們需要打包工具 ( vite 或是 webpack ) 去處理的靜態資源
    • 但與 public 不同,assets 內的資料無法直接從根目錄以類似 /assets/my-file.png 的方式去獲取該圖片

    • 若我們專案內要使用,路徑設置如下

         <img src="~assets/img/bg-banner.jpg" alt="bg-banner">
    • 但若在 assets 中常放的 scss 需要做成全域變數去掛載時,則需要特別在 nuxt.config.ts 針對對應文件進行設定

      • 將 scss 對應文件設成全域樣式的範例

           # 安裝 sass
        yarn add -D sass
        • 我們創建了 assets/scss/_colors.scss 如下,並希望設成全域的 scss 變數

             $primary: #49240F;
          $secondary: #E4A79D;
        • 那在 nuxt.config.ts 中需要加入以下這段

             // https://nuxt.com/docs/api/configuration/nuxt-config
          export default defineNuxtConfig({
            devtools: { enabled: true },
          	// 需要加上以下這段
            vite: { 
              css: {
                preprocessorOptions: {
                  scss: {
                    additionalData: '@use "~/assets/scss/_colors.scss" as *;'
                  }
                }
              }
            }
          })

八、Runtime Config & App Config

官網: https://nuxt.com/docs/getting-started/configuration

在 nuxt 實際執行時,在相對應的頁面由於伺服器在提交給瀏覽器前,會先執行 1 次,實質在瀏覽器收到並由其專案的 js 接管後,又會再執行 1 次,也就是同份程式碼實際上被執行了 2 次

( vscode 下的 terminal 是 server 端被執行的內容; chrome 瀏覽視窗是 瀏覽器上被執行的內容 )

https://i.imgur.com/MlrflRe.png[/img]

8-1. runtimeConfig ( 執行時的設定)

說明: Nuxt3 提供了 runTimeConfig 的方式,讓我們更方便的來設定環境變數,因為某些環境變數是屬於機敏資料 ( ex: 第三方的 token 或 api 等 ) 其只在伺服器端運行,並不會在使用者端執行,此時就可以用 runtimeConfig 來進行執行時的控制 ****

特性: 在 runtimeConfig 設定的 key 值預設是只能在 server-side 可以拿到,但若設定在其內的 public 中,則該 key 值則不管在 server-side 或是 client-side 都能拿到

   // nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // The private keys which are only available server-side
		// 直接設置在  runtimeConfig 的 key 只有在 server 可以拿到
    abc: '取得 abc',
		// 直接設置在  runtimeConfig.public 的 key 值不管在 server 或是 browser 都會拿到
    // Keys within public are also exposed client-side
    public: {
      apiBase: 'http://ww.jd.com'
    }
  }
})
  • 實際在 vue 元件中取 runtimeConfig 中的 key 值方式 ( 使用 useRuntimeConfig() )
   <script setup lang="ts">
const runtimeConfig = useRuntimeConfig()
console.log('runtimeConfig',  runtimeConfig)
console.log('runtimeConfig.abc',  runtimeConfig.abc)
console.log('runtimeConfig.public.apiBase',  runtimeConfig.public.apiBase)
</script>

( vscode 下的 terminal 是 server 端被執行的內容; chrome 瀏覽視窗是 瀏覽器上被執行的內容 )

  • 這邊可以看到 runtimeConfig.abc 這 key 對應的值只有在 server 端 ( vscode 中的 terminal 中取得 ),但無法在 瀏覽器中的取得 ( undefined )
  • 這邊可以看到 runtimeConfig.public.apiBase 這 key 對應的值不管在 server 端 ( vscode 中的 terminal 中取得 ),以及瀏覽器中的皆可取得

https://i.imgur.com/9VeLDFo.png[/img]

有鑑於 nuxt 會在服務端以及瀏覽器端各自會執行一次 JS 程式碼的特性,這邊可以用以下設定做一個判斷,去限制程式碼只在 server 端或 client 端執行

   // nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
		isServer: true
  }
})

組件中設置

   <script setup lang="ts">
const runtimeConfig = useRuntimeConfig()
if(runtimeConfig.isServer){
  console.log('只在服務端執行')
} else {
	console.log('只在瀏覽器端執行')
}
</script>

8-2. 環境變數的覆蓋

Nuxt 在開發模式或執行時,已經有內建 dotenv,如果在專案目錄下添加了 .env,Nuxt 會在開發期間、建構時或產生靜態網站時,自動載入 .env 內的環境變數。

   // 在 nuxt 的 .env 文件中,其變數命名皆是 NUXT 開頭,且皆是以大寫作為撰寫
NUXT_API_SECRET='api_secret_token'
NUXT_PUBLIC_API_BASE='https://nuxtjs.org'

這兩個值,將被 dotenv 自動載入至 process.env 中,作為環境變數。

但若在 .env 中有與 runtimeConfig 設置一樣的變數,則其值會取代 runtimeConfig 中的設定

比如剛才的範例

   export default defineNuxtConfig({
  runtimeConfig: {
    abc: '取得 abc',
    public: {
      apiBase: '/api'
    }
  }
})

若在.env 中有相同的名稱如下

   // .env 的寫法
NUXT_ABC='覆蓋 abc'
NUXT_PUBLIC_API_BASE= 'https://yahoo.com.tw'

則 .env 中的變數值就會覆蓋掉 runtimeConfig 內的設置

原因是 Nuxt 在啟動時會先載入 nuxt.config.ts 內的 runtimeConfig 設定,建立出可被呼叫的 useRuntimeConfig() 對應的物件,並逐一將 key 全轉成大寫再加上 NUXT_ 前綴,作為環境變數,但若原 .env 中有設定一樣的環境變數時,新值就換蓋掉舊值

8-3. App Config

Nuxt 3 提供了一個 App Config 的配置方式,來提供給整個 Nuxt App 使用的響應式配置,並且能夠在生命週期執行之中更新它。

在 app.config.ts 中定義的變數為全局變數,且如 ref 或 reactive 一樣,其內部的值是響應式的值

  • 配置 app.config

    你也可以在專案目錄下建立 app.config.ts 來配置 App Config,,通常我們會添加像網站主題的主色等這類可以公開的配置,讓網站可以使用這個設置。

       // app.config.ts
    export default defineAppConfig({
      title: 'Hello Nuxt',
      theme: {
        dark: true,
        colors: {
          primary: '#ff0000'
        }
      }
    })
  • 使用 app.config 使用 app.config 的方式也很簡單,使用 useAppConfig() 就可以取出其內部的響應值

       // pages/index.vue
    <script setup lang="ts">
    const appConfig = useAppConfig()
    console.log(appConfig.title)
    </script>

Nuxt 3 提供了 Runtime Config 及 App Config 來讓我們將常用或預設設定應用在不同的情境

  • runtimeConfig: 使用時,我們僅需記得,不能公開的金鑰或敏感訊息 runtimeConfig 中而且不在 public 屬性內,runtimeConfig.public 通常放的是前後端會使用到且不常修改的常數
  • runtimeConfig.public: 通常放的是前後端會使用到且不常修改的常數。而 App Config 則是當伺服器端與客戶端需要使用的設置,如主題顏色、是否啟用深色模式等這類可以被使用者調整變動的且需要具有響應性

參考資料: https://ithelp.ithome.com.tw/articles/10303583

九、異步資料獲取 ( useFetch, useAsyncData )

官網: https://nuxt.com/docs/getting-started/data-fetching

在之前的 vue 開發中我們常用 axios 來串接後端的 api 以方便獲取對應資料,但在 Nuxt3 中已內建立 $fetch,讓我們不用特別另外安裝第三方套件來獲取後端資料。

除卻這層因素外,nuxt3 也特別內建獲取資料的常用組合式函數 useFetch 以及 useAsyncData 搭配 $fetch,讓開發者方便使用,這麼做的最主要的原因是 Nuxt 會在 server 以及客戶端渲染與執行頁面,也就是說若不使用 nuxt3 已經包裝好的 useFetch 以及 useAsyncData 組合函數,而單指依賴第三方套件( ex: axios ) 或是只使用 $fetch,會造成在伺服器已打過 api 但到達瀏覽器後又重複打 api 等情形,而使用 useFetch 以及 useAsyncData 可以確保若此 api 已在 server 端已經打過,則 nuxt 會以 payload 的形式 ( 可用 useNuxtApp().payload 此方式獲取 ) 將 api 回應的資料傳往瀏覽器頁面,以避免瀏覽器在渲染時 ( hydration ) 重複打 api 而拖慢效能等情形

而且 Nuxt 使用了 Vue 的 \<Suspense\> 元件,在非同步資料尚未完全獲取時,阻止路由載入的機制,而藉由 Nuxt 所提供的組合函數,也方便開發者調整此阻塞機制

  • useFetch

    使用此組合式函數是最直觀且最直接的方法來做為資料獲取使用,此組合式函式也是 useAsyncData 以及 $fetch 這兩者稍後我們會各別介紹的組合式函數進行包裝,使用上範例如下

       
    // app.vue
    <script setup lang="ts">
    // useFetch 第一參數是 url 其也會依照此 url 自動生成對應的 key
    // 第二參數是 option 但是否添加與否是可選的
    const { data: count } = await useFetch('/api/count')
    </script>
    
    <template>
      <p>Page visits: {{ count }}</p>
    </template>

以下就各別針對 $fetch, useAsyncData 以及 useFetch 這幾個 Nuxt 內建的函式進行進一步的說明

9-1. $fetch

Nuxt 在資料獲取的方式是導入 oftetch 這函式庫, 並將其包裝成 $fetch 的別名已全域方式引入,以方便使用者取用 ( 這也是 useFetch 此組合式函數包裝的一部分同時也是 useAsyncData 搭配的使用的函式 ),此功能方便開發者在開發時用於發送 HTTP 的請求,而不用再另行引入第三方套件

其使用語法如下

   // url 是必要參數,options 則是非必要參數
$fetch(url, options)
  • url 為必要參數,可以直接以 $fetch('/api/count') 建立一個 GET 請求,發送至 /api/count 後會返回一個 Promise,完成後我們就可以接收回傳的資料
  • options 為非必要參數,若要參考 options 內部的寫法以及相關攔截器的功能可以參考 oftetch 這函式庫的 GitHUb repo 其內有更詳盡的說明

注意: 若只使用 $fetch 方法,則無法避免 api 在伺服器以及瀏覽器的重複呼叫,也無法避免路由在 api 尚未完全載入資料時進行路徑轉導 ( 原 \<Suspense\> 元件的功能 ),比較建議若要直接使用,則只在 CSR 的情境下使用,或者搭配 useAsyncData 使用

9-2. useAsyncData

使用 **useAsyncData** 組合式函數簡要來說其主要就是將非同步邏輯包裝後再回傳結果的功能,但 Nuxt 有將對應的非同步獲取資料的方法包裝進來,以避免其在伺服器與瀏覽器重複 call api 以及包含 Suspense 元件的功能以確保異步邏輯處理完畢後頁面才會執行對應的渲染 ( 阻塞路由 ),換句話說該頁面的元件會等 useAsyncData 呼叫完成後才會進行後續的渲染

  • useAsyncData type 如下

    大家可以搭配以下 type 內容與下方的傳入的參數與回傳值的說明,會更好理解

       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 傳入的參數說明

    useAsyncData 的組合函數中,能接收的參數有三個: key、handler、options以下針對這幾個參數進行說明

    • key: key 為唯一值,這是讓 nuxt 能夠確定並且確保資料不會被重複獲取,如果 key 相同就不會再發送相同的請求,若有需要重新獲取資料的內容,可以透過重新整理頁面 ( 由後端再次渲染獲取 ),或者裡用 useAsyncData 回傳的 refresh() 函數重新取得資料
    • handler: 相關獲取 & 處理 API 資料的邏輯可以放在這邊
    • options:
      • server: 是否能在伺服器端就執行此異步請求函式先獲取相關資料,預設是 true
      • lazy: 是否在載入對應路由時才開始執行此異步請求函式,預設是 false,所以會阻止路由載入直至請求完成後才開始渲染頁面
      • default: 設定此異步回傳資料的預設值在 lazy 為 true 時 ( 需要載入對應路由後才執行對應的異步請求函式 ),在畫面渲染以及異步尚未執行時,先有個預設值可以使用以及渲染顯示
      • transform: 對於 handler 回傳的結果進行加工
      • pick: 若 handler 回傳的資料內容是為一物件,只要從需求的 key 取出需求的對應資料 ex: 例如從 JSON 物件中只取其中的幾個 key 用以組成新物件
      • watch: 監聽響應式數據 ( ex: ref 以及 reactive ) 看是否有變化,若有變化時重新請求資料所用
      • initialCache: 預設為 true ,當第一次請求資料時,會將有效的 payload 視為快取,之後若之後觸發的請求是相同的 key ,則直接回傳快取的結果
      • immediate: 預設為 true,請求會立即觸發
  • useAsyncData() 的回傳值

    • data: 異步函數回傳的結果
    • pending: 以 boolean 表示是否目前正在獲取資料,若是則為 true
    • refresh / execute: 此為一個函式,可以用來重新執行 handler 內的函式,回傳新的資料,類似重新整理、重打 api 的概念,在預設情況下 refresh 被執行完成並回傳後才會再次執行
    • error: 資料獲取失敗回傳的物件
    • status: 針對目前獲取 data 狀態 ( idle 閒置的、pending、success、error )

    所以我們可以以解構的形式取出對應的資料如下

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

    useAsyncData 是經由包裝 handler 藉由 $fetch 來打 API ( 送出 HTTP 請求 ),除此之外也封裝了各種方法以方便開發者於各場景中使用。

   // useAsyncData 第一個參數是這函式的 key 
useAsyncData('獲取文章', () => $fetch('/posts', {
method: 'GET',
baseURL: 'https://jsonplaceholder.typicode.com',
server: true,
})).then(res =>{
  console.log(res)
})

9-3. useFetch

此組合式函式也是 useAsyncData 以及 $fetch 這兩者進行包裝的語法糖,其會針對 options 地內的 url 作為 key 生成 ( 也就是 useAsyncData 中所需要的 key 參數 )

  • useFetch type 如下

    大家可以搭配以下 type 內容與下方的傳入的參數與回傳值的說明,會更好理解

       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() 傳入的參數說明

    useFetch 的組合函數中,能接收的參數有兩個: url、options以下針對這幾個參數進行說明 ( 需要特別注意的是 options 部分是從 oftetch 套件與 useAsyncData 的選項所繼承 )

    • url: 獲取資料的 URL ( 若不另外設置其會轉化成 useAsyncData 的預設 key )
    • options ( 繼承自 oftetch 套件的部分 )
      • method: 發送 http 的請求方法 ex: GET, POST, DELETE
      • params: 查詢的參數 ( Query params )
      • body: 請求的 body 在傳入物件後其會自動變轉化成字串
      • headers: 請求的 headers
      • baseURL: 請求的 API 跟路徑 ( 網域 )
    • options ( 繼承自 useAsyncData 的選項 )
      • key: key 為唯一值,這是讓 nuxt 能夠確定並且確保資料不會被重複獲取,如果 key 相同就不會再發送相同的請求,若有需要重新獲取資料的內容,可以透過重新整理頁面 ( 由後端再次渲染獲取 ),或者裡用 useAsyncData 回傳的 refresh() 函數重新取得資料
      • server: 是否能在伺服器端就執行此異步請求函式先獲取相關資料,預設是 true
      • lazy: 是否在載入對應路由時才開始執行此異步請求函式,預設是 false,所以會阻止路由載入直至請求完成後才開始渲染頁面
      • default: 設定此異步回傳資料的預設值在 lazy 為 true 時 ( 需要載入對應路由後才執行對應的異步請求函式 ),在畫面渲染以及異步尚未執行時,先有個預設值可以使用以及渲染顯示
      • transform: 對於 handler 回傳的結果進行加工
        • 範例程式碼

             const { data: mountains } = await useFetch('/api/mountains', { 
            transform: (mountains) => {
              return mountains.map(mountain => ({ title: mountain.title, description: mountain.description }))
            }
          })
      • pick: 若 handler 回傳的資料內容是為一物件,只要從需求的 key 取出需求的對應資料 ex: 例如從 JSON 物件中只取其中的幾個 key 用以組成新物件
        • 範例程式碼

             <script setup lang="ts">
          /* only pick the fields used in your template */
          const { data: mountain } = await useFetch('/api/mountains/everest', {
            pick: ['title', 'description']
          })
          </script>
          
          <template>
            <h1>{{ mountain.title }}</h1>
            <p>{{ mountain.description }}</p>
          </template>
      • watch: 監聽響應式數據 ( ex: ref 以及 reactive ) 看是否有變化,若有變化時重新請求資料所用
        • 範例程式碼

             <script setup lang="ts">
          const id = ref(1)
          
          const { data, error, refresh } = await useFetch('/api/users', {
            /* Changing the id will trigger a refetch */
            watch: [id]
          })
          </script>

          但需要特別注意的是,監視響應式的數據並不會改變 URL 路徑內容,如下範例,就算是 id 有改變,其 URL 路徑仍會以原先 id 的路徑進行資料獲取,那是因為 URL 是在此函式被初次觸發時就已建立

             <script setup lang="ts">
          const id = ref(1)
          
          const { data, error, refresh } = await useFetch(`/api/users/${id.value}`, {
            watch: [id]
          })
          </script>

          但若你需要用響應式 URL ,則需要換個寫法,如下

             // Computed URL
          <script setup lang="ts">
          const id = ref(null)
          
          const { data, pending } = useLazyFetch('/api/user', {
            query: {
              user_id: id
            }
          })
          </script>
      • initialCache: 預設為 true ,當第一次請求資料時,會將有效的 payload 視為快取,之後若之後觸發的請求是相同的 key ,則直接回傳快取的結果
      • immediate: 預設為 true,請求會立即觸發
        • 範例程式碼 & 對應說明

          useFetch, useLazyFetchimmediate 被設置成 false ,代表不會立即執行,會等待額外的觸發動作 ex: 用戶互動、關聯的響應式數據的變動

          情境一: 若有異步請求需在特定響應式數據改變後再獲取這種常見的需求,有兩種解法

          • 解法一 : 使用 useLazyFetch 搭配 immediate 為 false 的方式

            當頁面改變時,組合函數內的 url 會先被建立以立即執行異步請求函式 ( immediate 預設為 true 的狀態 ),若希望組合函數在響應式數據改變後再執行異步請求函式,則可以將 immediate 改成 false,範例程式碼如下

               <script setup lang="ts">
            const id = ref(null)
            // 注意: 官網範例這邊是用 useLazyFetch,筆者推測如下
            // 使用 lazy 可以取消阻塞路由的功能,先載入頁面後再執行異步函式
            // 若不是用 useLazyFetch 而是用 useFetch 且 lazy 設為 false (預設)
            // 則其會在載入頁面前執行此非同步,也就是說響應式數據可能尚未被載入
            // 也就是若不設定 lazy 為 true 也只設定 immediate 為 false 無法達到 "待響應式數據改變後再執行異步的功能"
            const { data, pending, status } = useLazyFetch(() => `/api/users/${id.value}`, {
              immediate: false
            })
            </script>
            
            <template>
              <div>
                <!-- disable the input while fetching -->
                <input v-model="id" type="number" :disabled="pending"/>
            
                <div v-if="status === 'idle'">
                  Type an user ID
                </div>
                
                <div v-else-if="pending">
                  Loading ...
                </div>
            
                <div v-else>
                  {{ data }}
                </div>
              </div>
            </template>
          • 解法二 : 使用 watch 搭配 refresh 的方式

            參考自: https://nuxt.com/docs/getting-started/data-fetching#watch

               <script setup lang="ts">
            const id = ref(1)
            
            const { data, error, refresh } = await useFetch('/api/users', {
              /* Changing the id will trigger a refetch */
              watch: [id]
            })
            </script>

            但若你需要用響應式 URL ,則需要換個寫法,如下

               // Computed URL
            <script setup lang="ts">
            const id = ref(null)
            
            const { data, pending } = useLazyFetch('/api/user', {
              query: {
                user_id: id
              }
            })
            </script>

          情境二: 若需要使用者互動後再執行獲取,除了使用 immediate 為 false 外可以確保

    補充: 就算是有特別設定 transform 以及 pick 其在打初次發出請求 ( 伺服器端 ) 時,該對應回傳的資料仍與 api 原先回傳相同,但該資料值會調整 ( transform 或 pick 後 ) 再從伺服器端發到瀏覽器端

  • useFetch() 的回傳值

    • data: 異步函數回傳的結果
    • pending: 以 boolean 表示是否目前正在獲取資料,若是則為 true
    • refresh / execute: 此為一個函式,可以用來重新執行 handler 內的函式,回傳新的資料,類似重新整理、重打 api 的概念,在預設情況下 refresh 被執行完成並回傳後才會再次執行
    • error: 資料獲取失敗回傳的物件
    • status: 針對目前獲取 data 狀態 ( idle 閒置的、pending、success、error )
      • status 的詳細說明
        • idle 閒置,當 fetch (異步程式碼) 尚未被執行時
        • pending 當 fetch (異步程式碼) 已經開始但尚未結束
        • error 當 fetch ( 異步程式碼 ) 獲取資料失敗時
        • success 當 fetch ( 異步程式碼 ) 已成功完成
       <script setup lang="ts">
    const { data, error, execute, refresh } = await useFetch('/api/users')
    </script>
    
    <template>
      <div>
        <p>{{ data }}</p>
        <button @click="refresh">Refresh data</button>
      </div>
    </template>
   // 範例程式碼

const config = {
  baseURL: 'https://jsonplaceholder.typicode.com',
  headers: { authorization: 'love', 'content-type': '' },
  server: false, //如果是 true 則只會在 server 端執行
  onRequest({request, options}){ // 請求攔截器
    if(!options.headers){
      options.headers = {}
    }else {
      options.headers.authorization = 'i create authorization'
    }
  }, 
  onResponse({request, options, response}){}  // 響應攔截器
}

const {data} = await useFetch('/posts', {
  method: 'GET',
  params: {id: 3},
  // body: {},
  ...config
})

9-4. useLazyAsyncData

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

9-5. useLazyFetch

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

9-6. refreshNuxtData

前面有提到我們可以使用組合函數的回傳值內容的  refresh() 來重新獲取具有不同查詢參數的資料。

但除卻 refresh() 外,我們也可以透過 refreshNuxtData 這組合函數工具搭配其他組合函式 ( useAsyncDatauseLazyAsyncDatauseFetch 與 useLazyFetch ) 來重新打 api ( 使原本快取失效 )

   <template>
  <div>
    {{ pending ? 'Loading' : count }}
  </div>
  <button @click="refresh">Refresh</button>
</template>

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

// 使用相同 key count
const refresh = () => refreshNuxtData('count')
</script>

十、useCookies 介紹

官網連結: https://nuxt.com/docs/getting-started/data-fetching#pass-cookies-from-server-side-api-calls-on-ssr-response

在 server-side-rendering、Universal Rendering、Hybrid rendering,的情況下,有些 異步行為( $fetch ) 勢必在 server 端就已進行,但此時尚未能夾帶瀏覽器端使用者的 cookies 那勢必會導致部分功能的欠缺,而此章節就是來解決此問題

10-1. 獲取客戶端 Headers ( useRequestHeaders )

我們可以使用 useRequestHeaders 這個工具,讓我們尚在伺服器端的時候就能獲取到使用者的 proxy cookies

以下官網範例就是示範藉由使用 useRequestHeaders 以確保異步函式( $fetch )在伺服器端所夾帶的 header 會與使用者發送的 header 相同

   <script setup lang="ts">
// 若不指定 headers 內的 cookie 而是希望收到全部的 headers 值
// 可以直接用 const headers = useRequestHeaders()
const headers = useRequestHeaders(['cookie'])

const { data } = await useFetch('/api/me', { headers })
</script>
  • 但需要非常注意的是,需要請使用者只挑選必要的 header 作為 外部 api 的代理,請開發者只挑選那些真正需要的 headers,因為不是所有 headers 內容都合適被繞過,而此舉可能會導致些非開發預期的行為與結果
    • host, accept
    • content-length, content-md5, content-type
    • x-forwarded-host, x-forwarded-port, x-forwarded-proto
    • cf-connecting-ip, cf-ray
   補充: 這邊有另一個點是 token,一般來說我們 token 是放在請求攔截器中進行處理的
但是當強制刷新是不帶 token 的,此時有些做法就會將 token 放在 cookie 中做處理 

10-2. 從 Server-side 發 cookies 到瀏覽器的作法

此作法與 10-1 所提到的方式正好相反,是由 server 端帶 headers 內容到瀏覽器端

   // composables/fetch.ts
import { appendResponseHeader, H3Event } from 'h3'

export const fetchWithCookie = async (event: H3Event, url: string) => {
  /* Get the response from the server endpoint */
	// 由 nuxt server 端打異步取得 res 回應
  const res = await $fetch.raw(url)
  /* Get the cookies from the response */
	//  從 reponse 中取得 cookies
  const cookies = (res.headers.get('set-cookie') || '').split(',')
  /* Attach each cookie to our incoming Request */
  for (const cookie of cookies) {
		// 將對應 cookie 追加到 cookie 中
    appendResponseHeader(event, 'set-cookie', cookie)
  }
  /* Return the data of the response */
  return res._data // 再反饋給瀏覽器
}
   <script setup lang="ts">
// This composable will automatically pass cookies to the client
const event = useRequestEvent()

const { data: result } = await useAsyncData(() => fetchWithCookie(event, '/api/with-cookie'))

onMounted(() => console.log(document.cookie))
</script>

10-3. useCookie

Nuxt3 提供了一個組合式函數 useCookie() 讓我們就算在 SSR 的情況下,也方便能讀寫 Cookie,也方便在各元件、頁面、插件中使用

   const cookie = useCookie(name, options)
  • name 對應的也就是 cookie 的 key 值

  • options 可以設定對應的屬性如下

    • maxAge: 其值的單位為秒,也就是此 cookie 有效的時間範圍,預設是沒有設置,如果此欄沒有設置則會是以 session 作為有效期限 ( 當網頁關閉時消失 )

    • expires: 其值為一個 Date 物件作為過期時間,此項屬性是為向下兼容較舊的版本的瀏覽器使用,其功能與 maxAge 雷同,因此建議若有設置 expires,最好與 maxAge 設置一樣的時間 ( 預設是沒有設置,也就是關閉瀏覽器後就會被刪除 )

    • httpOnly: 其值為 Boolean,預設為 false,此為傳送機敏訊息時使用,要特別注意的是若其值為 true 時,也代表 client-side 端的 javascript邏輯 無法直接透過 document.cookie 來讀取 & 取用,比如在夾帶 Token 或 Session Id 時,就會建議將此項設為 true,只讓瀏覽器發送 http request 時夾帶

    • secure: 其值為 Boolean,預設為 false,若是 true 的情況瀏覽器端必須得是在 https 的(加密)傳輸協定下,才會自動夾帶此 token 回 server

    • domain: 這邊可以指定可以使用此 cookie 的 domain ,通常是以預設使用 ( 代表只適用於自己的 domain )

    • path: 指定可以使用此 cookie 的路徑

    • sameSite: 此值為 Boolean 或是 String,此屬性與網站安全有關 ( 預防 CSRF 攻擊 ),他可以確保在網站進行跨站請求時,不會將 Cookies 發送到其他網站

      • 若其值為 true: 代表為 strict 嚴格要求,代表若請求的對象為不同的網站,Cookie 不會被夾帶
      • 若其值為 false: 代表沒有跨站限制,也就代表不管是不是跨站請求,此 Cookie 都會被夾帶
      • 若其值為 none: 代表此 Cookie 是明確的為跨站請求進行設置的,但通常會與 secure 這項搭配使用,以確保至少在 https 環境下進行
      • 若其值為 strict: 與 true 同效果,代表嚴格執行只有同源網站才夾帶 Cookies
    • encode: 其值是為一函式用來加密 cookie 值 ( 但有鑑於 cookie 的限制必須為函式或表達式,該函式得要會傳簡單字串作為 cookie ) 而這邊若無設定預設,預設是 **JSON.stringify** + **encodeURIComponent**

    • decode: 其值是為一函式,目的是用來解密 cookie 的值,而此函式會被使用於 encode 所加密的 cookie 值,預設 decoder 為 decodeURIComponent + destr

    • default: 其值為一函式,其回傳的值是作為 cookie 的預設值,而此函式也會被用來回傳 Ref 值 ( 注意: 由於有 encode 會自動轉成字串以符合 cookie 規定,所以這邊的 cookie 預設值可以不用是字串 )

    • readonly: 若設置,則代表允許 cookie 被讀取,但不能被寫入

    • watch: 其對應值大多是 boolean 或是 string ,用來監視響應式 ( ref ) 的 cookie 資料

      • 若其值為 true: 則會深層監測 ( 包含內部巢狀結構 ) cookie 響應式資料 ( 此為預設值,會自動監測響應式 cookie 值的改變 )
      • 若其值為 shallow: 只會淺層監測 cookie 響應資料的變動 ( 不包含內部巢狀結構 )
      • 若其值為 false: 則不會監測 cookies 資料的變化
    • watch 範例 & 說明如下

      以下是 useCookie 使用 watch 的範例說明

      範例程式碼如下

         <script setup lang="ts">
      const list = useCookie(
        'list',
        {
          default: () => [],// 此為名為 'list' key Cookie 的預設值
          watch: 'shallow' // 淺層監視
        }
      )
      
      function add() {
        list.value?.push(Math.round(Math.random() * 1000))
        // 只有淺層監測,因此雖然畫面上會因為 ref 而變動,但不會寫入 Cookie 中
      }
      
      function save() {
        if (list.value && list.value !== null) {
          list.value = [...list.value]
          // 直接解構藉由整個 array 傳參考的替換,強制觸發淺層監測
        }
      }
      </script>
      
      <template>
        <div>
          <h2>我是 管理員</h2>
          <pre>{{ list }}</pre>
          <button @click="add">Add</button>
          <button @click="save">Save</button>
        </div>
      </template>
      • 若按 add,因為 watch 是設定 shallow 所以只有畫面上的內容有響應,但其變動實際不會寫入 cookie 中

        https://i.imgur.com/vkGoh9x.png[/img]

      • 若按 save,則因為 list cookie 的 array 被解構 ( 等於是新值 ) 如此觸發了 shallow watch,所以會實際寫在 cookie 中 註: 需要注意的是使用開發者工具時,其 cookie 變換若沒有按下開發者工具的更新鈕,則畫面不會更新

        https://i.imgur.com/lg0TA27.png[/img]

  • useCookie範例

    以下是 useCookie 的使用範例,這範例中我們建立了兩個 cookies ,個別為 name 以及 counter,並確認若更改這兩者對應值,除了畫面上的變動外,是否會實際寫入瀏覽器 cookie 中

       <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>
    // 由於使用 useCookie 其函式是 ref 來做為響應式數據,所以在取用或更改時則必須用 .value 來取
    // 設置 name 以及 counter 這兩個 cookie key
    const name = useCookie('name', {default:()=> 'Alex'})
    const counter = useCookie('counter', { maxAge: 60, default:()=> 0 }  )
    
    const setNameCookie = () => {
      name.value = 'Ryan' // 對應設置 name 按鈕,點後同時會更改 cookie 中 name 的值
    }
    
    const setCounterCookie = () => {
      counter.value = Math.round(Math.random() * 1000) // 對應設置 counter 按鈕,點後同時會更改 cookie 中 counter 的值
    }
    </script>

    實際對應畫面如下

    點擊前: name 以及 counter 已被存入 cookie 中,而顯示的則是其預先設定的預設值

    https://i.imgur.com/Dsl9D52.png[/img]

    點擊後: 更改 name 對應的值,而瀏覽器內的 cookie key name 的對應值也跟著變動

    https://i.imgur.com/fIiImdj.png[/img]

補充: 若要在 Nuxt 的 server 資料夾內 ( 代表後端內容 ) 想取得網頁前端所夾帶的 cookie 可以用 Nuxt 的組合式函式 getCookiesetCookie

十一、狀態管理 (State Management)

有鑑於 Nuxt 的共構渲染 ( Universal Rendering ) 等特性 ( 第一次請求是 SSR 其他時候是 SPA 的 CSR ),也就是說在 SSR 的那頁,在 Server 那頁會是先被渲染以及執行相關 JS 邏輯一次,直到 Client Browser 端亦會再渲染一次並 Hydration 後再次執行對應的 JS 邏輯,所以當你使用網頁重新整理時,該頁面是會被執行兩次的,如下方式示意圖

https://i.imgur.com/fVj1o8c.png[/img]

  • 正因為如此,實際上在執行 universal rendering 時伺服器回饋的第一頁 SSR,其內的響應式狀態/數據 ( reactive, ref 等) 在伺服器會被執行一次直到瀏覽器端 hydration 後再被執行一次,為體現此差異,這邊用 ref 搭配 Math.random 來顯示以下畫面上 ref 對應的數據,從動圖中應可被觀察到,ref 後面的數據每次刷新都會變動兩次
   <template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <p>亂數呈現</p>
      <p class="text-9xl font-semibold text-sky-400">ref: {{ randomNumber }}</p>
      <p class="text-9xl font-semibold text-sky-400">useState {{ fixedRandomNumber }}</p>
    </div>
  </div>
</template>

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

圖片呈現如下 ( 注意 ref 的變化,每次更新都會變化兩次 )

https://i.imgur.com/BXxJfyQ.gif[/img]

而右邊的 console 也會因為 hydration 時 ref 數值的不一致性,出現警告內容,這邊節錄如下

   chunk-NE34BHUI.js?v=5cc7a39a:1452 [Vue warn]: Hydration text content mismatch in <p>:
- Server rendered: ref: 278
- Client rendered: ref: 415 
  at <Random onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< undefined > > 
  at <Anonymous key="/count/random" vnode= {__v_isVNode: true, __v_skip: true, type: {}, props: {}, key: null, …} route= {fullPath: '/count/random', hash: '', query: {}, name: 'count-random', path: '/count/random', …}  ... > 
  at <RouterView name=undefined route=undefined > 
  at <NuxtPage> 
  at <Custom ref=Ref< undefined > > 
  at <LayoutLoader key="custom" layoutProps= {ref: RefImpl} name="custom" > 
  at <NuxtLayoutProvider layoutProps= {ref: RefImpl} key="custom" name="custom"  ... > 
  at <NuxtLayout name="custom" > 
  at <App key=3 > 
  at <NuxtRoot>

這邊警告與原因說明: 因為在 SSR 伺服器端渲染完成後 ref 對應產生的數值是 278 ( Server rendered ref: 278 ) 但在傳到瀏覽器端,網頁 hydration 完成 ( 結合 Vue 與 JS 邏輯後 ) 又再執行一次 JS 邏輯 ( Client rendered: ref: 415 ),而這兩個數明明是同一個數據 ref 對應的值但卻有差異性所以產生 mismatch 的警示

11-1. useState 的基本用法

Nuxt 為了解決 SSR 頁面會產生的 hydration mismatch 問題,因此提供了一個組合函數 useState 來解決此問題

語法

   useState<T>(key: string, init?: () => T | Ref<T>): Ref<T>
  • 第一個參數為 key 是為唯一值通常是字串,用於確保資料不會重複被請求/執行
  • 第二個參數是 init 函數,是用來提供初始值給 State,而其回傳值則會傳 ref ( 響應式變數 )

回到我們在一開始時所提到的範例 ( 範例程式碼如下 )

   <template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <p>亂數呈現</p>
      <p class="text-9xl font-semibold text-sky-400">ref: {{ randomNumber }}</p>
      <p class="text-9xl font-semibold text-sky-400">useState {{ fixedRandomNumber }}</p>
    </div>
  </div>
</template>

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

在第二個數值 ( 畫面 useState 後對應的數值 ),其就是使用 useState,在下面動圖中也可以觀察到其雖然其 JS 設定與 ref 相同都是顯示隨機數,但其每次更新只會變化一次 ( 而非兩次 )

https://i.imgur.com/BXxJfyQ.gif[/img]

11-2. useState 的 State 共享

當我們使用 useState,就算是在不同的組件但是用相同的 key 值,其值是全域共享的,就算是初始值不同也是一樣

比如在下方我分別做了個 增減以及驚喜頁面

增減頁

   // pages/count/increment.vue
<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="/count/surprise">回驚喜</NuxtLink>
      </div>
    </div>
  </div>
</template>

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

驚喜頁

   // pages/count/surprise.vue

<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">
        <p>驚不驚喜 ?意不意外 ?</p>
        <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="/count/increment">回到增減</NuxtLink>
      </div>
    </div>
  </div>
</template>

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

在上方兩頁程式碼中,我們皆用 useState 使用相同的 key 'counter' ,但是初始值設定不同 ( useState 的第二參數 ) 我們可以看到在未重新整理 ( 重新發出請求 ) 的情況下,這兩頁相同的 key 'counter' 狀態(數據) 是共享的

https://i.imgur.com/oaLJqfR.gif[/img]

11-3. useState 搭配 composables 實現狀態共享

由於 useState 只要相同 key 就會全域共享的特性,我們可以搭配 composables 來實現全域狀態共享,範例如下

註: Nuxt3 會 auto-imported 所有 composables

先在 composables 中建立 state.ts

   // composables/state.ts
export const useGreenText = ()=> useState('green', ()=> '綠色')
export const useBlueText = ()=> useState('blue', ()=> '藍色')

建立內部組件 color.vue

   //pages/roles/color
<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <div class="mt-8 flex flex-row">
        <h2>顏色變換</h2>
        <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="changeGreen"
        >
          綠色變英文
        </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="changeBlue"
        >
          藍色變英文
        </button>
      </div>
      <p>顏色是 {{colorGreen}}</p>
      <p>顏色是 {{colorBlue}}</p>
    </div>
  </div>
</template>

<script setup>
const colorGreen = useGreenText()
const colorBlue = useBlueText()

function changeGreen (){
  colorGreen.value = 'Green'
}

function changeBlue (){
  colorBlue.value = 'Blue'
}
</script>

在外部組件加入 useGreenText() 以及 useBlueText() 作為觀察,看是否有連動

   // app.vue

<script setup lang="ts">
  const colorGreen = useGreenText()
	const colorBlue = useBlueText()

</script>

<template>
  <div>
    <NuxtLayout name="custom">
      <h1>項目根組件</h1>
      <p>根組件顏色是 {{colorGreen}}</p>
      <p>根組件顏色是 {{colorBlue}}</p>
      <nuxt-link to="/roles/color"> 顏色變換 </nuxt-link>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>

實際操作會發現,與 composables 搭配的 useState 已然變成全域變數,任何改變皆會連動

https://i.imgur.com/2tLQQgz.gif[/img]

十二、組合式函式 ( Composables )

12-1. Composition API 與 Options API 對比與介紹

以我們之前撰寫 Vue 2 使用 options API 的經驗上應可以明確地感受到在撰寫邏輯上,相同邏輯的內容就算再模組化且分割再小,還是必須依照程式碼的性質進行分類,比如 data 只能存放狀態、methods、生命週期等等基本上皆得是分開撰寫,在撰寫上較無彈性,這是由於 Options api 為方便開發者快速上手與協作,所以使用程式碼性質進行區分的結果,但這與也同時限制了較有經驗的開發者進行開發,且就算是有關連的邏輯也必須打散,造成無法避免的關聯程式碼分散問題,而 vue3 的組合式 ( composition api ) 正是為解決原 option api 這問題而生,在撰寫風格上更趨近於原生 javascript,在寫程式上顯得相對靈活與自由

  • option api ( 可選式 ) 範例

       <script>
    export default {
      data() { // 所有的 state 必須都放在 data
        return {
          count: 0,
          doubleCount: 0
        }
      },
      methods: { // 所有的 methods 也必須放在一起,不管邏輯上是否有關連
        increment() {
          this.count += 1
        },
        incrementByTwo() {
          this.doubleCount += 2
        }
      }
    }
    </script>
  • 但同樣表達依樣的邏輯,組合式 api ( composition api ) 的寫法,倒是簡潔以及方便了解的多,如下

       <script setup>
    // Count 邏輯相關的 state 與 methods 分為一區
    const Count = ref(0)
    const increment = () => {
    	Count.value += 1
    }
    // doubleCount 邏輯相關的 state 與 methods 分為另一區
    const doubleCount = ref(0)
    const incrementByTwo = () => {
    	doubleCount.value += 2
    }
    
    </script>

12-2. Mixins 與 Composables

相較於 option api 使用 Mixins 來抽出共用邏輯,方便共用外,Composition api 的作法是使用 Composbales 來抽出共用邏輯,並解決原 Mixins 所造成的各種問題。

由於在引用 Mixins 共用的邏輯時是將該檔的所有邏輯一次全部引入,並不能只挑選想使用到的邏輯,所以常導致各種非預期的問題,比如下方問題

  • 命名衝突: 由於全部引入,沒有注意到有相同的變數命名
  • 不容易 debug: 因為出錯的點並非在原檔而是在共用邏輯 Mixins 中,而導致開發者容易忽略

而 Composition api ( 組合式 ) api 所抽出共用邏輯的方式則是採組合式函式 ( Composables ),通常分類在 composables 這資料夾下方,在 Nuxt3 預設是會將此資料夾下的所有邏輯一併載入,其可以只選擇引入需要的邏輯以避免 mixins 的問題外,也由於較好的區隔避免了耦合過深 debug 不易等問題。

12-3. Composables 組合式函式撰寫

在 Nuxt3 建立組合式函式通常是建立在專案根目錄下的 composables 這資料夾中,而 Nuxt3 預設是將此資料夾的所有第一層的內容自動載入 ( 所以在使用時就不用再另外 import 了,此時此 composables 就可以在此Nuxt 專案中的 vue, ts, js 檔使用 ),而在此資料夾下的各個組合式函式檔案官網皆建議以 use 作為開頭名稱 ex: useCounter.ts,以方便識別,而會影響到 Composables 的調用名稱在於兩處,這邊分為預設匯出 ( default export ) 以及具名匯出 ( Named export ) 這兩種方式搭配範例分別說明

前提假設,我們在 vue 頁面有如下範例程式碼,請分別撰寫描述 count, increment 邏輯的行為邏輯

   <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>

12-3-1. 預設匯出 ( default export )

使用預設匯出,會影響到 composables 調用的設定在於該組合函式檔案的命名,該命名方式建議用 use 開頭,可用小駝峰 (Camel case) 或是烤肉式( Kebab case )寫法,舉例若我需要使用名為 useCounter 的組合式函式,則需要建立 ./composables/useCounter.ts 或是 ./composables/use-counter.ts 這兩種命名方式命名,實際撰寫範例如下

   // ./composables/useCounter.ts
export default function () {
  const count = ref(0)

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

  return {
    count,
    increment
  }
}

而在 vue 個別元件使用時,則如下範例

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

12-3-2. 具名匯出 ( named export )

而具名匯出,則是以該組合式函式的名稱,而不是該檔案的名稱,而是檔案內各別函式 export 出來的名稱,比如我們可以在 composables 中建立 count.ts 這檔案,並用 具名匯出的寫法撰寫邏輯的範例會如同下方所示

   //  ./composables/count.ts
export const useCounter= () =>{
  const count = ref(0)
  const increment = () => {
    count.value+=1
  }
  return {
    count,
    increment
  }
}

而使用方式則與具名函式並無區別

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

12-4. Composables 載入方式說明

參考自: https://nuxt.com.cn/docs/guide/directory-structure/composables

雖說 Nuxt3 專案中的 composables 資料夾下的內容會自動載入,所以在個別 vue, ts, js 是不用另外再 import 的,但這自動載入是有限制的,Nuxt3 只掃描頂層文件,比如以下目錄結構

  • Nuxt3 只掃描頂層文件

       //  Nuxt composables 目錄結構
    
    | composables/
    ---| index.ts     // 會被掃描
    ---| useFoo.ts    // 會被掃描
    ---| useTime/
    ----| index.ts // 會被掃描,是同 useTime
    ---| time/
    ----| useDateFormat.ts // 不會被掃描
  • 若使用的 Composables 是以巢狀撰寫,匯出方式說明

    • 在 composables/index.ts 中重新導出

         // composables/index.ts
      // 使用此方式重新導出
      export { utils } from './nested/utils.ts'
    • 在 nuxt.config.ts 中調整 nuxt composables 自動載入設定

         // nuxt.config.ts
      export default defineNuxtConfig({
        imports: {
          dirs: [
            // 掃描頂層的各種 ts. js 文件
            'composables',
            // 或掃描特定名稱資料夾下的 index 文件
            'composables/*/index.{ts,js,mjs,mts}',
            // 或掃描給定目錄下的所有模塊
            'composables/**'
          ]
        }
      })

12-5. Composables 的型別

雖說在 Nuxt 內部其會自動建立 .nuxt/imports.d.ts 以 declare ( 聲明 ) composables 中的所有內容,但需要注意的是,其自動建立需在開發環境運行時 ( dev server running ),若不是在這條件下,Typescript 可能會丟出找不到對應型別的錯誤

註: Nuxt 通常會在 nuxi dev, nuxi preapre, nuxi build 等指令下生成 Nuxt 專案中所需的型別

12-6. Composables 嵌套

  • Composables 中使用 Composables

    開發者可以在組合式函式導入另一個組合式函式的邏輯,如下範例

       // composables/test.ts
    export const useFoo = () => {
      const nuxtApp = useNuxtApp()
      const bar = useBar()
    }
  • Composables 中使用注入的插件 ( plugin injections )

       export const useHello = () => {
      const nuxtApp = useNuxtApp()
      return nuxtApp.$hello
    }

十三、狀態管理 - Stores 目錄 ( Pinia )

Pinia 官網: https://pinia.vuejs.org/ssr/nuxt.html

  • Nuxt 3 之前的版本曾經有將 Vuex 包裝成預設的狀態管理工具,但在 Nuxt3 後的版本就沒有包裝狀態管理工具了,使用者可以依照自己的嗜好與需求進行選擇,官網有提及的管理工具如下

    • Pinia - Vue 官方推薦的管理工具,針對 Nuxt 的安裝配置也有做詳盡的說明包含使用 typescript 時所需要的配置
    • Harlem - 不可變的全域狀態管理工具
    • **XState** - 具有可以視覺化並測試你的狀態邏輯的狀態管理工具
  • Pinia 與 Vuex 核心概念上的差異

    • vuex 核心概念: state、mutations、actions、getters、modules
    • pinia 核心概念: state、actions、getters
  • Pinia 的優勢

    • 不用巢狀寫法 & 也不用另行設定 namespace 來區隔出獨立的命名空間 Pinia 不在有如 Vuex 的 modules 的巢狀結構,也不需要再為模組定義命名空間以避免命名上全域的汙染以及潛在的衝突,在 Pinia 定義的多個 Store 中,每個都是獨立的空間不用怕與其他的 Store 有命名上的衝突
    • 更完整的支持 Typescript Pinia 的 api 會盡可能的使用 Typescript 的類型推斷,而不需要再自行設定多餘的 types
    • 支援 SSR ( 伺服器渲染 ) 與程式碼拆分 Pinia 也支援 SSR 渲染的處理,詳情可見官網

    13-1. 安裝

    • 在 Nuxt 安裝

         yarn add pinia @pinia/nuxt
      # or with npm
      npm install pinia @pinia/nuxt
    • 接下來需要在 nuxt.config.ts 中加入 modules

         // nuxt.config.ts
      export default defineNuxtConfig({
        // ... other options
        modules: [
          // ...
          '@pinia/nuxt',
        ],
      })

    13-2.建立 store

    • Pinia 提供了 defineStore 這函式用來定義 store ,其中需定義兩個參數

      • 第一個參數: 也稱為 id 是為字串型別,其作用是作為唯一值,提供 Pinia 與 devtools 辨識使用
      • 第二個參數: 可放入物件 ( option api 的寫法 ) 或是 函式表達式 ( composition api 的寫法 )
    • 變數命名皆用 use 開頭: 使用變數接受使用 defineStore 函式定義的函式回傳值時,其變數名稱皆需要以 use 作為開頭,以作為辨識 ex: useCounterStore

    • option api 建立 store 的方式

      在 Nuxt 專案中,建立 ./stores/counter.ts

         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
        }
      })
    • composition api 建立 store 的方式

      在 Nuxt 專案中,建立 ./stores/counter.ts

         import { defineStore } from 'pinia';
      
      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
        })
      })
      
      export default useCounterStore

    13-3. 使用 store

    在需要使用的檔案中以如下方式引入 store 檔案

       import { useCounterStore } from '@/stores/counter'
    
    const counterStore = useCounterStore()
    • 搭配上方範例的實際使用範例

         // index.vue
      <script setup lang="ts">
      import { storeToRefs } from 'pinia'
      // 引入 store 中的 useCounterStore
      import useCounterStore  from '@/stores/counter'
        useHead({
          title: '首頁',
        })
        const counterStore = useCounterStore()
        // 注意: 這邊不能用如下方這種解構形式喔,否則會失去及時響應的能力
        // const {count} = counterStore
        // 若要保持響應能力搭配 pinia 提供的 storeToRefs
      	// 但需要注意的是 storeToRefs 只能解構 state 與 getters 不能用在 actions 喔 ( 不論 store 是用 composition api 或是 option api 都一樣 )
      	// 若須要使用 actions 直接解構就行  ( 不論 store 是用 composition api 或是 option api 都一樣 )
      const {count} = storeToRefs(counterStore)
      	const {increment} = counterStore
      
      </script>
      
      <template>
        <div>
      	// 解構寫法
          <span class="mt-8 text-4xl text-gray-700">{{ count }}</span>
        // 直接寫也可以
          <span class="mt-8 text-4xl text-gray-700">{{ counterStore.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>
    • 13-3-1. 調整 Pinia Store 中的 state 值

      調整方法如下

      • 直接修改 ex: counterStore.count+=1
      • $patch 只修改部分的 state 內容
      • **$state** 覆蓋原 state 所有 內容
      • $reset() 重置 state 所有屬性值 ( 變成初始值 ) 注意: $reset() 只能用在使用 option api 寫的 store,若使用在 composition api 撰寫的 store 會出錯

      以下是以上修改 state 實作的範例程式碼

         // pages/index.vue
      <script setup lang="ts">
      import useCounterStore  from '@/stores/counter'
      
        const counterStore = useCounterStore()
      
        onMounted(()=>{
          // 直接修改 寫法一
          counterStore.count =2
          // 直接修改 寫法二
          count.value = 2
          
      		// patch: 修改該 store 中的 部分 state
          // patch 寫法一
          counterStore.$patch({
            count: 5
          })
          
      		//patch 寫法二 以表達式寫法,以 state 獲取 store 中的 state 進行修改
          counterStore.$patch((state)=> {
            state.count = 6
          })
          
      		// $state: 修改該 store 中的 所有 state ( 原 state 整個被覆蓋 )
          counterStore.$state={
            count: 666
          }
          
      		// $reset() 重置該 store 中所有 state
          // 但只能使用在 option api 撰寫的 store, 若使用在 option api 撰寫的 store 會出錯
          // counterStore.$reset()
        })
      
      </script>
    • 13-3-2. Pinia Store 中的 Getters 與 Setters

      若使用 options api 撰寫 Pinia Store 的 getters ( 就是會隨著 state 變動而跟著變動的呈現邏輯 ) 在這點上同等於使用 composition api 撰寫的 computed 方法所承接的值

      • 使用 option api 的寫法撰寫 getters

           import { defineStore } from 'pinia';
        
        const useCounterStore  = defineStore('counter', {
          state: () => ({
            count: 0
          }),
          getters: {
            doubleCount: (state) => state.count * 2,
          }
        })
        
        export default useCounterStore
      • 使用 options api 的寫法撰寫 getters 與 setters

        註: 此寫法參考自 Github pinia issues 447

        Options api 寫法只能寫 getters 不能寫 setters,若要寫 setters 就使用 actions 來替代或是改成 composition api 來撰寫 store 才能順利運作原有的 setters 功能

        筆者目前測試,只有 getter 有成功,但使用 setters 會出現 “Write operation failed: computed value is readonly” 的警示,無法順利使用 setters 更改,當然也有另一種寫法將原 setters 功能放在 actions 中設置,但這就不是 getters 中的 setters 功能了

           import { defineStore } from 'pinia';
        
        const useCounterStore  = defineStore('counter', {
          state: () => ({
            count: 0
          }),
        
          getters: {
            tribleCount(state){
              return computed({
                get(): number {
                  return state.count*3
                },
        				// 使用時會出現 Write operation failed: computed value is readonly 警告而無法順利更改
                set(value: number) {
                  state.count = Number((value/3).toFixed(2))
                },
              })
            }
          }
        })
        
        export default useCounterStore
      • 使用 composition api 的寫法撰寫 getters 與 setters

        使用 composition api 利用 computed 可以同時達到理想中的 getters 與 setters 效果,範例程式碼如下

           import { defineStore } from 'pinia';
        
        const useCounterStore = defineStore('counter', () => {
          const count = ref(0)
        
        	// 使用 computed 撰寫一般 getters 寫法
          const doubleCount = computed(() => count.value * 2)
        
          // 使用 computed 撰寫 getters 與 setters 的寫法 
        	const tribleCount = computed({
            get: ()=> {
              return count.value*3
            },
            set: (value:number)=> {
              count.value = Number((value/3).toFixed(2)) 
            }
          })
        
          return {
            count,
            doubleCount,
            tribleCount,
          }
        })
        
        export default useCounterStore
    • 13-3-3. Pinia Store 中的 Actions

      Pinia 中的 Actions 與 Vuex 不同點在於,其可以使用同步,也可以搭配異步

      • option api 的 actions 寫法

           import { defineStore } from 'pinia'
        
        export const useCounterStore = defineStore('counter', {
          state: () => ({
            count: 0
          }),
        	// option api 的 actions 寫法
          actions: {
            increment() {
              this.count += 1
            },
            decrement() {
              this.count -= 1
            }
        		// 非同步寫法
        		async getUserProfile() {
              try {
                const { data } = await useFetch('/api/profile')
                this.profile = data
              } catch (error) {
                return error
              }
        		}
          },
        })
      • composition api 的 actions 寫法

           import { defineStore } from 'pinia'
        
        export const useCounterStore = defineStore('counter', () => {
          const count = ref(0)
        
        	// composition api 的 actions 寫法
          const increment = () => {
            count.value += 1
          }
          const decrement = () => {
            count.value -= 1
          }
        
          return {
            count,
            increment,
            decrement,
          }
        })

      在 Nuxt 中甚至可以搭配 Nuxt 內建 & 支援 SSR 的組合函數 useAsyncData() 來確保 Nuxt 可以跳掉在客戶端渲染後打 api 的行為 ( 在 server 時已打過的 api 行為 )

      參考自 官網

         <script setup>
      const store = useStore()
      // 非同步的寫法,在實際調用 store 中的非同步方法時,可搭配 useAsyncData
      await useAsyncData('user', () => store.fetchUser())
      </script>

    13-4. Store 的解構 storeToRefs

    特別需要注意的是,若想要解構 store 函式所建立的對象時,直接解構會有資料響應式問題 ( 不會立即更新於畫面上 ) ,但也要盡量避免 toRefs 方式進行響應式解構,而是要用 pinia 提供的組合式函數 storeToRefs 進行解構,且解構的對象只能是 state 或是 getters ( actions 用一般解構取) 否則會造成解構出來的 store action 失效

    簡單總結

    • toRefs: 不建議拿來解構 store,但其可以用來解構一般物件中所有的屬性並將其轉換成 ref 響應式數據

    • storeToRefs: 在解構 pinia store 時建議的使用方式,但只能解構 state 與 getters

      ( actions 用一般解構方式獲取 )

       // storeToRefs 使用範例
    
    <script setup lang="ts">
    import { storeToRefs } from 'pinia'
    import useCounterStore  from '@/stores/counter'
      const counterStore = useCounterStore()
      // 注意: 這邊不能用如下方這種解構形式喔,否則會失去及時響應的能力
      // const {count, increment} = counterStore
    
      // 若要保持響應能力搭配 storeToRefs 其只能解構 state 與 getters
      const {count, doubleCount, tribleCount } = storeToRefs(counterStore)
    	//  store 的 action 用正常解構才能使用
      const {increment} = counterStore
      
    </script>
       **補充說明:  為何 Pinia 的 store 不建議用 toRefs 解構,而 storeToRefs 與 toRefs 有何不同**
    筆者實際嘗試以 toRefs 解構 store 也可以正常響應
    ****而以下是查尋到 storeToRefs 與 toRefs 解構 store 差異的原因
    **1. 造成不必要的記憶體浪費和混亂** 
    		toRefs 會將 store 中的所有屬性都轉換為 ref 對象,包括一些不需要的屬性,例如 $id, $patch, $reset 等,這會造成不必要的記憶體浪費和混亂
    2. **失去了原始 store 的 this 指向**
    		toRefs 會將 store 中的 getters 和 actions 也轉換為 ref 對象,因為它們失去了原始的 this 指向,導致模板中調用它們時出現錯誤。
    3. **Pinia 原本的響應式 Proxy 被 ref 改變**
    		toRefs 會將 store 中的 state 轉換為 ref 對象,但是這些 ref 對象是可變的,因為它們沒有被 pinia 的 proxy 保護。這會導致模板中修改它們時可能出現錯誤

    13-5. Store 的持久化插件

    pinia-plugin-persistedstate 搭配 Nuxt 官網介紹: 連結

    • Pinia 的持久化插件: Pinia Plugin Persistedstate

      此套件的功能: 利用存取 Pinia state 到 localStorage (預設) , sessionStorage 或是 cookies 的方式,讓 store 能持久化

      註1: 若不這麼做的話其 Pinia state 會因為網頁重整或是關掉網頁而導致 state 被重置

      註2: 此套件功能基本上類似我們在 vuex 時使用的 vuex-persistedstate

      注意: 不管是 localStorage 或是 sessionStorage 都只能在客戶端的瀏覽器存取

    • 安裝方式

         # npm 
      npm i -D @pinia-plugin-persistedstate/nuxt
      # yarn 
      yarn add -D @pinia-plugin-persistedstate/nuxt
    • 配置方式

         // nuxt.config.ts
      export default defineNuxtConfig({
      	...
        modules: [ // 在 module 配置
          '@pinia/nuxt',
          '@pinia-plugin-persistedstate/nuxt', // 加上這行
        ],
      	...
      })

    在 Nuxt 中的使用方式 ( 注意: 在 Nuxt 中的使用方式與其他框架有差異,詳情請參照 官網 )

    實際使用的範例程式碼

    • composition api 的 store

         import { defineStore } from 'pinia';
      
      const useCounterStore = defineStore('counter', () => {
        const count = ref(0)
        const num = ref (0)
        const tryMyBigObject = ref({
          attr1: 0,
          attr2: 0,
        })
        return {
          count,
          tryMyBigObject,
          num,
        }
      }, {
        persist: {
      		// 筆者測試在這邊是否設置 key 其實際存取的對象都是本 store 中所有的 state
      	   // key: 'counter', 
      		// 筆者測試就算指定 path 也就是 只存取部分 state 的功能也不成功,其存取的一樣是整個 store 的 state
      	   // path: ['num', 'tryMyBigObject.attr1'], 
      		// storage 有 local ( persistedState.localStorage ) 與 session ( persistedState.sessionStorage )
      		// storage 也可使用 cookies 來存取 persistedState.cookiesWithOptions
      		storage: persistedState.cookiesWithOptions({
            sameSite: 'strict', // 限制只有同個網域才能存取 cookies
          }),
        }
      })
      
      export default useCounterStore
    • option api 的 store

         import { defineStore } from 'pinia'
      
      export const useStore = defineStore('main', {
        state: () => {
          return {
            someState: 'hello pinia',
          }
        },
        persist: {
      		// storage 一樣除了 local 以及 session storage 外也可以存取在 cookies
      		storage: persistedState.cookiesWithOptions({
            sameSite: 'strict', // 限制只有同個網域才能存取 cookies
          }),
        },
      })

    註: 雖說從官網上 pinia-plugin-persistedstate 可以全域設置 ( 在 nuxt.config.ts 中 ),但筆者實際測試時並無效用,所以在這邊就暫不敘述,想了解詳情者可參照官網敘述

十四、渲染模式 ( Rendering )

官網: https://nuxt.com/docs/guide/concepts/rendering

14-1. Nuxt 3 支援的渲染模式

所謂渲染 ( Rendering ) 在這邊是指 Nuxt 不管在瀏覽器或是 server 端將 Vue.js 所撰寫的元件編譯 ( interpret ) 成 HTML, javascript, css 檔案的能力

Nuxt3 預設是使用通用渲染模式 ( Universal Rendering ) 。但其支援不同的渲染模式,除了 通用渲染外也包含 CSR ( Client Side Render )、混合渲染 ( Hybrid Rendering ) 、更給予了 ESR ( Edge-Side Rendering ) 讓你藉由 CDN 渲染你的網頁的可能性

由於稍後會做較詳盡與優缺點的介紹,這邊主要針對 Nuxt 的渲染模式做個基本介紹 & 總結

  • CSR ( Client Side Rendering ): 只在瀏覽器做渲染 ex: Vue.js
  • 通用渲染 ( Universal Rendering ): 初次載入的該頁使用 SSR,其餘頁面使用 CSR
  • 混合渲染 ( Hybrid Rendering ): 藉由不同路由設定哪些頁面是 SSR 哪些是 CSR
  • 邊緣渲染 ( Edge-Side Rendering ): 藉由 CDN 做渲染,利用 CDN 的優勢減少網站的延遲

接下來筆者會針對各點渲染方式做說明 & 設定範例,但由於邊緣渲染會牽涉到第三方付費服務 CDN 所以只會有概念的介紹並不會有設定範例

註: 這邊官網不直接寫支援 SSR ( Server Side Rendering ) 的原因,筆者推測是因為 在 nuxt.config 的 ssr 設定 ( Boolean ) ,其實是指 Universal Rendering 與 CSR 的切換並不是指全域 SSR 與 CSR 的設定,若要每頁都 SSR 勢必就是使用混合渲染的方式藉由不同路由去設定成每頁為 SSR

14-2. 客戶端渲染 ( CSR )

由一般 Vue.js 所撰寫的網頁皆屬於 CSR 的範疇,也就是伺服器只丟有根節點 ( 空的 ) 的 HTML 文件以及對應的 Vue.js 邏輯給瀏覽器,而瀏覽器下載完這些資料後,再由 Vue.js 在客戶端解析 ( parse ) 成 JavaScript 進一步的邏輯去生成呈現給使用者的介面 ( HTML, css, js )

https://nuxt.com/assets/docs/concepts/rendering/csr.svg

  • CSR 優點

    • 開發速度快:

      我們不需要在意程式碼在 server side 的問題,比如: 不能撰寫只有瀏覽器才能使用的 web-api ex: window 物件

    • 價格便宜: 不需要花額外的費用於需要支援 & 運行 JavaScript 的設備,只要有可以放置靜態檔案的地方 ( static server ) 就行

    • 可以實現部份離線功能: 由於程式碼的邏輯是被包好放在客戶端,所以除了動態資料外,其他靜態資料的渲染都在使用者本地的瀏覽器上 ( 只要不 reload 就 ok~ )

  • CSR 缺點

    • 載入速度慢,影響使用體驗
    • SEO 不佳: 爬蟲抓不到尚未被渲染的內容
  • Nuxt 設置方式:

    在 nuxt.config.js 設置以下屬性

    • spaLoadingTemplate: 預設 null, 在 SPA 網頁尚未完整渲染 ( Hydration ) 前所出現的畫面,在使用情境上大多人會放 loading 畫面
    • ssr: 預設 true, 注意這是在 nuxt.config.ts 中的 ssr 屬性,預設是 true 代表是 Universal Rendering,這邊須設定成 false 代表 CSR
       // nuxt.config.ts
    export default defineNuxtConfig({
    	spaLoadingTemplate: true, // 選擇設置
      ssr: false // 必要設定
    })
    • 若有設定 spaLoadingTemplate: true 的話,需要撰寫對應的呈現畫面模板畫面 app/spa-loading-template.html ,注意名稱位置皆不能自行改動,如下範例
       // ./app/spa-loading-template.html
    <!-- https://github.com/barelyhuman/snips/blob/dev/pages/css-loader.md -->
    <div class="loader"></div>
    <style>
      .loader {
        display: block;
        position: fixed;
        z-index: 1031;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 18px;
        height: 18px;
        box-sizing: border-box;
        border: solid 2px transparent;
        border-top-color: #000;
        border-left-color: #000;
        border-bottom-color: #efefef;
        border-right-color: #efefef;
        border-radius: 50%;
        -webkit-animation: loader 400ms linear infinite;
        animation: loader 400ms linear infinite;
      }
    
      \@-webkit-keyframes loader {
        0% {
          -webkit-transform: translate(-50%, -50%) rotate(0deg);
        }
        100% {
          -webkit-transform: translate(-50%, -50%) rotate(360deg);
        }
      }
      \@keyframes loader {
        0% {
          transform: translate(-50%, -50%) rotate(0deg);
        }
        100% {
          transform: translate(-50%, -50%) rotate(360deg);
        }
      }
    </style>

14-3. 通用渲染 ( Universal Rendering )

這是 Nuxt3 的預設渲染方式,其設置位置如下,但由於是預設所以有無設置基本上 Nuxt3 都是以通用渲染的方式進行渲染

   // nuxt.config.ts
export default defineNuxtConfig({
  ssr: true, // 預設就是 true,所以不用特別設置
})
  • 第一次請求 ( 含刷新 ),伺服器回傳的是 SSR 伺服器已渲染好的頁面 ( 不限制首頁,而是第一次的請求對象 ),此階段 伺服器所回傳的只是渲染好的 HTML 網頁不具有互動性
  • 第二次請求時,則會下載 Vue 以及對應的 JS 邏輯程式碼並 hydration 完成讓網頁有更好的互動性,而之後使用者在網頁上的操作 (請求) 則使用 CSR 渲染,也就是 SPA

https://nuxt.com/assets/docs/concepts/rendering/ssr.svg

  • Universal Rendering 優點
    • 快速載入: 由於第一次是 SSR 直皆由伺服器傳遞以渲染好的 Html 網頁,所以使用者不用等待,同時保持 CSR 的響應性
    • 對 SEO 有利: 由於通用型渲染由 server 傳遞的是已渲染好內容的 Html 網頁,也較有利於爬蟲將網頁內容做為索引
  • Universal Rendering 缺點
    • 較不容易開發: 由於在 server 以及瀏覽器端的環境使用的 API 不盡然相同,所以在寫代碼時需要無縫接軌兩個環境需要點技術。但 Nuxt3 提供了指引以及特定的變數,用來協助你決定在甚麼環境需要執行哪部份的程式碼
    • 開銷稍大: 為了確保伺服器能夠支援在傳遞或預先渲染好頁面,所以不能只使用靜態伺服器,所以開銷會稍大,但還好的是通用渲染只有一頁是 SSR 其餘大部份仍是在客戶端瀏覽器完成渲染,所以並不會貴太多 ( 但若要再減少開銷,可以考慮利用 ESR )

通用型渲染其實非常萬用,任何內容網站 ex: 部落格、行銷網站、個人網站、電子商務 …etc 都適用

   **在使用通用渲染需要注意的是

當使用 第三方套件 (libries) 特別是那些依賴 WEB API 且有副作用 ( side effect ) 的套件時
必須確保使用這第三方套件的元件只在瀏覽器端被載入,當然也要注意若其套件本身沒有這些問題,
但其引用的子套件有這情況,也必須確保其只會在瀏覽器端被載入
但若這套件會涉及些敏感資訊的調用,則須確保該套件只在伺服器內被使用以避免可能的隱私洩漏

依賴 WEB API 套件帶來的問題: 
因為若沒做此限制而導致該元件在伺服器端被執行的話,會因為 server 並沒有 web api 而導致錯誤
ex:** 抓不到 window 這物件的警告 ReferenceError: window is not defined 或是 抓不到 DOM 的
警告 TypeError: document.getElementById is not a function
而這些錯誤與警告導致網頁無法正常運作甚至無法被正常渲染

**擁有 Side Effect 套件帶來的問題:
所謂的 side Effect 就是 除了本來的返回值對象外,其執行時還會對外部環境產生影響 
ex:** 我們常用的 console.log 就算,他會在瀏覽器的控制台上輸出信息,或是 localStorage.setItem
會在瀏覽器中存取資料,這些都會改變外部環境的狀態 ( 控制台、瀏覽器 )
而若在伺服器端運作有副作用的套件時會有以下風險
**1. 數據混亂或是洩漏**
因為在伺服器的副作用會影響到所有使用者,導致意想不到的數據混亂或洩漏,或者無謂增加了伺服器
的附載,進而引響網頁的穩定與安全
**2. 影響 treeshake 的效果, 增加打包後程式碼的大小進而影響效能**
打包工具無法確定有副作用的模組是否真的被使用或引用,因為它們的副作用可能會在其他地方產生影響
,而不是只在模組本身。如果打包工具隨意地刪除有副作用的模組,可能會導致一些意想不到的結果
例如功能的缺失或錯誤,或者副作用的消失或變化

**若要確保該元件只在客戶端被渲染,則可以使用 Nuxt 提供的組件 \<client-only\> 或是使用變數 process.client**

如上述所提,在使用通用渲染時若有使用到有使用到 web api 的且有副作用的套件需要確保該使用到該套件的元件只在瀏覽器端做渲染,可以使用 Nuxt 提供的組件 <client-only> 或是使用變數 process.client ,這邊就針對此二者的用法做個簡易的介紹

  • <ClientOnly> 官網: https://nuxt.com/docs/api/components/client-only

    這是只有在 client side 才會 render 的組件,但有另一個同樣是 Nuxt 所提供,但只有在其內含組件在 server 端渲染出錯時才會觸發 <NuxtClientFallback>

       // example.vue
    <template>
    	// 使用 client-only tag 包夾只在客戶端渲染的元件
      <client-only>
        <my-component />
      </client-only>
    </template>
    
    <script>
    import MyComponent from './MyComponent.vue'
    import SomeLibrary from 'some-library' // 假設這個套件依賴於瀏覽器 API 並且有副作用
    
    export default {
      components: {
        MyComponent
      },
      mounted() {
        // 這裡可以使用 SomeLibrary 的功能,因為這個元件只在客戶端被呼叫
      }
    }
    </script>

    基本使用功能說明

    • Props ( 屬性 ): 用來替代 server 端渲染的簡單結構

      • placeholderTag | fallbackTag : 在 server 端希望替代該元件渲染的 html tag
      • placeholder | fallback : 填寫希望這 tag 需要被呈現的內容

      比如以下撰寫的程式碼

         <template>
        <div>
          <h1 class="text-primary">我是首頁默認組件</h1>
          <div class="flex flex-col items-center">
            <ClientOnly fallback-tag="span" fallback="請讓我想想...">
            <span>雙倍計數 {{ doubleCount }}</span>
            <span>三倍計數 {{ tribleCount }}</span>
            <span>測試數字 {{ tribleCount }}</span>
            <span>測試物件 {{ tribleCount }}</span>
          </ClientOnly>
      		</div>
      		</div>
      </template>

      以下是撰寫的程式碼與 server 實際回傳的 html 程式碼畫面示意

      https://i.imgur.com/YO33fnp.png

    • Slots ( 屬性 ): 用來替代 server 端渲染的複雜結構

      使用 template 搭配 #fallback: 用來替代 server 端呈現的複雜結構

      比如以下程式碼

         <template>
        <div>
          <h1 class="text-primary">我是首頁默認組件</h1>
          <div class="flex flex-col items-center">
            <ClientOnly>
              <span>雙倍計數 {{ doubleCount }}</span>
            <span>三倍計數 {{ tribleCount }}</span>
            <span>測試數字 {{ tribleCount }}</span>
            <span>測試物件 {{ tribleCount }}</span>
            <template #fallback>
              <div>
                <p>我會算數好棒棒的哩~</p>
                <ul>
                  <li>我會吃飯</li>
                  <li>我會睡覺</li>
                  <li>我會打東東</li>
                </ul>
              </div>
            </template>
          </ClientOnly>
      		</div>
      		</div>
      </template>

      以下是撰寫的程式碼與 server 實際回傳的 html 程式碼畫面示意

      https://i.imgur.com/s6sylYc.png

  • 使用 process.client 變數

    process 可以用來 判斷當前執行環境是否為客戶端 如下方範例,若直接執行 console.log 就可以看到 process 內容在 server 端以及 Browser 端的差異

    https://i.imgur.com/w6CnuZI.png

14-4. 混合渲染 ( Hybrid Rendering )

Nuxt3 的混合式渲染功能是藉由 路由規則 ( Route Rule) 去實現的,也就是路由決定該 server 該如何回應請求的方式

之前的 Nuxt 版本只能選擇要馬是通用渲染要馬是 CSR 的渲染方式,但在網站需求上有時若要使用者體驗好,必須要是彈性的,比如某些頁面是 prerender ( 事先 build 好的 ) 的靜態頁面 ( SSG static site generation ),有些要是 CSR,部份是 SSR 亦或是改良 SSG 因太久沒更新等缺點的 ISR 或 SWR

  • SSG ( Static Site Generation ): 是指靜態網頁,是 prerendering 的一種,靜態網站的生成是在打包 ( build ) 階段就建立&渲染 ( 伺服器內就渲染完成 ),而由於是靜態網頁其 SEO 以及載入速度都非常良好 ( 也適合放在 CDN 上被 cache 提升網站的效能 )
  • SSR ( Server Side Render ): 雖說與 SSG ( Prerender ) 很像都是,在伺服器端就已完成渲染,但其關鍵的不同點在於 SSG 是靜態的 ( 因為在 build 時就建立,顯示的數據是舊的 ),SSR 則是動態的,也就是說 SSR 在 每次瀏覽器發送請求時,伺服器才會生成並渲染網頁並回到瀏覽器顯示,但伺服器回應的時間也會較久
  • ISR ( Incremental Static Regeneration ): ISR **增量靜態生成,**是 SSG 的改良版,在 build 時同樣會先渲染成 html 等檔案,但與 SSG 不同的是,在有新的 request 且已經過設定的時間後,伺服器就會先給瀏覽器 舊的已渲染好的網頁,同時在背景中重新生成新的檔案,產生完後再更新瀏覽器的畫面並 cached,給下一個使用者使用,所以說 ISR 與 SSG 很像,但其解決了 SSG 資料無法即時更新等問題,且與 SSG 相同其 cache 的資料也適合放在 CDN ( currently Netlify or Vercel ) 上去提升網頁效能
  • SWR (Stale-While-Revalidate): 是 HTTP 快取的策略,這種策略的核心就是能夠允許客戶端可以先使用快取的資料,並同時在背景中驗證快取資料是否已經過期,如果過期表示需要進行更新,就會重新的抓取資料並更新至快取中 (畫面上不會重新渲染成新的資料),而當再次有請求時,就會拿到剛剛已經更新過的快取資料。SWR 的快取策略,會在伺服器端請求回應的回應標頭 (Response header) 中包含一個名為 Cache-Control 的選項,用來控制頁面的快取策略。

以上這些設置皆可以在 Nuxt3 的 Nuxt.config.ts 中的路由規則中去設定並實現

   // nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
		// 格式: key 路由,值則是設定內容
    // Homepage pre-rendered at build time
    '/': { prerender: true }, // 就是靜態 SSG ( static site generation )
    // Product page generated on-demand, revalidates in background
		// swr 是以秒做為單位 3600 就是 1 hr
    '/products/**': { swr: 3600 }, // http 快取策略,使用 cached 先給用戶,
    // Boolean: generated on-demand once until next deploy
		// Number: isr 同樣是以秒為單位
    '/blog/**': { isr: true },
    // Admin dashboard renders only on client-side
    '/admin/**': { ssr: false },
    // Add cors headers on API routes
    '/api/**': { cors: true },
    // Redirects legacy urls
    '/old-page': { redirect: '/new-page' }
  }
})

以下會針對每個可設定屬性做介紹

路由規則 ( Route Rule)

以下是 Nuxt3 針對不同路由可以設定的渲染模式屬性介紹

屬性名型別 ( type )說明
redirectstring定義 server 端針對該頁面是否轉頁
ssrboolean確認是否是 SSR 或是 CSR
corsboolean可以針對特定路由 ex: api 路徑,選擇是否自動加上 cors 跨域限制,通常會搭配 headers 做詳細的設定 ( 註一 )
headersobject針對網站特定路由加上 特定 headers 設定,如範例 ( 註一 )
swrnumber 或 boolean在伺服器回應中添加快取 headers 資訊,並在伺服器或反向代理上快取它,快取的時間可以由可配置的 TTL (存活時間) 決定。Nitro 的 node-server 預設可以快取完整的回應。當 TTL 過期時,快取的回應會被發送,同時頁面會在背景中重新生成。如果使用 true,則會添加一個 stale-while-revalidate 頭部資訊,而不會有 MaxAge
isrnumber 或 booleanm這機制與 swr 雷同,差別是我們可以將伺服器的回應緩存在 CDN 上 (currently Netlify or Vercel). 如果設定 true 則該緩存內容就會一直被使用直至 CDN 的內容被重新佈署 ( deploy)
prerenderboolean若是 true 則會在打包 ( build ) 時就會將其生成靜態頁面
experimentalNoScriptsboolean禁止 Nuxt scripts ˇ與 js 資源被拿來渲染使用

注意: 若使用混合型渲染,則不能使用 nuxt generate 這指令

註一 cors + headers 設定

   export default defineNuxtConfig({
   // also tried with variation - nitro: { routeRules: {...}}
    routeRules: {
        'api/v1/**': {
            cors: true,
            headers: {
                'access-control-allow-methods': 'GET,HEAD,PATCH,POST,DELETE',
                'access-control-allow-origin': 'http://localhost:3000',
                'access-control-allow-credentials': 'true'
            }
        }
    }
});

14-5. 邊緣渲染 ( Edge-side Rendering )

官網: https://nuxt.com/docs/guide/concepts/rendering#edge-side-rendering

ESR 邊緣渲染 是 Nuxt 3 中一個厲害的功能,其允許你的 Nuxt 程式在離實際使用者更近的位置進行渲染,藉由 CDN ( Content Delivery Network ) 技術中的邊緣伺服器 ( edge servers )

藉由 ESR 技術能帶來增進網站效能以及減少延遲進而增加使用者體驗 ( 因為在 CDN 會直接處理使用者的請求進而回傳已在 CDN 中渲染好的 HTML 內容 )

ESR 簡單的說就是將渲染位置推到 CDN 的邊緣網絡中,所以與其說 ESR 屬於不同的渲染模式不如說其不同的只在部屬的目標不同而已

ESR 能在 Nuxt3 中被使用,全歸功於 Nuxt3 所選定的伺服器引擎 Nitro,其也提供跨平台支援 ( 支援 Node.js, Deno, Cloudflare, Workers…etc. )

可以支援 Nuxt3 的平台如下

注意: 混合渲染也可以與 ESR 一起使用

十五、插件 ( Plugins ) 目錄

參考自: https://nuxt.com/docs/guide/directory-structure/plugins

Nuxt 的 plugins 是用來作為原 Vue 功能的擴充 ( 就像 Chrome 套件與 Chrome 瀏覽器的關係 ),或者調用原 Vue 方法 & 設定 ( directive, use…etc. )

在 Nuxt 中安裝 Plugins 有幾個特點功能

  • 所有在 Nuxt 專案中有在 Plugins 資料夾/目錄內定義的插件都是自動註冊的 ( 只有第一層 ),也就是除了不用在 Nuxt.config.ts 外,使用時也不需要另外引入

  • 若是有些套件只能在 server 端或是 client 端運行,可以在該 plugins ts, js 或 vue 檔名後加上 .server 或是 .client 的後綴

       plugins/
    |—— myPlugin.client.js
    |—— testPlugin.server.js

    註: 比如有些會與 web api 互動的套件 ( window, document ),若讓其在 server 階段運行會跳錯 ( window is not defined )

15-1. Plugins 目錄的自動引入

呈上所提,在 Nuxt 專案目錄中的 plugins 資料夾註冊的插件都是自動註冊的 (auto-imports),因此已在這註冊的插件不用再到 nuxt.config.ts 再設定一次。但這邊需要特別注意的是雖然 Nuxt 會掃描在 Plugins 下的 ts, js, vue 檔名並自動註冊,但其所掃描與註冊的對象與 component 資料夾一樣只有在以下兩個情況會被掃描到

  • 在 Plugins 底下的第一層 ts, js 或 vue
  • 在 Plugins 底下的第一層資料夾下的 index.ts

如下範例

   plugins/
├── myPlugin/
│   └── index.ts  // 會被掃描
└── myPlugin.ts // 會被掃描

但若有特殊需求,需要註冊 Plugins 第一層以下範圍的話,可以使用個別註冊的方式

若是以下方 plugins 目錄中的資料結構作為範例,筆者個別在其後方備註了 nuxt 在預設狀況下能是否能被掃描到的情況

   -| plugins/
---| foo.ts      // 會被掃描
---| bar/
-----| baz.ts    // 不會被掃描
-----| foz.vue   // 不會被掃描
-----| index.ts  // 會被掃描

此時就可以用原本註冊 plugins 的方式,在 nuxt.config.ts 進行個別註冊

   // nuxt.config.ts
export default defineNuxtConfig({
  plugins: [
    '~/plugins/bar/baz',
    '~/plugins/bar/foz'
  ]
})

15-2. 建立插件 & nuxtApp 介紹

Plugin 只提供 nuxtApp 唯一參數在 defineNuxtPlugin 函式作為預設參數導入,而 nuxtApp 為一個物件,寫法如下

   // plugins/myPlugin.js
export default defineNuxtPlugin(nuxtApp => {
  // 可以使用 nuxtApp 來配合設定
})

nuxtApp 功能介紹說明

各個項目的功能介紹官網: https://nuxt.com/docs/api/composables/use-nuxt-app

基本上 nuxtApp 是一個 Nuxt 提供給開發者以獲取各種 Nuxt 運行時的脈絡 ( context ) 資訊,我們可以在自製的插件內部放個 console.log 來看一下 NuxtApp 這物件有哪些內容,咱挑一些可能可以用到的看

註: 若是想在 Nuxt 專案內其他頁面調用 nuxtApp 的話也可以,使用 Nuxt 內建的組合函式 useNuxtApp 或是 [tryUseNuxtApp](https://nuxt.com/docs/api/composables/use-nuxt-app#tryusenuxtapp)

https://i.imgur.com/ijH5sR0.png

  • 15-2-1. nuxtApp 中的 Hooks 以及與 Hooks 相關的方法

    官網: https://nuxt.com/docs/guide/going-further/hooks

    hook(name , cb)

    在 Nuxt 中的 Hooks 功能是由 unjs/hookable 所提供 (也就是相關使用方式除了官網外,也可以直接參考該套件的寫法 )

    在 Nuxt 中的 Hooks 主要是針對 不同的生命週期進行掛勾( hook ),hooks 可以搭配 vue 的組合式函數、Nuxt plugin 去使用

    使用範例

       export default defineNuxtPlugin((nuxtApp) => {
      nuxtApp.hook('page:start', () => { // 對應 page 組件在載入前 ( Suspence ) 的階段
        /* your code goes here */
      })
      nuxtApp.hook('vue:error', (..._args) => {
        console.log('vue:error') // 對應 vue error 出現時觸發
        // if (process.client) {
        //   console.log(..._args)
        // }
      })
    })

    而 Nuxt 中的 Hooks 可以針對以下各個階段不同的生命週期進行監聽 ( 官網 )

    • 在本地開發 ( runtime ) 的 App Hooks 階段 ( 連結 )
    • 在打包 ( build ) 階段 的 Nuxt Hooks ( 連結 )
    • 在伺服器執行階段的 Nitro App Hooks ( 連結 )
  • 15-2-2. nuxtApp 中的 Payload

    這邊會呈現由 server 端傳到客戶端的狀態變數 & 方法,比如下圖的 state 以及 Pinia 就是,其中幾個屬性這邊順便說明下

    • serverRendered ( Boolean ): 若目前是 SSR 則是 true
    • data ( object ): 若是有用 useFetch 或是 useAsyncData 在 server 端執行過 api ,則這些資料便會在 payload.data 中緩存,並傳到客戶端,所以藉由獲取 payload.data 中的資料就可以獲得在 server 端的資料狀態
    • state ( object ): 可以獲取使用 useState 定義的共享狀態

    https://i.imgur.com/Nu61V7Z.png

  • 15-2-3. nuxtApp 中的 VueApp

    透過 nuxtApp 中的 VueApp 讓我們方便在 Nuxt 中直接調用 Vue.js 所提供的方法,一些常用到的如下 ( Vue 中可以調用的方法 )

    • component() : 新註冊或取用已註冊的元件

    • directive() : 新定義或取用已定義的 vue 指令 ( 定義指令方式介紹 )

         // plugins/my-directive.ts
      export default defineNuxtPlugin((nuxtApp) => {
        nuxtApp.vueApp.directive('focus', { // 定義 Vue 指令
          mounted (el) {
            el.focus()
          },
          getSSRProps (binding, vnode) {
            // you can provide SSR-specific props here
            return {}
          }
        })
      })
    • use(): 可以用來安裝 vue.js 的 plugins

         // VueApp.use() 範例
      // plugins/vue-gtag.client.ts
      import VueGtag, { trackRouter } from 'vue-gtag-next'
      
      export default defineNuxtPlugin((nuxtApp) => {
        nuxtApp.vueApp.use(VueGtag, { // 使用 vueApp 註冊 vue 的插件
          property: {
            id: 'GA_MEASUREMENT_ID'
          }
        })
        trackRouter(useRouter())
      }) 

    https://i.imgur.com/vn2KY2j.png

15-3. 載入插件的預設順序

在預設情況下,插件的載入順序是依照套件名稱的前綴,以字母順序引入 ( alphabetical order ) 所以若我們希望套件是按照特定順序引入,可以在套件檔名前面加上數值作為前綴,如下範例

   plugins/
 | - 01.myPlugin.ts
 | - 02.myOtherPlugin.ts

此範例結構中,02.myOtherPlugin.ts 可以獲取 以載入 01.myPlugin.ts 的功能

註: 但需要注意的是由於載入的順序是按照字母順序並不是按照數值順序,所以若同時有命名為 10.myPlugin.ts 以及 2.myOtherPlugin.ts 的兩個套件,其會由 10 開頭的套件先載入,而不是 2 的那個,也正因如此官網上面的範例是以 0 作為開頭

15-4. 插件的同步載入

  • 插件同步載入

    plugins/ 預設會依序載入,如果希望同時載入,只要在 plugin 內加上 parallel: true,下一順位 plugin 就會跟這個 plugin 同時載入

       // plugins/asyncPlugin.js
    export default defineNuxtPlugin({
      name: 'async-plugin',
      parallel: true,
      async setup (nuxtApp) {
        // 這裡的功能等同於一般函式定義的 plugin
      }
    })
  • 相依插件的同步載入

    若某插件恰好是相依於同步的插件,需要該同步套件載入後才能被執行,此時就可以使用 dependsOn 陣列來定義

       //  plugins/depending-on-my-plugin.ts
    export default defineNuxtPlugin({
      name: 'depends-on-my-plugin',
      dependsOn: ['my-plugin']
      async setup (nuxtApp) {
        // this plugin will wait for the end of `my-plugin`'s execution before it runs
      }
    })

15-5. 插件能使用 Composables

在 plugins 這資料夾內定義套件時,也一樣可以獲取自訂的組合函數 ( composables ) 或是 工具函數 ( utils )

   // plugins/hello.ts
export default defineNuxtPlugin((nuxtApp) => {
  const foo = useFoo()
})

但在使用上,需要注意兩點:

  • 若 composable 依賴於其後才載入的 plugin,則可能無法正常運作
  • 若 composable 依賴 Vue 生命週期,由於 composable 綁定的是使用他的元件實體,但 plugin 只會綁定在 nuxtApp 實例,可能無法正常運作

15-6. 利用 Provide 注入全域變數

基本上就是利用 Provide & Inject 的方式搭配 nuxtApp 使 Provide 注入的值成為全域變數

   export default defineNuxtPlugin(nuxtApp => {
  // 方法一
  nuxtApp.provide('hello', (msg: string) => `Hello ${msg} !`);

  // 方法二
  return {
    provide: {
      hello: (msg: string) => `Hello ${msg} !`
    }
  };
})

註: 功能同 Nuxt2 inject 寫法: inject('hello', msg => Hello $\{ms\} !)

接著就可以在頁面中使用 useNuxtApp 這組合式函式取得全域變數

   // pages/hello.vue
<template>
  <div>
    {{ $hello('World') }}
  </div>
</template>

<script setup>
const { $hello } = useNuxtApp();
</script>

但 Nuxt 官方並不建議這樣使用,他們希望藉由 composables ( useState ) 或式 pinia 等全域狀態管理工具統一作管控,而不是使用 Provide 的這方法,因為這麼做可能會造成全域汙染

十六、模組 ( Modules ) 目錄

Nuxt 可以透過模組 ( Modules ) 進行原 frameworks 的核心功能擴寫,雖然 Nuxt 同樣可以通過插件 ( Plugins ) 的方式進行擴展後再進行相關的配置 ( 但也等於每開一個新的專案也都需要重複配置 ) ,但有些套件已與 Nuxt 框架模組進行整合,此時就可以透過 modules 將該整合模組進行引入以減少重複配置,而這些高整合度的模組就可以透過 官方( 指令開頭 @nuxt/ (e.g. [@nuxt/content](https://content.nuxtjs.org/) )) 、社群 ( @nuxtjs/ (e.g. [@nuxtjs/tailwindcss](https://tailwindcss.nuxtjs.org/) ))、第三方 ( nuxt-. Anyone can make them ) 進行進一步的安裝

註: 若你要安裝本地/自行設定的 modules 其 Nuxt auto-import 邏輯同 plugins

  • 只有第一層的檔案會被載入
  • 以字母順序作為預設載入順續
   補充: Nuxt 3 中 plugin 與 modules 的差異
根據文章: https://ithelp.ithome.com.tw/articles/10299594
其有提及 modules 與 plugin 還有一個重要的差異點在於執行的時間不同
Nuxt 在伺服器啟動時,會依序
1. 載入 modules 並執行
2. 建立 Nuxt 的環境 (Context) 與 Vue 的實例 (Instance)
3. 載入並執行 Nuxt 的 plugins

因此 modules 相較於 plugin 可以做更多的事情 ex:啟動 nuxt 時透過 modules 來覆蓋模板、配置 webpack...etc.

但筆者在官網中未看到直接對應的描述佐證,但有間接描述其不同的執行時間點, 原文如下
連結: https://nuxt.com/docs/guide/going-further/modules
1.Ultimately defineNuxtModule returns a wrapper function with the lower level (inlineOptions, nuxt) module signature. This wrapper function applies defaults and other necessary steps before calling your setup function
2. Modules, like everything in a Nuxt configuration, aren't included in your application runtime.
Inside the runtime directory, you can provide any kind of assets related to the Nuxt App:
- Vue components
- Composables
- Nuxt plugins
  • 若需要自訂 modules 可以參考以下兩篇文章

  • 以下是筆者使用 nuxt devtools 介面直接下載 & 安裝 Nuxt modules 的紀錄

    在 nuxt dev tool 中,點選 modules

    https://i.imgur.com/uREx9Sc.png

    找到目標 modules

    https://i.imgur.com/qftwDJz.png

    點選後會跳出需要驗證身分的視窗

    https://i.imgur.com/eHZDpJN.png

    輸入此時在你專案 terminal 中出現的對應 token

    https://i.imgur.com/MV8ebAI.png

    待填入 & 授權後,再點選同一個套件會看到以下畫面,其代表是當你確定要安裝,它會自動執行哪些指令 & 配置

    https://i.imgur.com/Uo1ozQM.png

    按下 install 後就會自動安裝 & 進行相對應的配置囉

    https://i.imgur.com/RnWJvgK.png

十七、中間件 ( Middleware ) 目錄

曾撰寫過 Nuxt2 的開發者想必對於 Middleware 並不陌生,基本上雖說 Nuxt3 官網的翻譯是 “中間件” 但其實其作用 & 功能叫他路由守衛 ( Navigation Guards ) 或許更為貼切,基本上就是路由轉換的中間件,因此有些需要判斷頁面權限抑或是限制路由載入中間判斷邏輯都會寫在這裡 ( 有些隱私訊息不放在本地存儲的,也會藉由 middleware 去判斷是否需要拿相對應的資訊 ex: 個人基本資料 ),基本上 Nuxt 的路由以及 Moddleware 功能就是由 vue-router 所提供 ( Nuxt 的相依套件 ) 並包裝而成 ,因此在功能上 vue-router 有的功能基本上也都能在 Nuxt3 中實現 ( 也就是說除了在各別 router 轉換在載入前後可以判斷外,也可以進行全域路由轉換時的中間需執行邏輯撰寫 ) ,只是在撰寫上有所差異。

在 Nuxt 的 Middleware 分為三類

  1. 匿名路由中間件 ( Anonymous route middleware ) 在各別頁面中直接定義 ( 直接用 [definePageMeta](https://nuxt.com/docs/api/utils/define-page-meta) 定義 )
  2. 具名路由中間件 ( Named route middleware ),定義在根目錄的 middleware 資料夾內,當使用到對應路由時會載入 ( 非同步 ) & 使用 ( 用defineNuxtRouteMiddleware 定義,並在使用頁面利用 [definePageMeta](https://nuxt.com/docs/api/utils/define-page-meta) 指定引入 )
  3. 全域路由中間件 ( Global route middleware ),同樣會定義在根目錄中的 middleware 資料夾內,但與具名路由不同的是: 全域路由需要在檔案名尾部加上 .global ,而其會在每次路由被轉換時執行 ( 只能用 defineNuxtRouteMiddleware 定義 )

須注意項目

  1. 檔案命名邏輯需要烤肉串形式 ( kebab-case ) : 建議用烤肉串形式命名 middleware 的檔案名稱 ex: my-middleware.ts
  2. middleware 與 server/middleware 不同: 需要注意的是在 Nuxt 根目錄的 middleware 資料夾內所放置的路由邏輯,只在 Vue 邏輯層運行,與 server 資料夾下面的 middleware 資料夾名稱雖相同,但功能上完全不同,server/middleware 的內容主要是針對每個針對於 server 的 request 接觸 server 前,進行檢查或 添加對應的表頭或資料,在功能上與 根目錄的 middleware 是完全不同的

17-1. 基本使用介紹

middleware 會接收目前 ( from ) 以及即將前往的路徑 ( to ) 作為其參數

   // middleware/my-middleware.ts

// 需注意的點是 Nuxt 的 middleware 中止有兩個參數( to, from ) 沒有 vue-router 的第三個參數 ( next )
export default defineNuxtRouteMiddleware((to, from) => {
  if (to.params.id === '1') {
    return abortNavigation() // 中止目前的路由轉換 (會停留在原本的頁面位置)
  }
  // 通常會用 to.path 作為即將前往路由位置的判斷,另外要避免 to.path & navigateTo 所指向路徑的差異
  if (to.path !== '/') {
    return navigateTo('/') // 前往指定路徑同等於 vue-router 的 push
  }
})

Nuxt 提供了兩組在專案全域可用的 API,可以在 middleware 作為 return 的對應值 ( 作為返回對象 )

  1. navigateTo 指向重新定向目標路由
  2. abortNavigation 中止導航,其參數位置可以放置對應的錯誤信息 ( 沒參數就是沒有錯誤信息 ),位置仍維持在 from 位置

在 Middleware 定義的邏輯中,其 return 的值只可能是以下幾類

  1. 沒有回傳內容 ( return 後不接任何值,或是乾脆連 return 都沒有 ): 代表不會阻斷目前的路由轉址,其會繼續原本邏輯 ( 可能是皆下另一 middleware 定義邏輯或是乾脆完整載入目標路由 )

  2. return navigateTo(路徑): 例如 return navigateTo(’/’),代表重新定向路由前往指定位置 ( 若此段邏輯在 server 中進行,則同時會設定於request 為 get 的 header 上標示 ****[302 Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302) )

  3. return nagivateTo( 路徑, { redirectCode: 301 } ): 與上讚功能雷同,但不同點是在 server 中進行時,可指定 code 的類型為 [301 Moved Permanently](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301) 代表原路徑已被永久移除

  4. return abortNavigation(): 代表停止目前路由的轉換 ( 會停在 from 的位置 )

  5. return abortNavigation(error): 停止路由轉換外,會出現指定錯誤信息

這邊建議在使用 Nuxt 時,使用 Nuxt 提供的 API 路由工具 ( navigateTo, abortNavigation ),在目前雖然 return 的對應值也可以寫 vue-router 的 API 工具,但是未來有可能不會支援

17-2. 匿名路由中間件介紹 & 範例

如17章一開始所提,其使用方式在各別頁面中直接定義 ( 直接用 [definePageMeta](https://nuxt.com/docs/api/utils/define-page-meta) 定義 )

   // pages/user.vue
<template>
  <div>...</div>
</template>

<script setup>
definePageMeta({ // 同具名 middleware 頁面元件掛載方式
  middleware: [
    (to, from) => {
      // 這裡是匿名 middleware 邏輯設定
    }
  ]
});
</script>

17-3. 具名路由中間件介紹 & 範例

如17章一開始所提,其使用方式: 用defineNuxtRouteMiddleware ****定義,並在使用頁面利用 [definePageMeta](https://nuxt.com/docs/api/utils/define-page-meta) 指定引入

   // middleware/check-login.ts

export default defineNuxtRouteMiddleware((to, from) => {
   if (!isLoggedIn) {
    return navigateTo('/login');
  }
})

在個別元件使用時引入

   // pages/user.vue
<template>
  <div>...</div>
</template>

<script setup>
definePageMeta({
  middleware: [ 'check-login' ] // or middleware: 'check-login'
});
</script>

17-4. 全域路由中間件介紹 & 範例

如17章一開始所提,其使用方式: 全域路由需要在檔案名尾部加上 .global ,而其會在每次路由被轉換時執行,與具名一樣用 defineNuxtRouteMiddleware ****定義,但不需要被引入 ( 因為只要路由轉換就會被觸發 )

   // middleware/check-login.global.ts

export default defineNuxtRouteMiddleware((to, from) => {
   if (!isLoggedIn) {
    return navigateTo('/login');
  }
})

17-5. 引入順序說明

Nuxt 中 Middleware 的載入順序順序

  1. Global Middleware ( 若有多個則是按照字母順序 )
  2. 各別頁面定義的 middleware 順序 ( 若有多個會是以 array 的形式定義,載入順序也會依照 array 的定義順序載入 )

基本上,middleware 的載入順序會以 Global 優先載入,再來會以各頁面( page )元件其定義的載入順序依序載入

如官網示例

   middleware/
--| analytics.global.ts (載入順序 1 )
--| setup.global.ts( 載入順序 2 )
--| auth.ts
   // pages/profile.vue
<script setup lang="ts">
definePageMeta({
  middleware: [
    function (to, from) { // 載入順序 3
      // 自定義
    },
    'auth', // 載入順序 4
  ],
});
</script>

這邊以我自己練習的範例截圖為例

https://i.imgur.com/RvWPIEd.png

但若希望 global 的 middleware 載入順序依照自己設定的順序,可以在該檔案前以編號的方式命名,以確保 Nuxt 以自訂順序載入

   middleware/
--| 01.setup.global.ts
--| 02.analytics.global.ts
--| auth.ts

17-6. 判定 middleware 運行環境

若你設定的該頁是 SSR 或是 Univeral rendering 的第一頁,則該頁面就會同時在 Server 端與 Client 端各執行一次,換句話說就是會執行至少 2 次,但若你的 middleware 中有些引用的組件或是需要執行的邏輯只在 Client 端或是 只有在 Server 端,就可以搭配官網給予的以下方法進行判斷

   export default defineNuxtRouteMiddleware(to => {
  if (process.server) return // 在 server 端則不進行 middleware 內的邏輯
  if (process.client) return // 在 client 端則不進行 middleware 內的邏輯
  const nuxtApp = useNuxtApp()
  if (process.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return
})

17-7. Plugins 中設置動態 middleware

除了在 middleware 資料夾中設定中間件的邏輯外,也可以在 plugins 資料夾中使用 [addRouteMiddleware()](https://nuxt.com/docs/api/utils/add-route-middleware) 來設定 middleware 的動態載入邏輯

   // plugins/myPlugins

export default defineNuxtPlugin(() => {
  addRouteMiddleware('global-test', () => {
	// 全域 middleware 其邏輯會每次路由變換時觸發 ( 其載入相較於 middleware 中定義的 global 晚,但比 page 個別引入的早 )
    console.log('this global middleware was added in a plugin and will be run on every route change')
  }, { global: true })

  addRouteMiddleware('named-test', () => {
	// 具名 middleware: 在個別頁面載入時,若 middleware 中有同名的邏輯 ( ex:middleware/named-test ),plugins 中的 named-test middleware 會替代掉原 middleware/named-test.ts 邏輯
    console.log('this named middleware was added in a plugin and would override any existing middleware of the same name')
  })
})

這邊以我自己練習的範例截圖為例

https://i.imgur.com/ehcruEp.png

十八、伺服器 ( Server ) 目錄

Nuxt3 後才有 Server 這資料夾 ( Nuxt2 沒有 ),而在 Server 資料夾內可以開發原屬於後端的 API 邏輯,而能夠撰寫後端邏輯這個功能則是由 Nitro 這個網頁伺服器框架 ( web server framework 也可以說是 server engine 但這兩個名詞的概念有差異 ) 所提供,因此要了解 Nuxt3 的 server 資料夾這功能,也必須了解 Nitro 這 web server framework

Nitro 搭配 unjs/h3 來建立 Server API,而 unjs/h3 也提供許多 utilites 方便開發者使用

18-1. Nitro 介紹

官網: https://nitro.unjs.io/guide/getting-started

屬於 web server framework ( 也可以說 Nitro 是 server engine ) ,其是 Typescript 所撰寫且專為 Nuxt3 而設計進而對外公開的網頁伺服器框架。

而安裝了 Nitro 的 Nuxt3 也正式讓 Nuxt 解鎖撰寫全端的能力

   網頁伺服器框架 ( web server framework) 功能說明

簡單的理解是撰寫網頁後端邏輯所使用的框架,而之所以說是框架,是因為他也同時提供了一
些工具與函式庫,讓開發者更可以方便的開發網頁後端邏輯,舉例:
1. 根據不同的 request 返回不同的網頁或是資料
2. 與資料庫互動 ex: 存取使用者的評論、文章內容、使用者資訊
3. 支援權限設定與授權 ex: 註冊、登入、登出、修改個人設定
4. 回傳 JSON, HTML, XML 等資料內容給瀏覽器/客戶端
5. 提高網頁的安全性 EX: 減少 SQL injectio, CSRF

其網頁伺服器框架的目的是為了讓開發者可以專注在對應的網頁邏輯撰寫,而不用太費心思在網路通訊
或更底層的細節處理,同時也讓後端程式碼可以更結構與模組化以方便維護與拓展

常見的網頁伺服器框架有: express, nitro, spring boot...etc.

而 Nitro 引擎主要是基於 unjs/h3 以及 rollup 進而開發而成,這邊也順便簡略說明下這兩個套件的功能

  • unjs/h3 : 提供輕量但支援 HTTP 通訊協議的框架
  • rollup.js : 是 js 的模組打包工具,他可以將多個小檔案合併成一個大檔案以減少請求伺服器的次數,同時其也有 tree-shake 等去除多餘程式碼的功能 ( vite 也有使用到 rollup 套件 )

補充 1: 因為 node.js 本身就支援各種通訊協議,所以撰寫 node.js 或對應的框架 ( express ) 時並不用再另外安裝支援通訊協議的套件,但是 Nitro 本身有跨平台運行的需求 ( Node.js、Deno、瀏覽器、Service Worker…etc. ) 但不是每個平台都支援 HTTP 通訊協定所以需要安裝 支援 HTTP 框架套件 h3

補充 2: 關於 HTTP 通訊協議,再前端開發時不需要特別安裝是因為 web api ( 瀏覽器 ) 有提供,當然也可以另外安裝 axios 之類的套件

使用 Nitro 來作為 Nuxt3 的 server engine 有以下幾點好處

Nitro 官網: https://nitro.unjs.io/

  • 開發快速: 不用特別設定,在開發模式時就有熱更新功能 ( HMR: Hot Module Replacement )
  • 基於檔案路徑路由自動生成: 會依照 server 檔案下的檔案路由自動註冊路由路徑,讓開發者可以專注撰寫網頁應用邏輯
  • 跟 node_modules 說掰掰: Nitro 在打包 & 建構正式網站時,不會也不需要再安裝 package.json 中對應的依賴, Nitro 會拆分這部分的相依程式碼只抽取有用到的部分並打包出 .output 這檔案 ( 依據官網描述是小於 1 MB 的容量 ),使得部屬時可以更為輕量
  • 支援混合渲染模式: 透過 Nitro 可以設定哪些頁面需要預渲染 ( 靜態 ),哪些是伺服器渲染或 CSR 等。也可以配置每個路由不同的靜態或動態快取規則,甚至可以結合 serverless 服務等
  • 更好的支援 typescript

18-2. server 目錄基本介紹

Nuxt3 的 Server 目錄常用來註冊 API 以及掛載對應的伺服器處理程序到自身專案中。

在 Server 資料中,常見的資料夾擺放位置如下 (涵蓋了 api, routes, middleware 三個資料夾 ),Nuxt 會自動掃描 server 目錄下的API 以及部分伺服器處理應用 ( server handler ),並支援熱更新 Hot Module Replacement (HMR)

   -| server/
---| api/            # 放置開頭為 api 路由
-----| hello.ts      # 自動生成路徑開頭為 api 的路由 (/api/hello)
---| routes/         # 放置想對應的路由路徑
-----| bonjour.ts    # /bonjour 產生為 /bonjour 的路由路徑 
---| middleware/     # server middleware 可以在每個 client 的 request 送到 server 前執行
-----| log.ts        # log all requests

每個在 server 目錄下定義的文件都應該以 defineEventHandler 或是 eventHandler 定義預社匯出函式,並在此 handler 中定義實作邏輯

defineEventHandler ( 別名 eventHandler ) 可以直接回傳 JSON 資料 、 promise,或者也可以使用 event.node.res.end() 作為 response 內容

18-3. server/api 目錄 ( 建立 api )

以下是筆者練習的簡單範例

   // server/api/hello.ts
export default eventHandler((event)=> {
  console.log('event from server dir', event) // 這邊 console 出來的解果放在註 1
  return {
    hello: 'Hello Dear Mother'
  }
})

取用方式

   // pages/index.vue
<script setup lang="ts">
	// 注意這邊取出來的是 Ref 值,所以需要以  .value 進行取值
  const { data } = await useFetch('/api/hello')
	console.log('hello data', data.value.hello)

</script>

若直接將滑鼠一到解構出來的 data 會得到以下 ts 提示

https://i.imgur.com/nGvHrpi.png

若此時將瀏覽器路徑指向該 api 路徑 (以範例是 http://localhost:3000/api/hello ) 則會呈現以下畫面

https://i.imgur.com/MW0yP5d.png

若此時使用 postman 針對 Nuxt 在本地起的伺服器路徑打該 api 地址,也會正常回傳如下

https://i.imgur.com/dzOHIk7.png

註 1: 這邊使用 console.log event 的結果,在 server 端 ( vs code terminal 端 )呈現的內容如下,從以下結果可以知道此 event 的功能應同樣來自 h3.js 插件

  • console.log eventHandler 中的 event

       event H3Event {
      __is_event__: true,
      node: {
        req: IncomingMessage {
          __unenv__: [Object],
          _events: [Object: null prototype] {},
          _maxListeners: undefined,
          readableEncoding: null,
          readableEnded: true,
          readableFlowing: false,
          readableHighWaterMark: 0,
          readableLength: 0,
          readableObjectMode: false,
          readableAborted: false,
          readableDidRead: false,
          closed: false,
          errored: null,
          readable: false,
          destroyed: false,
          aborted: false,
          httpVersion: '1.1',
          httpVersionMajor: 1,
          httpVersionMinor: 1,
          complete: true,
          connection: [Socket],
          socket: [Socket],
          headers: [Object],
          trailers: {},
          method: 'GET',
          url: '/api/hello',
          statusCode: 200,
          statusMessage: '',
          body: null,
          originalUrl: '/api/hello'
        },
        res: ServerResponse {
          __unenv__: true,
          _events: [Object: null prototype] {},
          _maxListeners: undefined,
          writable: true,
          writableEnded: false,
          writableFinished: false,
          writableHighWaterMark: 0,
          writableLength: 0,
          writableObjectMode: false,
          writableCorked: 0,
          closed: false,
          errored: null,
          writableNeedDrain: false,
          destroyed: false,
          _data: undefined,
          _encoding: 'utf-8',
          statusCode: 200,
          statusMessage: '',
          upgrading: false,
          chunkedEncoding: false,
          shouldKeepAlive: false,
          useChunkedEncodingByDefault: false,
          sendDate: false,
          finished: false,
          headersSent: false,
          strictContentLength: false,
          connection: null,
          socket: null,
          req: [IncomingMessage],
          _headers: {}
        }
      },
      web: undefined,
      context: {
        _nitro: { routeRules: {} },
        nitro: { errors: [] },
        matchedRoute: { path: '/api/hello', handlers: [Object] },
        params: {}
      },
      _method: undefined,
      _path: '/api/hello',
      _headers: undefined,
      _requestBody: undefined,
      _handled: false,
      fetch: [Function (anonymous)],
      '$fetch': [Function (anonymous)],
      waitUntil: [Function (anonymous)],
      captureError: [Function (anonymous)]
    }

18-4. server/routes 目錄 ( 建立 api )

如果不希望自己的 api 路徑有 /api 作為路徑前綴,此時就可以將希望路徑作為檔案名並放在 server/routes 這資料夾內

以下是筆者練習的簡單範例

   // server/routers/userInfo.ts
export default eventHandler((event)=> {
const userInfo = {
  name: 'nobody',
  age: 18,
  money: 0
}

export default defineEventHandler(async () => {
  await new Promise((resolve) => setTimeout(resolve, 3000)) // 等待 2 秒
  return JSON.stringify(userInfo)
})

取用方式

   // pages/index.vue
<script setup lang="ts">
	// 注意這邊取出來的是 Ref 值,所以需要以  .value 進行取值
  const { data } = await useFetch('/userInfo')
	console.log('data', data.value)

</script>

若此時將瀏覽器路徑指向該 api 路徑 (以範例是 http://localhost:3000/userInfo ) 則會呈現以下畫面

https://i.imgur.com/O25zRxc.png

postman 打該 api 後的結果

https://i.imgur.com/qVIZboO.png

18-5. server/middleware

Nuxt 會自動讀取在路徑 ~/server/middleware 下方所有的文件,並在瀏覽器端的每一個 request 到達 server 前,添加或檢查 headers、紀錄 request 或者拓展 event 中的 request 物件

注意: Server 中的 middleware 不會 return 任何東西,也絕對不會關閉或回應任何的 request,其功能只該是 檢查下 request 的內容、拓展 request 的 context 或是丟些錯誤資訊

以下是筆者練習的簡單範例

先建立 api

   // server/routes/userInfo.ts
const userInfo = {
  name: 'nobody',
  age: 18,
  money: 0
}

export default defineEventHandler(async () => {
  await new Promise((resolve) => setTimeout(resolve, 3000)) // 等待 2 秒
  return JSON.stringify(userInfo)
})

建立 middleware 的檔案

   // server/middleware/log.ts
export default defineEventHandler((event) => {
	// 使用 getRequestURL(event) 可以獲取到瀏覽器發出 request 的對應 url 路徑
  console.log('middleware log request: ' + getRequestURL(event))
  event.context.auth = { user: 123 }
  console.log('event.context', event.context)
})

在首頁呼叫 api

   // pages/index
<script setup lang="ts">
	// 呼叫 api
  const { data } = await useFetch('/userInfo')
  console.log('data', JSON.parse(data.value).name)

</script>

<template>
...
</template>

此時從 VS Code 的 terminal 中 ( 代表 server 運行的部分 ),就可以看到以下畫面,也就是在打 api 前,Nuxt 確實得先執行了 server/middleware 內的邏輯

https://i.imgur.com/8P8DKlc.png

18-6. server/plugins

Nuxt 允許在 server 下面的 plugins 資料內增加 Nitro 的套件以拓展 Nitro 在 server 階段執行 ( runtime ) 的行為,也可以搭配 Nitro runtime hooks 來針對執行時的不同生命週期進行邏輯設定,詳情也可參考 Nitro 官網的介紹頁 ,若要訂定 server/plugins 下面的文件,需使用 defineNitroPlugin 這函式。

官網範例

   //server/plugins/nitroPlugin.ts
export default defineNitroPlugin((nitroApp) => {
  console.log('Nitro plugin', nitroApp)
})
  • console.log(nitroApp) 的打印結果

       {
      hooks: Hookable {
        _hooks: {},
        _before: undefined,
        _after: undefined,
        _deprecatedMessages: undefined,
        _deprecatedHooks: {},
        hook: [Function: bound hook],
        callHook: [Function: bound callHook],
        callHookWith: [Function: bound callHookWith]
      },
      h3App: {
        use: [Function: use],
        handler: [AsyncFunction (anonymous)] { __is_handler__: true },
        stack: [ [Object], [Object], [Object], [Object] ],
        options: {
          debug: true,
          onError: [Function: onError],
          onRequest: [AsyncFunction: onRequest],
          onBeforeResponse: [AsyncFunction: onBeforeResponse],
          onAfterResponse: [AsyncFunction: onAfterResponse]
        }
      },
      router: {
        add: [Function (anonymous)],
        use: [Function (anonymous)],
        connect: [Function (anonymous)],
        delete: [Function (anonymous)],
        get: [Function (anonymous)],
        head: [Function (anonymous)],
        options: [Function (anonymous)],
        post: [Function (anonymous)],
        put: [Function (anonymous)],
        trace: [Function (anonymous)],
        patch: [Function (anonymous)],
        handler: [Function (anonymous)] { __is_handler__: true }
      },
      localCall: [Function: callHandle],
      localFetch: [Function: localFetch],
      captureError: [Function: captureError]
    }

若是有在非 server/plugins 資料夾下定義 nitro plugins 的內容可以在 nuxt.config.ts 中以如下範例的方式載入

比如我在 Nuxt 的根目錄放了一個 Nested 資料夾內有一個名為 check.ts 的 nitro plugins

   // Nested/check.ts
export default defineNitroPlugin(() => {
  console.log('check plugin is loaded')
})

那我在 nuxt.config.ts 中補上以下這段,就能順利載入 ( 補充: 在 nuxt.config.ts 定義的 nitro plugins 會比 server/plugins 中定義的 plugins 優先載入 )

   // nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    plugins: ['@/Nested/check.ts']
  }
})

在 nitro 中有幾個可以用的生命週期鉤子

  • "close", () => {} : 關閉 Nitro 時觸發(關閉 Nuxt 專案)
  • "error", (error, { event? }) => {} : server 端有錯誤信息時觸發
  • "render:response", (response, { event }) => {} : 在 SSR 時 server 有提供的渲染畫面內容觸發
  • "request", (event) => {} : 有請求時觸發
  • "beforeResponse", (event, { body }) => {}: server 回應前觸發
  • "afterResponse", (event, { body }) => {}: server 回應後觸發

以下是筆者練習的簡單範例

先定義 server 端的 nitro error hook 作為錯誤捕捉 plugin

   // server/plugins/example.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook("error", async (error, { event }) => {
    console.error(`${event.path} Application error:`, error)
  });
})

並在首頁畫面邏輯上打了一隻不存在該路徑的 api

   // pages/index.vue
<script setup lang="ts">
// 打了隻不存在的 api 故意製造錯誤
  const { data } = await useFetch('/userInfo/123')
  
</script>

由於範例首頁是 SSR 頁面且使用 useFetch 打 API,所以實際打 api 的行為會在 server 端,所以可以在 VSCode 中 terminal 看到 server 端的錯誤內容,如以下畫面

https://i.imgur.com/wqhIUS0.png

18-7. server 動態路由

在 server 中建立 api 時 ( api / routes ) 也可以使用動態路由 ( 常見使用場景: 產品頁、文章頁 ),此時可以利用像 /api/hello/[name].ts 的方式進行動態路由設置,若想在該檔案中取得其具體動態變數 (這範例是 name ),則可用 event.context.params/ getRouterParam 獲取

官網範例

   // server/api/hello/[name].ts
export default defineEventHandler((event) => {
  const name = getRouterParam(event, 'name')

  return `Hello, ${name}!`
})

以下是筆者練習範例

這邊想建立一個 product 的動態路由並獲取其產品名稱

先建立 product 的動態路由 api

   // server/api/product/[name].ts
export default defineEventHandler((event) => {
  const name = getRouterParam(event, 'name')
	console.log('event.context.params', event.context.params)
  return `Hello, ${name}!`
})

在首頁畫面使用 useFetch 打該動態路由 api

   // pages/index.vue
<script setup lang="ts">
	const { data } = await useFetch('/api/product/MacBook')
  console.log('data', data.value)
</script>

由於目前範例首頁是 SSR 渲染,所以畫面的 console.log 也會在 server 中被執行,從 VS Code 中的 terminal ( server 端反應結果 ) 如下

https://i.imgur.com/YCjUJYp.png

18-8. 匹配 HTTP 請求

在 server 中建立 api 時,可以在檔名上加上 **HTTP 方法**名稱作為檔名的後綴,以定義取用此 api 的方法,比如 server/api/test.get.ts

但若同一條 url 對應不同的 HTTP 方法有不同功能,可以直接在 server/api 或 server/routes 底下建立資料夾,而下方的檔案用 index 開頭的命名,如 index.post.ts,如下

   // server/api/foo/index.get.ts
export default defineEventHandler((event) => {
  // handle GET requests for the `api/foo` endpoint
})
   // server/api/foo/index.post.ts
export default defineEventHandler((event) => {
 // handle POST requests for the `api/foo` endpoint
})

以下是筆者練習範例

假設需要建立接收前端傳遞的 post 請求作為 body payload 的 api

建立 api

   // server/api/count.post.ts
export default defineEventHandler(async (event) => {
  let counter = JSON.parse(event.node.req?.body).counter || 1
	// 取 body 也可以用 h3.js 提供的 utities readBody 其會回傳一個 promise
	console.log('readBody(event)', await readBody(event)) //readBody(event) { counter: 2 }
  counter += 1

  return JSON.stringify(counter)
})

順便建立擁有同樣路徑,但對應不同 http 方法有不同邏輯的 api

   // server/api/foo/index.get.ts
export default defineEventHandler((event) => {
  return '觸發 GET foo api '
})
   // server/api/foo/index.post.ts
export default defineEventHandler((event) => {
  return '觸發 post foo api '
})

並在畫面邏輯首頁針對上面三隻 api 進行呼叫

   // pages/index.vue
<script setup lang="ts">
const { data:countData } = await useFetch('/api/count', {
    method: 'POST',
    body: {
      counter: 2
    }
  })
  const { data:getFooData } = await useFetch('/api/foo', {
    method: 'GET',
  })
  const { data: postFooData } = await useFetch('/api/foo', {
    method: 'POST',
  })
  console.log('data', countData.value)
  console.log('getFooData', getFooData.value)
  console.log('postFooData', postFooData.value)
</script>

由於目前範例首頁是 SSR 渲染,所以畫面的 console.log 也會在 server 中被執行,從 VS Code 中的 terminal ( server 端反應結果 ) 如下

https://i.imgur.com/HwYTWvb.png

18-9. 匹配任意路由

可以建立 […].ts 的檔案,來將所有未定義的路由路徑來做轉導 ( 類似前端路由 404 的作法 & 概念 ) 比如若設置 ~/server/api/foo/[...].ts ,則會將所有在 /api/foo/ 路徑下未定義的路由轉導至此路由

比如筆者以下的範例 ( 範例情境是 /api/check 路徑下除了以下定義內容外沒有其他已定義的路徑 )

   // server/api/check/[...].ts
export default defineEventHandler((event) => {
   console.log('event.context.params', event.context.params)
  return '隨便拉~'
})

那只要符合 /api/check/ 此路徑且未被定義的路徑都會被導到這個定義來,我們可以用 event.context.params 來查看對應的 request 路徑內容

比如在畫面呼叫符合此路徑但未定義的 api 路徑如下

   <script setup lang="ts">
	const { data } = await useFetch('/api/check/whatever')
</script>

那在 server 端我們設定的 server/api/check/[...].ts 就會被觸發,並打印出我們於其中定義的

console.log('event.context.params', event.context.params) 的邏輯,其在 server 端打印結果如下

server 端執行結果

    event.context.params { _: 'whatever' }

但若我們希望該 request 的路徑對應 event.context.params 的 key 有個具體自定義的 key 名稱好方便取用,這點也可以做到,比如如下範例

假設我們要捕捉在 api/catchAny 這路徑下未被定義的 request url ,而我希望取用該 request url 時,我的 params key 值叫 “slot”,那我們可以定義如下檔案,並命名該檔名為 […slot].ts

   // server/api/catchAny/[...slot].ts
export default defineEventHandler((event) => {
	// 這邊就可以以自定義的名稱 slot 取用 params ex: event.context.params.slot
  console.log('event.context.params', event.context.params)
  return '自定名拉~'
})

那麼我們打 api 時

   <script setup lang="ts">
	const { data } = await useFetch('/api/checkAny/whatever')
</script>

其 server 上回傳的結果如下

    event.context.params { slot: 'whatever' }

18-10. 建立錯誤信息

在訂定 api 時,若沒有丟出任何錯誤,則一致都會回傳 200 OK 的訊息

但若在執行時有遇到任何預期之外的錯誤,則一致都會顯示 500 Internal Server Error 的 HTTP 錯誤,若希望回傳這兩者以外的錯誤狀態碼與信息,可以使用 createError 來定義

   // server/api/validation/[id].ts
export default defineEventHandler((event) => {
  const id = parseInt(event.context.params.id) as number

  if (!Number.isInteger(id)) {
    throw createError({
      statusCode: 400,
      statusMessage: 'ID should be an integer',
    })
  }
  return 'All good'
})

那我們嘗試建立一個 count api 當 request 傳入的 payload 型別不為數字型別則跳 400 以及錯誤信息 ( 注意: 錯誤信息也須符合 http 規範,若自己在自訂 statusCode 這邊亂設 一律會被轉成 500 )

   // server/api/count.post.ts
export default defineEventHandler(async(event) => {
  let { counter } = await readBody(event)
  if( typeof counter !== 'number'){
    throw createError({
      statusCode: 400,
      statusMessage: 'YOU CAN ONLY USE NUMBER TYPE!!!!',
    })
  } else {
    counter += 1
    return JSON.stringify(counter)
  }
})

那當我們在畫面上呼叫我們自行定義的 api count 時故意傳入字串數值

   <script setup lang="ts">
	const { data } = await useFetch('/api/count', {
    method: 'POST',
      body: {
        counter: '1234'
      },
  })
</script>

此時我們就可以看到以下畫面

https://i.imgur.com/OpwAeQq.png

18-11. 處理 request 中的 body, params, queries, cookies

Nitro 搭配 unjs/h3 來建立 Server API,而 unjs/h3 也提供許多 utilites 方便開發者使用

  1. 可以參考所有 h3 的 utilities 文件: https://www.jsdocs.io/package/h3#package-functions
  2. 筆者在研究 server 章節時就有用到 h3 所提供的 readBody 以及 getRouterParams, getQuery, parseCookies
  • readBody:

    可以取得 client request 中所攜帶的 payload,也就是 request 中的 body 所攜帶的內容

    比如以下的使用者請求,以及對應 api 的設置

    api 內容

       //server/api/submit.post.ts
    export default defineEventHandler(async (event) => {
      const body = await readBody(event) // readybody 就可以解析使用者放在 request body 內的內容\
      return { body }
    })

    範例使用者請求

       //pages/index.vue
    <script setup>
    async function submit() {
      const { body } = await $fetch('/api/submit', {
        method: 'post',
        body: { test: 123 } 
      })
    }
    </script>
  • getRouterParam: 與原 vue-router 的 params 功能基本一致 ( this.$route.params.id ) ,可以藉由此工具函式取得動態路由中,其 user 實際 request 的動態路由實際值 如之前曾提及的範例,假設有一個動態路由**/api/hello/[name].ts**  那要取得其動態路由 client 端實際請求的 name 值,則可用兩個方式

  1. **event.context.params

  2. getRouterParam**

       // server/api/hello/[name].ts
    export default defineEventHandler((event) => {
      const name = getRouterParam(event, 'name') // 可以取得動態路由實際帶入的路徑值
    
      return `Hello, ${name}!`
    })
  • getQuery: 與原 vue-router 的 params 功能基本一致 ( this.$route.query ) ,,可以藉由此工具函式取得如 /user?id=111,其 ? 後面的 query 參數與值

    範例: 若要取的請求路由為 /api/query?foo=bar&baz=qux

       //server/api/query.get.ts
    export default defineEventHandler((event) => {
      const query = getQuery(event) // 取得 query 參數的 key 與 value
    
      return { a: query.foo, b: query.baz }
    })
  • parseCookies 取得 client request header 中所攜帶的 cookies

       // server/api/cookies.ts
    export default defineEventHandler((event) => {
      const cookies = parseCookies(event)
      return { cookies }
    })

    舉例,比如我設置 API 如下

       // server/api/hello.get.ts
    export default eventHandler((event)=> {
      const cookieContent = parseCookies(event)
      return cookieContent // 回傳收到的 cookies 內容
    })

    然後在頁面中呼叫

       // pages/index.vue
    
    <script setup lang="ts">
    	const { data } =  await useFetch('/api/hello', {
        method: 'GET',
        server: true,
        headers: { cookie: "description=somebody; screenMode=dark; itemsCode=17" }
      })
      console.log('data', data.value) // 回傳結果 Proxy(Object) {description: 'somebody', screenMode: 'dark', itemsCode: '17'}
    
    </script>

18-12. 與 RuntimeConfig 配合使用

在前面有在第 8 章提及 RuntimeConfig 是甚麼、以及如何使用等詳細介紹,這邊就簡略的介紹其在 server 這邊搭配使用的情境,以及對應方法,若不熟的建議可以回去看下第 8章

在前面有提及 Runtime Config 主要是在 Nuxt3 中處理些機敏資訊,在 server 端有用到但不方便提供給 client 端使用的一些重要的變數,以下則參照官網範例,以 github 的 token 作為進一步的描述

   // server/api/foo.ts
export default defineEventHandler(async (event) => {
	// 使用 useRuntimeConfig 取得在 nuxt.config.ts 中設定的 runtimeConfig 所設定的變數
  const config = useRuntimeConfig(event)

	// 在 server 中打 api 並取用 runtimeConfig
  const repo = await $fetch('https://api.github.com/repos/nuxt/nuxt', {
    headers: {
      Authorization: `token ${config.githubToken}` // 取得對應 token
    }
  })

  return repo
})

在 nuxt.config 中設置

   // nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    githubToken: ''
  }
})

如前面所述,在 nuxt.config 中設定的 runtimeConfig 會轉成與 .env 內變數相同的 ‘NUXT_GITHUB_TOKEN’ 變數,所以若是有同時設定此 env 的環境變數,則會蓋掉原 nuxt.config 的設定,如下範例

   // .env
NUXT_GITHUB_TOKEN='<my-super-token>'

若有機敏資訊存取在 env 中,則可用環境變數做取代,並在上傳 Github 時做設定以避免上傳至公用的 Gitlab 空間 ,而當在 CICD 做該專案的部署時,針對這種機敏資訊對應的變數值再另外設置,以避免機敏資訊外流

18-13. 進階使用方式

  • 18-13-1. Nitro 設定

    可以直接在 nuxt.config.ts 中設定 Nitro 的相關設定 ( 官網介紹 )

       // nuxt.config.ts
    export default defineNuxtConfig({
      // https://nitro.unjs.io/config
      nitro: {}
    })
  • 18-13-2. 自訂巢狀路由

    如稍早所提,基本上 server 的路由也多是由 server/api 以及 server/routes 目錄下的層級所組合而自動生成,但若想要自訂多層路由,又不希望一堆資料夾,此時則可以參考以下範例 & 方法

    直接使用 h3 的 createRouter() 方法來建立巢狀路由。

       // server/api/check/[...].ts 
    import { createRouter, defineEventHandler, useBase } from 'h3'
    
    const router = createRouter()
    
    router.get('/test', defineEventHandler(() => 'Hello World')) // 單一路由設置
    router.get('/test/user', defineEventHandler(() => 'Hello User')) // 多層路由設置
    router.get('/whatever', defineEventHandler(() => '隨便拉')) // 單一路由設置
    router.get('/:id', defineEventHandler(() => '其他內容')) // 任意路由設置
    router.get('', defineEventHandler(() => '首位')) // 單一路由設置
    
    export default useBase('/api/check/selfMade', router.handler) // base 路由
    // 注意: 依照此範例位置,useBase 只能設置 /api/check 作為 baseUrl 開頭

    在設置時需特別注意:

    1. 必須為 server/api 或 server/routes 資料夾下,並且設置任意路由檔案 ex: […].ts
    2. 其設置的 base url ( useBase ) 必須符合其階層前面命名 ( ex: server/api/check/[…].ts 的情況,則 base url 只能設置 ‘api/check’ 開頭 )

18-14. 跨平台存取 ( ex: radis )

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

18-15. 接 SQL DB 範例

windows postgresql 安裝: 直接下載對應的 exe

  • 密碼 Ss54088

  • port 設定: 5432

  • Prisma: https://www.prisma.io/

    • 提供 ORM ( Object Oriented Mapping ) 可以使用物件導向的方式來操作 db ( 使用物件與類別來操作 db )
    • 支援多種資料庫切換 ex: PostgrersSQL, MySQL, SQL Server, SQLite, MongoDB, CockroachDB
    • 可以預防 SQL Injection ( 因為他可以協助查詢參數的驗證與轉譯,可提高 DB 的安全性 )
    • 不需要直接寫 SQL 語言,直接使用 Prisma Client 來進行資料庫的操作
  • supabase: https://supabase.com/docs/guides/getting-started/tutorials/with-nuxt-3

    • 提供 js 可以與 db 互動的 libary ( 提供 from、select、insert、update、delete、filter、order、limit 方法與 db 進行互動 ) ( 使用函式與參數來操作 DB )
    • 只支援 postgresSQL
    • 在 nuxt module platform 有提供整合過的 module

註: 此兩者並不衝突,也不是互為替代的關係,也可以使用 Prisma 搭配 supabase 來開發

  • 搭配 Prisma 使用 SQLite

    參考來源文章: https://supabase.com/docs/guides/getting-started/tutorials/with-nuxt-3

    • 安裝 prisma 與 prisma client
       yarn add -D prisma @prisma/client
    • 初始化 Prisma,使其產生 Schema

      初始化後,在 nuxt 的根目錄會產生 prisma 資料夾,內含 schema.prisma 檔案

       npx prisma init
    • 初始化後在 terminal 會產生以下信息
       Next steps:
    1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
    2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
    3. Run prisma db pull to turn your database schema into a Prisma schema.
    4. Run prisma generate to generate the Prisma Client. You can then start querying your database.
    • 此時產生的 prisma/schema.prisma 檔案因為預設是 postgresql 所以需要修改成如下
       // This is your Prisma schema file,
    // learn more about it in the docs: https://pris.ly/d/prisma-schema
    
    generator client {
      provider = "prisma-client-js"
    }
    
    // 將以下內容修改成 SQLite
    datasource db {
      provider = "sqlite"
      url      = "file:./dev.db"
    }

    若將 provider 改成其他,可以參考官網說明

    • 新增一個 User schema 在 schema.prisma 內
       model User {
      id             String   @id @default(uuid())
      providerName   String?
      providerUserId String?
      nickname       String   @default("User")
      email          String   @unique
      password       String?
      avatar         String?
      emailVerified  Boolean  @default(false)
      createdAt      DateTime @default(now())
      updatedAt      DateTime @updatedAt
    }

    ( 延伸學習項目: SQL schema, SQL 註解 @ )

    接下來執行已下指令來初始化資料庫,prisma 會依照 schema.prisma 來建立對應的資料表

       npx prisma db push

    此時本地資料庫就已建立,若要看 prisma 提供的 UI 介面,可以在 Nuxt 中的 prisma 資料夾內下以下指令,就可以從 Prisma 提供的 db 操作介面 Prisma Studio 中操作資料庫的內容了

       npx prisma studio

    確認有建立資料庫後,我們在 nuxt 專案資料夾中下以下指令來產生 Prisma Client ,好方便我們直接在 Nuxt 專案中使用 Prisma Client 來操作我們稍早建立的資料庫

       npx prisma generate

    以下是在 nuxt 專案中的 server 寫一隻 api 來使用 Prisma Client 來操作 db 的範例

       // server/api/test-create-user.get.ts
    import { PrismaClient } from '@prisma/client'
    const prisma = new PrismaClient()
    
    export default defineEventHandler(() => {
      // console.log('PrismaClient', PrismaClient)
      const userInfo = prisma.user.create({
        data: {
          providerName: null,
          providerUserId: null,
          nickname: 'Bruno',
          email: 'jackhellowin@gmail.com',
          password: '',
          avatar: '',
          emailVerified: false,
        }
      })
    
      return userInfo
    })
  • 搭配 Prisma 使用 Postgresql

    • 安裝 prisma 與 prisma client
       yarn add -D prisma @prisma/client
    • 初始化 Prisma,使其產生 Schema

      初始化後,在 nuxt 的根目錄會產生 prisma 資料夾,內含 schema.prisma 檔案

       npx prisma init
    • 初始化後在 terminal 會產生以下信息
       Next steps:
    1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
    2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
    3. Run prisma db pull to turn your database schema into a Prisma schema.
    4. Run prisma generate to generate the Prisma Client. You can then start querying your database.
    • 此時產生的 prisma/schema.prisma 檔案如下,並加上預設 schema ( 注意: 若這邊有型別定義錯誤,則會回傳 500 錯誤會無法使用 )
       // This is your Prisma schema file,
    // learn more about it in the docs: https://pris.ly/d/prisma-schema
    
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }
    
    model user {
      id             Int   @id
      providerName   String?
      providerUserId String?
      nickname       String   @default("User")
      email          String   @unique
      password       String?
      avatar         String?
      emailVerified  Boolean  @default(false)
      createdAt      DateTime? @default(now())
      updatedAt      DateTime? @updatedAt
    }
    • 安裝 postgresql windows 安裝檔連結: 直接下載對應的 exe

      教學可參考: https://postgresql-note.readthedocs.io/en/latest/section01/Install/01_install-PostgreSQL.html

      • 跟著 exe 設置使用者密碼與 port 號

      • 安裝只鈎 server engine 就可以了 ( Stack Builder 可以選擇不安裝 )

      • 在 windows 本地中查找關鍵字”pgAdmin” ,其就是 postgres 於本地的 UI 介面

        https://i.imgur.com/tUmzitO.png

      • 在左側中的 Database 點選右鍵,create database ( 建立自訂的 database 的名稱 範例為 user )

        https://i.imgur.com/fLhvpbd.png

      • 在該新建的 user Database 找到以下 table 路徑 schemas/public/table 後按右鍵新增自訂 table 名稱 ( 範例為 user )

        https://i.imgur.com/MsYtzCE.png

      • 在 columns 內部訂定對應的 columns

        https://i.imgur.com/7OQFnzD.png

      • 在 Nuxt 中的 .env 中加入如下的 variables

           DATABASE_URL="postgresql://postgres:Ss54088@localhost:5432/user?schema=public"

十九、內容 ( Content ) 目錄

Nuxt 3 提供了 content 這資料夾在目錄中,主要目的是方便開發者創建一個基於文件格式的文件管理系統 ( CMS: Content Management System )

Nuxt3 是使用 Nuxt 團隊開發的模組套件 Nuxt Content,用其解析 ( parse ) 開發者在 content 目錄下放的 .md.yml.csv.json 等類型的檔案

而開發者也藉由 content 這資料夾達到以下目的

  • 藉由 Nuxt Content 模組內建的元件來渲染 content 內容
  • 藉由類似 MongoDB 的方法來操作 content
  • 藉由 MDC 語法可以在你的 Markdown 文件中調用 Vue 元件
  • 自動生成導航

啟用 Nuxt Content 模組

須將 @nuxt/content 這模組安裝至你的 Nuxt 專案,並且將該模組加入你的 nuxt.config.ts 中

你可以利用 Nuxt 官方提供的指令來同時達成這兩件事

   npx nuxi module add content

創建內容

將我們的 Markdown 檔案放到 content 目錄中

   # 🏅 Week1 - typescript 練習
**本週學習重點:**
1. 基本型別 number, string, boolean, undefined, null, 
2. 任意值 any
3. 型別推論 type infer
4. 聯合型別
5. interface 介面/接口 ( 可選屬性/任意屬性/唯讀屬性 )
6. array 數組型別
10. 函式型別 ( 陳述/表達/可選參數/參數默認/重載/剩餘參數 )

<br>

呼叫 & 呈現內容

讓我們嘗試在 Nuxt 專案頁面中使用 ( 使用到 Nuxt Content 提供的方法 ContentRenderer )

   // pages/content/index
<template>
  <div>
    <h2>這是 content 目錄下的內容</h2
      >
			<!-- 使用 contentRenderer 可以使用對應的解析成畫面 -->
      <ContentRenderer :value="data"  />
  </div>
</template>
<script lang='ts' setup>
	// 使用 useAsyncData 去銜接放在 content 資料夾下的內容
const { data } = await useAsyncData('requestContent', () => queryContent('/').findOne())
</script>
<style lang='scss' scoped></style>

呈現畫面如下

https://i.imgur.com/xhs4qOX.png

request content

https://i.imgur.com/Pd9xD1j.png

二十、工具 (utils) 目錄

此資料夾存在於 Nuxt3 的意義是方便用以區隔 Vue composables ( 工具函式定義於 composables 資料夾內 )

20-1. Composables 與 Utils 資料夾比較

基本上不管是 utils 或是 composables 都是可用來存放可複用的工具函式,且皆會被 Nuxt auto-import 於專案中,但兩者所存放的工具函式邏輯有些不同,以下是進一步的說明

  • compoables 資料夾:

    存放的是 Vue composables ( composition api 寫法的可複用函式 ),其多用來放置可複用的 有狀態邏輯 ( Stateful Logic ),換句話說就是從有類似的邏輯中抽取共同部份並封裝成可複用的抽象邏輯 ( composition api 中的 Vue composables 就是 option api 中的 mixins )

  • utils 資料夾: 目的是與 Vue composables 做區隔,其多用來放置無狀態邏輯 ( Stateless Logic ) 的可複用函式

補充: 有狀態邏輯 ( Stateful Logic ) 與 無狀態邏輯 ( Stateless Logic ) 說明

  • 有狀態邏輯 ( Stateful Logic ):

    也可以說是會產生副作用 ( side effect ) 的函式,每當觸發此類型函式時,其他狀態可能會改變 ( 是指執行時被改變,不含自行指定的返回對象 ),也就是說就算輸入相同的值也可能會得到不同的結果

  • 無狀態邏輯 ( Stateless logic ):

    也可以說是不會產生 副作用 ( side effect ) 的函式,也就是不論何時輸入相同的值皆可以期待可以得到同樣的結果,大部分封裝共用的工具方法都屬於這類 ( 輸入值後,經無狀態邏輯計算所返回的值,其並不會因為其他狀態的變動而被影響 )

20-2. utils 使用方法

資料夾下的命名檔案方式,可以用烤肉串命名 ( Kebab Case ) 或是 小駝峰命名 ( lower camel case )

  • 方法一: 使用具名匯出 ( named export )

    官網範例

       // utils/text.ts
    
    // intl 則是 ECMA 提供的國際化 API 的物件,可以進行個國家的字串、數字、日期時間等格式化
    // 其內含 ( NumberFormat 與 DareTimeFormat 和 callator 構造函式 )
    // Intl.NumberFormat 是結構函式可以格式化 number 的值
    // 使用解構重新命名的方式將原在 Intl.NumberFormat instance 中的 format 函式解構出來
    // 並重新命為 formatNumber 函式
    export const { format: formatNumber } = new Intl.NumberFormat('en-GB', 
    // en-GB 代表用英國語言環境來格式化數字 ( locales 參數 )
    { // 物件用來撰寫需要格式化的方式
      notation: 'compact', //緊湊表示,通常會用較大或較小的表示類型,較容易閱讀
      maximumFractionDigits: 1 // 代表顯示小數位數
    })
    • 補充:
      • intel 介紹 ( MDN )
      • intel 內含的 ( NumberFormat 與 DareTimeFormat 和 callator 構造函式 ) 基本上會包含兩個參數 locales, options
        • locales: 必須是 BCP 47 規範的字串 ( 國家部分可參照 IANA ),可支持延伸寫法
        • options: 物件,會因為不同的功能內容會有變化

    那在使用則非常簡單,直接呼叫就好

       //pages/index.vue
    <script setup lang="ts">
      console.log('formatNumber ', formatNumber(12345))// 12.3K
    
    </script>
  • 方法二: 使用預設匯出 ( default export )

    官網範例

       // utils/random-entry.ts or utils/randomEntry.ts
    
    // 給予 array 並回傳任意一個 array item
    export default function (arr: Array<any>) {
    	// Math.random 隨機顯示 0 ~ 1
    	// Math.floor 無條件捨去至整數位
      return arr[Math.floor(Math.random() * arr.length)]
    }

    使用一樣簡單,直接呼叫就好 ( 不管是烤肉串或是小駝峰檔名,一律都用小駝峰函式名呼叫 )

       //pages/index.vue
    <script setup lang="ts">
      console.log('randomEntry',  randomEntry([1,2,3,5,7])) // 3
    
    </script>

二十一、錯誤處理方式 & 工具 ( Error Handling & Error Page ) -Nuxt Hooks 補充

官網: https://nuxt.com.cn/docs/getting-started/error-handling

21-1. Nuxt 常見的錯誤介紹 ( Nuxt Hooks 補充 )

Nuxt3 屬於全端框架,也就是說在執行時可能會發生各種不可預期的錯誤,以下是官網歸類的 4 大類錯誤來源以及對應的說明

   **補充: Nuxt 的 Lifecycle hooks** 
但在了解 Nuxt 提供的各種錯誤工具之前,我們須了解 Nuxt 中的 Lifecycle hooks 這工具,以及這些 hooks 能使用的情境與範疇 
Nuxt 的 Lifecycle hooks 是 Nuxt 內原代碼藉由 **[unjs/hookable](https://github.com/unjs/hookable)** 預定義的 Lifecycle hooks,其目的是可以藉由 hooks
與 app (app & vue) 或 server 在執行時間或是打包時的不同的生命週期或產生特殊狀態時掛勾,在 app (app & vue) 或 server 執行到該階段時可以被觸發
進而執行我們所自訂的邏輯

Nuxt 所提供的 Lifecycle hooks 大致分成**三大類** & 以及其各自常被定義的目錄位置
1. **APP Hooks ( APP 執行/運行 (runtime) 時相關的 Hook )**: 
	- 說明: 多與 Vue、app 畫面渲染的生命週期有關
	- 定義位置: 多定義 & 使用在 Nuxt 的 Plugins 資料夾 

2. **Nuxt Hooks ( 打包 (build) 時相關的 hook )**
	- 說明: 不只與打包階段有關(ex:編譯、模板生成),也與 Nuxt 本身的狀態 (ex: instance 是否已生成、被關閉、重啟),模組(modules)的安裝、建立、引入相關、nitro 的建立等等
	- 定義位置: 多定義 & 使用在 nuxt.config 以及 modules 資料夾

3. **Nitro App Hooks( 多與 server運行時相關的 hook )**
	- 說明: 多與 Nitro server 運行相關的 hook ( ex:request, response 的時機, nitro 被關閉時.. )
	- 定義位置: 多定義 & 使用在 server 資料夾下的 plugins 資料夾

參考自
- Nuxt Lifecycle Hooks intro: [https://nuxt.com/docs/guide/going-further/hooks](https://nuxt.com/docs/guide/going-further/hooks)
- All lifecycle Hooks in Nuxt: [https://nuxt.com/docs/api/advanced/hooks#app-hooks-runtime](https://nuxt.com/docs/api/advanced/hooks#app-hooks-runtime)
  • Vue 渲染時的生命週期 ( Vue Rendering Lifecycle)

    此錯誤情境可能會發生在 Vue 於 server 端 ( SSR ) 或客戶端渲染 APP ( CSR )的時候產生

    • 在 Nuxt 中有兩個生命週期 Hooks 可以使用,其會在子層錯誤冒泡 ( propagate ) 至頂層時觸發該 Hook

      • onErrorCaptured: Vue 本身提供的生命週期函式 Hook ( 與 onMounted 等同一分類 )
      • vue:error: Nuxt 提供根據 Vue 的 onErrorCaptured 所開發,其傳入的參數與 onErrorCaptured 一致皆是 error, instance, info ( 屬於三大類中的 APP Hooks 多在 plugins 資料夾中藉由預設參數 nuxtApp 來搭配使用 )
         補充 NuxtApp: 其不只在 plugins 中藉由預設參數取得,
      Nuxt 提供 useNuxtApp 此組合式函式方便使用者可以在 plugins、components、composables 
      資料夾中藉由使用 useNuxtApp 來獲取 NuxtApp,也可以利用 compsables auto-import 的特性,
      將其取得的 NuxtApp 讓你在 app 各個地方使用
      
      參考自: https://nuxt.com/docs/guide/going-further/nuxt-app
    • 如若你有使用 錯誤回報框架 ( error reporting framework ),你可以藉由自訂義插件在 plugins 資料夾做以下設定,其會在錯誤產生時被觸發 ( 就算是之後有其他機制將其處理掉的也會 )

         // plugins/error-handler.ts
      export default defineNuxtPlugin((nuxtApp) => {
      // 方法一: 使用 vue 提供的 errorHandler 設定因錯誤而觸發時對應的執行邏輯
        nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
          console.error('[由 vueErrorHandle 插件所捕獲的錯誤]', error)
        }
      
      // 方法二: 使用 nuxtApp.hook('vue:error', ()=>{}) 
        nuxtApp.hook('vue:error', (error, instance, info) => {
          console.error('[由 Nuxt 的 vue:error hook 所記錄到的錯誤]', error)
        })
      })
  • 啟動錯誤 ( Startup Error )

    如果 Nuxt 專案在啟動其間產生任何錯誤,都會觸發 app:error Hook ( 屬於三類中的 APP Hooks 多被使用 & 定義在 plugins 資料夾內 )

    這包含了以下情境

    • 運行 Nuxt plugins
    • 處理 & 觸發 app:created Hook ( vueApp instance 剛被建立 ) app:beforeMount ( 在掛載 app 前 )
    • 在 server 端使用 vue 渲染頁面成 HTML
    • 正在掛載 ( mounting ) app 到客戶端時 ( 此階段的錯誤也可以使用 Vue 渲染時的生命週期 的 onErrorCaptured 與 vue:error )
    • app 剛被掛在到客戶端觸發 app:mounted 時 ( 此階段的錯誤也可以使用 Vue 渲染時的生命週期 的 onErrorCaptured 與 vue:error )
  • Nitro Server的生命週期 ( Nitro Server Lifecycle )

    Nitro 就是 Nuxt 內建寫後端邏輯的 server engine,目前尚無如同 Vue 生命週期出錯的 handler ( nuxtApp.vueApp.config.errorHandler ) 可以去定義其出錯時需要執行的邏輯,但可以利用 我們等下在 21-2 中所提及藉由 Error Page 的建立去呈現 Nitro server 的出錯呈現的畫面

  • 網頁下載 JS chuncks 產生的錯誤 ( Errors with JS chunks )

    當網路差導致連結失敗或是有新的部屬時 ( 新部屬會使原先舊的 js chunk URL 失效 ) 而產生 JS chunks 的載入錯誤時,Nuxt 有內建處理這種問題的機制,當路由導航的過程中 JS chunks 失敗時,執行強制刷新 ( Hard reload )

    而若你希望關閉這種處理機制,也可以藉由手動設置將 experimental.emitRouteChunkError 設置為 false 來關閉

21-2. Error Page 錯誤頁

  • 21-2-1. 自訂錯誤頁面

    在專案根目錄新增 error.vue,因 error page 本身不具有路由,因此不能使用 definePageMeta 方法 ( 雖說如此仍可使用 <NuxtLayout> 來定義 error page 的 layout )

       |—— app.vue
    |—— error.vue

    範例 error 頁面

       //error.vue
    <script setup lang="ts">
    import type { NuxtError } from '#app'
    
    const props = defineProps({
      error: Object as () => NuxtError
    })
    </script>
    
    <template>
      <div>
        <h1>{{ error.statusCode }}</h1>
        <NuxtLink to="/">Go back home</NuxtLink>
      </div>
    </template>

    而 error 頁面會有一個 prop 也就是 error object 被傳入

    • error object 只涵蓋以下屬性
       {
      statusCode: number
      fatal: boolean
      unhandled: boolean
      statusMessage?: string
      data?: unknown
      cause?: unknown
    }

    若我們的 API 中有自訂的額外屬性,則須放在 data 中,否則會讀取不到

    自訂 api 錯誤範例

       throw createError({
      statusCode: 404,
      statusMessage: 'Page Not Found',
      data: {
        myCustomField: true
      }
    })
  • 21-2-2. 錯誤頁面渲染時機

    官網: https://nuxt.com.cn/docs/getting-started/error-handling

    當發生**致命錯誤(fatal error)**時,會自動觸發錯誤頁面,如果是非致命錯誤(non-fatal error)只會拋出錯誤訊息,可能觸發錯誤頁的時機如下:

    Server Side

    • 執行 Nuxt plugins 發生錯誤

    • 編譯 Vue app 到 HTML 發生錯誤而致使無法順利渲染

    • Server API 產生錯誤

      註: 筆者實際測試的情況是,若該頁是 SSR 渲染 ( 通用渲染的觸發頁 ),則在 server 端會先進自訂的 error.vue 頁面後,但若其錯誤不影響頁面渲染,則 server 回傳給 client 端的畫面上仍正常,也就是呈現上一樣不會有問題,只有會影響頁面渲染的重大錯誤 ( ex: 未定義的物件中取屬性在畫面上顯示 ) ,不管是否是 SSR 或 CSR,皆會進入自訂的 error.vue 頁面

    Client Side

    • 執行 Nuxt plugins 發生錯誤
    • app:beforeMount 生命週期發生錯誤
    • 不會被 onErrorCaptured 方法或 vue:error 生命週期捕捉的錯誤
    • Vue app 初始化與 app:mounted 生命週期發生錯誤

    註: 筆者測試在未定義 404頁面時,Error 頁面也會在客戶端觸發不存在路由時出現

21-3. 錯誤工具 ( Nuxt 提供的工具函式 )

  • useError: 會返回 Nuxt 正在處理的全域錯誤

       function useError (): Ref<Error | { url, statusCode, statusMessage, message, description, data }>
  • createError: 可以讓你不管在客戶端( vue ) 或是 server 中自訂可被拋出的錯誤物件 ( with additional metadata. )

       function createError (err: { cause, data, message, name, stack, statusCode, statusMessage, fatal }): Error

    但在客戶端與瀏覽器端丟出用 createError 自訂的錯誤效果會不大一樣

    • 在 server 端: 會觸發一個全屏幕的錯誤頁 ( error page,若你有自訂的會在此時 server 端被觸發 ),你可以使用 clearError 這方法將此錯誤清除
    • 在 client 端: 其只會觸發一個非致命 ( non-fatal ) 的錯誤,並不會觸發全屏幕的錯誤頁 ( error page ),但若你想在 client 端觸發全屏幕的錯誤頁 ( error page ) 的話,需要在 createError 時,加上 fatal: true
  • showError ( 連結 )

       function showError (err: string | Error | { statusCode, statusMessage }): Error

    你可以不管在 server 或 client 端呼叫此函式 ( 不管在 middleware, plugins 或是 setup 函式內皆可以呼叫 ) 在呼叫此函式後,會出現全屏的錯誤頁面 ( error page ),你可以使用 clearError 方法將其清除 ( 相較於使用 throw createError,Nuxt 官方更推薦使用 showError 方法 )

  • clearError ( 連結 )

       function clearError (options?: { redirect?: string }): Promise<void>

    使用此方法會清除掉目前 Nuxt 正在處理的錯誤,並且此方法也可讓你設定重新將頁面導向相較安全 ( 沒有錯誤 ) 的頁面

二十二、i18n 多國語言設置

官網: https://i18n.nuxtjs.org/docs/getting-started

i18n 其實是 inernationalization 取第一個字 i 以及其單字最後一個字的 n,並省略該單字內中間 18 個字母的縮寫,也就是國際化的意思,在 js 的 i18n 套件其目的是方便開發者針對不同語言的受眾做網頁語言的轉換

而其中國際化一個重要的概念是地區資訊( Locale )。其組成概念如下

   地區資訊 ( Locale ) = 語言編碼 ( Language code ) + 可選區域編碼 ( Country code )
  • 語言編碼 ( Language code ): 是由 ISO-639 定義,是用兩個英文小寫作為代表 ex: “zh” 代表中文、”en” 代表英文
  • 區域編碼 ( Country code ): optional 可選的,是由 ISO-3166 定義,是用兩個英文大寫作為代表 ex: “TW” 代表台灣 ( Taiwan ) 、”IT” 代表義大利 ( Italy )

在 Nuxt 中,有與 Nuxt 環境整合良好的模組可以使用是為 @nuxt/i18n ( 官網 ) 其有以下幾個重點特色

@nuxt/i18n ( 官網 ) 特色

  • 其是為 Nuxt 與 Vue 的 i18n 良好整合 ( 包含 SEO 的部分 )
  • 自動路由的生成 ( 自動在路由的前綴上加上對應的 locale 編碼 )
  • 支援 SEO ( 可以藉由組合式函式 composables 已根據目前的 locale 資訊添加對應的 SEO metadata )
  • 支援延遲載入 ( lazy-loading ): 只會載入使用者對應的語言,而不會載入全部的內容
  • 自動轉址 ( locale-aware redirection ): 支援根據使用者地點的自動轉址,可藉由 composables 來實現
  • 支援不同域名 ( locales specific domain ): 可依據不同的 locale 來設定不同的域名

22-1. Nuxt i18n 基本運用

Nuxt3 目前需要搭配 i18n 的 v8 版本 ( v7 只相容余 Nuxt 2 )

而基本上在 Nuxt 中使用 @nuxt/i18n 所提供的 composables 以及 API,不需要特別引入,因為其也有支援 auto-import

安裝

   yarn add -D @nuxtjs/i18n

並在 nuxt.config.ts 中設定,將 modules 欄位加上

   export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n']
})

在 @nuxt/i18n 中的預設方法是統一將各國語言的檔案統一存放在一個資料夾內 ( 以 langDir 定義資料夾名稱&位置以及 locales 定義語言代碼以及對應的檔案名稱,如下範例 )

目錄結構 ( 範例是建立一個 locales 資料夾在根目錄 )

   locales/
  |—— en.json
  |—— zh.json
nuxt.config.js

nuxt.config.ts

   // nuxt.config.ts
export default defineNuxtConfig({
  i18n: {
    strategy: 'prefix', // 設定是否在路由上加上對應 locale 前綴,預設是 'prefix_except_default'
    langDir: 'locales', // i8n 對應語言文件的存放目錄,範例是在根目錄下的 locales 資料夾
    locales: [ // 值為陣列,陣列內可以放字串或是物件
      {
        code: 'en', // 必填,代表對應的 locale 語言編碼
        iso: 'en-US', // 選填,但使用 SEO 為必填,定義在 IETF's BCP47,
        file: 'en.json' // 對應的檔案名稱
      },
      {
        code: 'zh',
        iso: 'zh-TW',
        file: 'zh.json'
      }
    ],
    defaultLocale: 'zh', //預設語系
    detectBrowserLanguage: { // 值為 boolean 或是物件,啟用偵測使用者瀏覽器目前語系
      useCookie: true, // true 代表會將使用者目前語系以 cookie 形式存在瀏覽器中
      cookieKey: 'i18n_redirected', // 存瀏覽器的 cookies key 名稱設定,預設是 'i18n_redirected'
      redirectOn: 'root' // 基於哪種路由路徑進行 i18n 轉址,若為了 seo 建議放 root 代表只在根目錄位址做判斷
    }
  }
})

也記得在對應的位置放置對應的語言文件

此範例是設在 locales 資料夾下

注意: 每個語言的 key 值要是一樣的,也皆需要對應的值,避免語言切換時無法對應到對應的值而產生錯誤

   // ./locales/en.json
// key: value
{
  "hello": "Hello!",
  "language": "Language",
  "about": {
    "title": "English",
    "description": "this is page in english"
  }
}
   // ./locales/zh.json
{
  "hello": "你好!",
  "language": "語言",
  "about": {
    "title": "繁體中文",
    "description": "這是繁體中文的頁面"
  }
}

而在專案內的基本調用

  • $t 這個 Vue 實體化的方法搭配我們設定好的 key 後就可以順利調用 ( 須注意: 調用時是用字串型式調用 )
  • useLocalePath() : 此 composable 是用來解析路徑,將帶入的路徑參數轉換成目前指定的語言路徑 ( 功能同 \<NuxtLinkLocale\> )
  • useSwitchLocalePath(): 此 composable 是用來切換網站語系成其所帶入的參數值

範例

   // pages/index.vue
<script setup lang="ts">
// 使用 @nuxt/i18n 的 composables 時不需要特別 import ,其會 auto-import
const localePath = useLocalePath() // 可以用來解析路徑的 composable
const switchLocalePath = useSwitchLocalePath() // 可以用來切換語系的 composable
</script>

<template>
  <div>
    <h1 class="text-primary">我是首頁默認組件</h1>
		<!--to 使用 v-bind 綁定並使用 useSwitchLocalePath 的 instance 來切換目前語系 -->
		<NuxtLink :to="switchLocalePath('zh')"> 繁體中文 </NuxtLink>
    <NuxtLink :to="switchLocalePath('en')"> 英文 </NuxtLink>
		<!--to 使用 v-bind 綁定並使用 useLocalePath instance 來解析目標路徑 -->
    <NuxtLink :to="localePath('/roles/admin')">
      roles/admin
    </NuxtLink>
		<!--NuxtLinkLocale = NuxtLink + useLocalePath()-->
    <NuxtLinkLocale to="/roles/admin">roles/admin(NuxtLinkLocale)</NuxtLinkLocale>
      <h2>測試 i18n 語言轉換</h2>
			<!--使用$t 以及我們設定的 key 就能顯示出對應的語言內容-->
      <p>{{ $t('hello') }}</p>
      <p>{{ $t('language') }}:{{ $t('about.title') }}</p>
      <p>{{ $t('about.description') }}</p>
    </div>
</template>

22-2. nuxt/i18n 中使用 vue i18n

Vue I18n 官網: https://vue-i18n.intlify.dev/guide/

之前也有提 及nuxt/i18n 是由 vue 的 I18n 套件深度整合而來,若開發者相對熟悉 vue I18n 套件的設定方法,也可以藉由 nuxt/i18n 提供的 vueI18n 的屬性在 nuxt.config.ts 中作相對應的配置

   // nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    vueI18n: './i18n.config.ts' // if you are using custom path, default
  }
})
   // i18n.config.ts
export default defineI18nConfig(() => ({
  legacy: false, // 代表不用舊的 i18n API
  locale: 'en',
  messages: { // 語系以及對應的 key & value 設置
    en: {
      welcome: 'Welcome'
    },
    zh: {
      welcome: '歡迎'
    }
  }
}))

而範例這邊所單獨抽出定義的 i18n.config.ts,其功能與 VueI18n 中的 createI18n 方法是一樣的,藉由如此的配置,也就等於在模組在安裝階段藉由 nuxt 的 plugin 傳入 createI18n 專案內部,當然對於 createI18n 的內部配置 & 設置方法,可參考 VueI18n 套件的官網

此時若想要在 Nuxt 專案中使用 VueI18n 的套件方法,其因為 @nuxt/i18n 所以皆支援 auto-import 功能,並不需要額外引入

範例

   // pages/index.vue
<script setup lang="ts">
// 使用vue i18n 的 composables 時不需要特別 import ,其會 auto-import
// locale 為 ref() 物件,可藉由改變其值而改變目前網站的語系
// 但需要的特別注意的是利用 vue i18n 的方法雖可以改變網站的語系,但無法改變 url 的語系前綴
const { locale } = useI18n()
</script>

<template>
  <div>
    <h1 class="text-primary">我是首頁默認組件</h1>
      <p>{{ $t('welcome') }}</p>
    </div>
		<!--可以藉由改變-->
		<select v-model="locale">
        <option value="en">en</option>
        <option value="zh">zh</option>
      </select>
</template>

22-3. i18n 的變數放置

  • 具名變數使用方法

    使用 { 變數名 }

       // ./locales/en.json
    // key: value
    {
      "hello": "Hello!",
      "language": "Language",
      "about": {
        "title": "English",
        "description": "this is page in english {name}" // 使用 {} 內置變數 name
      }
    }
       // ./locales/zh.json
    {
      "hello": "你好!",
      "language": "語言",
      "about": {
        "title": "繁體中文",
        "description": "這是繁體中文的頁面 {name}"  // 使用 {} 內置變數 name
      }
    }

    使用時,以物件型式帶入 $t

       // pages/index.vue
    
    <template>
      <div>
        <h1 class="text-primary">我是首頁默認組件</h1>
          <h2>測試 i18n 語言轉換</h2>
          <p>{{ $t('hello') }}</p>
          <p>{{ $t('language') }}:{{ $t('about.title') }}</p>
    			<!--帶入變數 name 所需的對應值-->
          <p>{{ $t('about.description', {name: 'inserted_success'}) }}</p>
        </div>
    </template>
  • 匿名變數使用方法

    使用 { index },這邊的 index 是為所帶陣列的 index 對應值

       // ./locales/en.json
    // key: value
    {
      "hello": "Hello!",
      "language": "Language",
      "about": {
        "title": "English",
        "description": "this is page in english {0}" // 使用 {} 內置index 
      }
    }
       // ./locales/zh.json
    {
      "hello": "你好!",
      "language": "語言",
      "about": {
        "title": "繁體中文",
        "description": "這是繁體中文的頁面 {0}"  // 使用 {} 內置index
      }
    }

    使用時,以陣列型式帶入 $t

       // pages/index.vue
    
    <template>
      <div>
        <h1 class="text-primary">我是首頁默認組件</h1>
          <h2>測試 i18n 語言轉換</h2>
          <p>{{ $t('hello') }}</p>
          <p>{{ $t('language') }}:{{ $t('about.title') }}</p>
    			<!--帶入變數 name 所需的對應值-->
          <p>{{ $t('about.description', ['inserted_success'] }}</p>
        </div>
    </template>

22-4. useLocaleRoute

若在 script 邏輯中想改變路由但同時需要考量到 i18n 等語系路由路徑時可使用

useLocaleRoute 是 nuxt/i18n 所提供的 composable 其 instance 會回傳一個 Route 物件 ( 是由 vue-router 處理的物件 ),此 instance 方法參數中可以帶入想帶入的 params 像是 query,好方便操作

其 instance 與 useLocalePath 的差別是,useLocalePath所回傳的是路徑而不是物件

   <script setup>
const localeRoute = useLocaleRoute()
function onClick() {
	// route 為 useLocaleRoute() 所建立的 instance 方法所建立的物件
	//  localeRoute 為 useLocaleRoute() 所建立的 instance 其可帶入對應 query
  const route = localeRoute({ name: 'user-profile', query: { foo: '1' } })
  if (route) {
    return navigateTo(route.fullPath)
  }
}
</script>

<template>
  <button @click="onClick">Show profile</button>
</template>

二十三、Nuxt 專案部屬

官網: https://nuxt.com/docs/getting-started/deployment

此部分由於筆者並未接觸多數服務因此尚未能完全理解 & 操作,所以這邊預期會先暫分成兩部分敘述,其一是筆者認為未來撰寫時所能參考的文章 url,第二部分是針對官網所提供的內容針對已理解的部分進行整理,然後待之後筆者較有經驗時再將這塊做較完整的描述

官網理解的內容

Nuxt 應用可以被部屬在 Node.js 的環境上、或以預渲染模式 ( pre-rendered ) 進行靜態託管( static hosting )、抑或是部屬到 serverless 服務、又或是部屬到邊緣環境 ( CDN )

Nuxt 3 的應用由於預設使用 Nitro 來作為引擎,所以我們在任何 Node.js 伺服器環境之下,基本上都可以啟動 Nuxt 的建置出來的 Nitro Server。

Node server 的入口位置 ( Entry point )

將 nuxt build 指令作為 Node 環境的預先配置,其運行的結果便會產生出對應的入口文件 ( Entry point ),通常檔案位置在 .output/server/index.mjs ( 或者將本地 build 後的 .output 上傳至正式環境的機器上,並使用 Node.js 做執行)

此時,若下以下指令,就會運行我們 Nuxt 應用的 server production 版本,並以 3000 作為預設端口進行監聽

   node .output/server/index.mjs

若想調整,我們可以藉由以下環境變數進行調整

  • NITRO_PORT or PORT : 監聽的 port號,預設是 3000
  • NITRO_HOST or HOST : 服務的 host 位置,預設是 0.0.0.0 ( 此就是 IP 位置,通常以 IPv4 或 IPv6 進行配置 )
  • NITRO_SSL_CERT and NITRO_SSL_KEY : 通常設定兩者,Nuxt 應用就會以 HTTPS 模式下啟動伺服器,但除了測試會用外基本上不會使用到這兩參數的設定,因為在絕大多數情況下,Nitro 的伺服器是運行在反向代理服務( 例如 NGINX 或 CloudFlare )之後 ( 而通常由反向代理中止 SSL,所以不用再其之後再使用 Nitro 建立的 SSL 通訊也就是 HTTPS )

而在 server 端為了預防我們的應用無預警崩潰或異常,Nuxt 官網建議我們在 server 的 node.js 環境中安裝 PM2 來管理 Node.js 執行中的應用,並確保該應用崩潰時能被重新啟動以確保該應用的正常運作 ( 另外也可 藉由 設定 PM2 來啟用叢集( cluster ) 功能 ),然後為了搭配 Node.js 環境中的 PM2 運作,我們可以在 Nuxt 專案中放入以下檔案

PM2 官網: https://pm2.keymetrics.io/docs/usage/quick-start/

   // ecosystem.config.js

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'
      }
    }
  ]
}

二十四、參考資料來源