跳到主要內容
前端開發

Nuxt 3 專案建立筆記:CLI、目錄結構、路由與 Layout

從 nuxi 建立 Nuxt 3 專案開始的完整筆記,包含 TypeScript 型別檢查與 ESLint 設定、預設目錄結構每個資料夾的用途、檔案系統路由的動態與 catch-all 寫法、Layout 與插槽的用法,以及 Pinia、Sass、Tailwind 的安裝與踩到的 overrides 問題。

W

安裝 Nuxt CLI

nuxi 全名是 Nuxt CLI(Nuxt Command Line Interface),是 Nuxt 官方提供的標準工具。它之於 Nuxt,就像 Vue CLI 之於 Vue——用來建立與管理專案。

環境準備

為了確保 nuxi 不會拿到快取的舊版本,可以先清一次 npx 快取:

npx clear-npx-cache

Node.js 需要 v16.10.0 以上

建立專案

npx nuxi@latest init <project-name>

執行後會問幾個問題,一路照預設走就好:

✔ Which package manager would you like to use?
  › npm / pnpm / yarn / bun

✔ Initialize git repository?
  › Yes / No

裝完的專案長這樣(只有最基本的幾個檔案,其他資料夾要自己開):

nuxt-app/
├── .nuxt/
├── node_modules/
├── public/
├── server/
├── app.vue
├── nuxt.config.ts
├── package.json
├── tsconfig.json
└── README.md

啟動:

npm run dev

TypeScript

Nuxt 3 內建支援 TypeScript,設定放在專案根目錄的 tsconfig.json。我習慣在開發時就開型別檢查,出錯當下就看到,不要等到 build。

安裝 Vue 的型別檢查工具:

npm install -D vue-tsc

nuxt.config.ts 打開 typeCheck

export default defineNuxtConfig({
  typescript: {
    typeCheck: true
  }
})

ESLint

ESLint 是 JavaScript 的 linter,用來檢查程式碼風格(縮排、單雙引號⋯⋯)並找出語法問題。在團隊裡它的價值更明顯:大家寫出來的東西會長得一樣,code review 就不用花時間在排版上吵架。

它可以直接套用大公司的規則配置(Google、Airbnb),也可以自己客製。這裡選 Nuxt 官方提供的那組。

1. 安裝套件

npm install -D eslint @nuxtjs/eslint-config-typescript eslint-plugin-vue

2. 建立設定檔

專案根目錄新增 .eslintrc.js

module.exports = {
  env: {
    browser: true,
    es2023: true
  },
  extends: ['@nuxtjs/eslint-config-typescript'],
  parserOptions: {
    ecmaVersion: 2023,
    sourceType: 'module'
  },
  rules: {
    'no-undef': 'off'
  }
}

extends 是陣列,放要擴展的規則配置。@nuxtjs/eslint-config-typescript 是官方針對 Nuxt + TypeScript 的規則;如果專案沒用 TypeScript,改用 @nuxtjs/eslint-config 即可。

no-undef 要關掉,是因為 Nuxt 有大量自動匯入的 API(useRoutedefineNuxtConfig⋯⋯),ESLint 看不到它們的宣告,開著會滿江紅。

預設目錄結構

Nuxt 3 是「約定優於設定」,資料夾名稱本身就是設定,所以要先知道每個資料夾在幹嘛。

nuxt-app/
├── .nuxt/
├── .output/
├── assets/
├── components/
├── composables/
├── content/
├── layouts/
├── middleware/
├── node_modules/
├── pages/
├── plugins/
├── public/
└── server/
    ├── api/
    ├── routes/
    └── middleware/
├── .gitignore
├── .nuxtignore
├── app.config.ts
├── app.vue
├── nuxt.config.ts
├── package.json
└── tsconfig.json
目錄/檔案用途
.nuxt/開發環境下自動產生的 Vue 網站,不要手動改
.output/build 的產出,每次建置都會重新產生,不要手動改
assets/需要被 Vite 處理的靜態資源(CSS/SASS、字型、圖片)
components/Vue 元件,Nuxt 會自動載入,不用 import
composables/組合式函數,同樣自動載入
content/給 Nuxt Content 用,放 .md.yml.csv.json,做檔案式 CMS
layouts/版面模板,提供跨頁面重複使用的外框
middleware/路由中間件,導航到下一頁之前執行(例如權限驗證)
pages/頁面,檔案結構直接決定路由
plugins/外掛,會自動載入
public/直接放在網站根目錄的檔案(robots.txtfavicon.ico
server/後端邏輯(API、routes、middleware),不自動載入但支援 HMR

幾個檔案:

  • .nuxtignore:建置時要忽略的檔案
  • app.config.ts:執行期會暴露給客戶端的設定,不要放任何機密
  • app.vue:Nuxt 3 網站的入口元件
  • nuxt.config.ts:專案設定檔
  • tsconfig.json:Nuxt 3 會在 .nuxt/ 底下自動產一份含路徑別名的設定,根目錄這份用來擴展或覆蓋它

assets/public/ 最容易搞混:assets/ 裡的東西要在 JS/CSS 裡 import 才會被處理public/ 則是原封不動搬到根目錄,用絕對路徑 /xxx.png 引用。

目錄名稱也可以改,在 nuxt.config.tsdir 參數調整,但不是每個都能改,能改的只有官方列出的那幾個。

檔案系統路由

Nuxt 的核心特性是「檔案系統路由器(file system router)」——pages/ 底下每一個 Vue 檔案都會產生一個對應的 URL。底層還是 vue-router,只是路由表由檔名自動推出來,不用自己維護。

建立第一個頁面

新增 pages/index.vue,然後把 app.vue 改成:

<template>
  <div>
    <NuxtPage />
  </div>
</template>

<NuxtPage /> 就是路由頁面被塞進來的位置。一旦建立了 pages/ 目錄,就一定要有這個元件,否則什麼都不會顯示。

查看自動產生的路由

想看 Nuxt 實際產出的路由表長什麼樣,可以 build 之後翻產物:

npm run build

打開 .output/server/chunks/app/server.mjs,搜尋 const _routes =,或直接搜尋你剛建立的檔名(例如 about),就會看到類似這樣的結構:

const _routes = [
  {
    name: "about",
    path: "/about",
    component: () => import('./_nuxt/about-xxxxxxxx.mjs')
  },
  ...
]

路由連結

<NuxtLink> 而不是 <a>,才會走前端導航不重新載入整頁:

<NuxtLink to="/about">前往 About</NuxtLink>
<NuxtLink to="/contact">前往 Contact</NuxtLink>

動態路由

檔名用中括號包起來就是動態參數:

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

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

匹配所有層級

三個點加參數名,可以吃掉後面所有層級:

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

輸入 /catch-all/hello/catch-all/hello/world 都會進到這頁,差別在 slug 是一個陣列,每個元素對應一個層級。

404 頁面

把 catch-all 放在 pages/ 根目錄,所有沒被匹配到的路由都會交給它:

<template>
  <div class="bg-white py-24">
    <div class="flex flex-col items-center">
      <h1 class="text-8xl font-semibold text-red-500">404</h1>
      <p class="my-8 text-3xl text-gray-800">Not Found</p>
    </div>
  </div>
</template>

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

setResponseStatus(404) 不能省。少了它畫面上雖然寫著 404,HTTP 狀態碼卻是 200,搜尋引擎會把這些不存在的頁面通通收錄進去。

多層目錄與巢狀路由

目錄結構直接對應路徑層級:

./pages/
└── posts/
    ├── [postId]/
    │   ├── comments/
    │   │   └── [commentId].vue
    │   └── index.vue
    ├── index.vue
    └── top-[number].vue

巢狀路由則是同名的資料夾與 .vue 檔並存

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

docs.vue 是外層容器(裡面要放 <NuxtPage />),docs/ 底下的檔案會被渲染進去。

靠目錄結構與中括號就能涵蓋大部分情境,實務上很少需要手動定義路由規則。

Layout

版面模板放在 layouts/,同樣會自動載入。

預設模板

新增 layouts/default.vue

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

模板裡一定要有一個未命名的 <slot />,那是採用這個模板的頁面內容會出現的位置。

在 app.vue 掛上 NuxtLayout

<template>
  <div>
    <p class="pb-4 text-2xl text-slate-600">這裡是最外層 app.vue</p>
    <NuxtLayout name="default">
      <template #header>
        <p class="px-6 pt-4 text-xl text-green-500">這段會放置在 header 插槽</p>
      </template>
      <template #default>
        <NuxtPage />
      </template>
      <template #footer>
        <p class="px-6 pt-4 text-xl text-blue-500">這段會放置在 footer 插槽</p>
      </template>
    </NuxtLayout>
  </div>
</template>

name 預設就是 default,這裡明寫出來只是避免誤會。

指定其他模板

新增 layouts/custom.vue

<template>
  <div class="bg-rose-100 py-2">
    <p class="px-6 py-4 text-2xl text-gray-700">
      使用 <span class="font-bold text-rose-500">Custom</span> 布局
    </p>
    <slot />
  </div>
</template>

在要套用的頁面用 definePageMeta

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

模板名稱一律是 kebab-case。檔名寫 customLayout.vue 的話,要傳給 layout 的值是 custom-layout,不是 customLayout

元件

components/ 底下的元件會被自動載入,不需要 import。命名規則是用路徑推出元件名稱

./components/
├── AppHeader.vue          → <AppHeader />
└── base/
    └── Button.vue         → <BaseButton />

子資料夾名稱會變成元件名稱的前綴,所以 base/Button.vue<BaseButton /> 而不是 <Button />。想關掉這個行為,在 nuxt.config.tscomponents: { pathPrefix: false }

檔名加上 .client.server 後綴可以決定它只在哪一端渲染,例如 Chart.client.vue 只在瀏覽器渲染——用到 window 的元件很需要這個。

Pinia

Pinia 是取代 Vuex 的全域狀態管理函式庫,比較輕量,適合中小型專案。

npm install pinia @pinia/nuxt --save-dev

nuxt.config.ts

export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt'
  ]
})

踩坑:Pinia 或相依套件裝不起來

裝的時候如果一直卡在相依衝突,在 package.json 加上:

{
  "overrides": {
    "vue": "latest"
  }
}

原因是 Pinia 與 Nuxt 各自宣告的 vue 版本範圍對不上,npm 解不出一組共同的答案。overrides 直接指定用哪一版,繞過解析。

Sass

npm install node-sass sass-loader sass -D
export default defineNuxtConfig({
  css: ['~/assets/scss/app.scss']
})

Tailwind CSS

npm install -D @nuxtjs/tailwindcss

nuxt.config.ts

export default defineNuxtConfig({
  modules: ['@nuxtjs/tailwindcss']
})

tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './components/**/*.{vue,js,ts}',
    './layouts/**/*.vue',
    './pages/**/*.vue',
    './composables/**/*.{js,ts}',
    './plugins/**/*.{js,ts}',
    './app.{js,ts,vue}'
  ],
  theme: {
    extend: {}
  },
  plugins: []
}

content 這個陣列很關鍵——Tailwind 只會產出這些檔案裡真的出現過的 class。漏掉某個資料夾,那裡的樣式在正式環境就會整組消失(開發環境常常看不出來)。

搭配 Prettier 自動排序

class 一多就很難一眼確認有沒有重複,官方的 Prettier 外掛可以自動照推薦順序排列:

npm install -D prettier-plugin-tailwindcss

附錄:Nuxt 2 的 create-nuxt-app

Nuxt 2 用的是另一支 CLI,會問一長串問題:

npx create-nuxt-app my-nuxt-project

逐題的選項大致如下:

? Project name: my-nuxt-project
? Programming language: (Use arrow keys)
❯ JavaScript
  TypeScript

? Package manager:
❯ Yarn
  Npm

? UI framework:
❯ None
  Ant Design Vue
  BalmUI
  Bootstrap Vue
  Buefy
  Chakra UI
  Element
  Framevuerk
  Oruga
  Tachyons
  Tailwind CSS
  Windi CSS
  Vant
  View UI
  Vuetify.js

? Template engine:
❯ HTML
  Pug

? Nuxt.js modules: (Press <space> to select)
❯ ◯ Axios - Promise based HTTP client
  ◯ Progressive Web App (PWA)
  ◯ Content - Git-based headless CMS

? Linting tools: (Press <space> to select)
❯ ◯ ESLint
  ◯ Prettier
  ◯ Lint staged files
  ◯ StyleLint
  ◯ Commitlint

? Testing framework:
❯ None
  Jest
  AVA
  WebdriverIO
  Nightwatch

? Rendering mode:
❯ Universal (SSR / SSG)
  Single Page App

? Deployment target:
❯ Server (Node.js hosting)
  Static (Static/Jamstack hosting)

? Development tools: (Press <space> to select)
❯ ◯ jsconfig.json
  ◯ Semantic Pull Requests
  ◯ Dependabot

ESLint 與 Prettier 兩個都會出現在 linting 那題,它們的分工是:

  • ESLint:靜態分析工具,找出程式碼裡的問題,也管風格
  • Prettier:純格式化工具,只管排版,不管品質

通常兩個一起用——ESLint 負責分析,Prettier 負責排版。

Nuxt 2 裝 Pinia

npm install pinia

Nuxt 2 裝 Sass

npm install node-sass sass-loader --save-dev

nuxt.config.jsbuild 加上:

build: {
  loaders: {
    scss: { implementation: require('sass') }
  }
}

TypeScript 版本:

import { Configuration } from '@nuxt/types'

const config: Configuration = {
  build: {
    loaders: {
      scss: { implementation: require('sass') }
    }
  }
}

export default config

後記

這篇整理完沒多久,Nuxt 4 就成了預設版本,上面有好幾段已經不能照抄了——最明顯的是整個 srcDir 搬進 app/,還有 .eslintrc.js 這種 legacy config、@nuxtjs/tailwindcsstailwind.config.js 都換掉了。老實說 ESLint 那段在寫的時候就已經該換成 @nuxt/eslint 了,這裡留著是當時的實況。

新的做法另外寫在 Nuxt 4 現在怎麼開專案

參考資料

分享這篇