nuxt
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseThis skill defines opinionated Nuxt 4 architecture: a BFF server layer, DDD-inspired contexts, a strict component hierarchy, and clean code principles applied to the Nuxt ecosystem.
Nuxt 4 provides a full-stack framework with file-based routing, server API routes, auto-imports, and SSR/SSG capabilities. The patterns here ensure a maintainable, scalable application by combining Nuxt's conventions with solid software design principles (see skill).
guidelines本规范定义了约定式的 Nuxt 4 架构:包含 BFF 服务层、领域驱动设计(DDD)启发的上下文、严格的组件层级,以及应用于 Nuxt 生态的整洁代码原则。
Nuxt 4 是一个全栈框架,提供基于文件的路由、服务端 API 路由、自动导入以及 SSR/SSG 能力。本文档中的模式结合 Nuxt 约定与可靠的软件设计原则(参考 规范),确保应用可维护、可扩展。
guidelinesProject Structure
项目结构
.
├── app/
│ ├── app.vue # Root component — calls useApp init
│ ├── error.vue # Global error page
│ ├── pages/ # File-based routing
│ │ ├── index.vue
│ │ └── users/
│ │ └── [id].vue
│ ├── layouts/ # Layout components
│ │ ├── default.vue
│ │ └── dashboard.vue
│ ├── middleware/ # Route middleware
│ │ ├── auth.ts # Global or named auth guard
│ │ └── role.ts
│ ├── plugins/ # App-level plugins
│ │ ├── analytics.client.ts # Client-only plugin
│ │ └── sentry.ts
│ ├── components/
│ │ ├── app/ # Shared application components
│ │ │ ├── AppHeader.vue
│ │ │ ├── AppFooter.vue
│ │ │ └── AppLoadingState.vue
│ │ ├── ui/ # Independent, reusable UI components (no domain logic)
│ │ │ ├── BaseCard.vue
│ │ │ └── BaseEmptyState.vue
│ │ ├── home/ # Home page components
│ │ │ ├── HomeHero.vue
│ │ │ ├── HomeLatestArticles.vue
│ │ │ └── HomeFeaturedProducts.vue
│ │ └── user/ # User module components (matches the user domain)
│ │ ├── list/ # User list view
│ │ │ ├── UserList.vue # Orchestrates user list (no "Container" suffix)
│ │ │ ├── UserListItem.vue
│ │ │ └── UserListFilters.vue
│ │ ├── detail/ # User detail view
│ │ │ ├── UserDetail.vue # Orchestrates user detail
│ │ │ ├── UserProfile.vue
│ │ │ └── UserActivityFeed.vue
│ │ └── add/ # User add/edit views
│ │ ├── UserAdd.vue # Orchestrates user creation
│ │ └── UserAddForm.vue
│ ├── composables/ # Auto-imported composables (filename without "use" prefix)
│ │ ├── app.ts # exports useApp()
│ │ ├── pages.ts # exports usePages()
│ │ └── user.ts # exports useUser()
│ └── stores/ # Pinia stores (auto-imported via nuxt.config)
│ ├── app.store.ts # exports useAppStore
│ └── user.store.ts # exports useUserStore
│
├── server/
│ ├── api/
│ │ ├── app/
│ │ │ └── index.get.ts # App initialization endpoint
│ │ ├── pages/
│ │ │ ├── index.get.ts # Home page data endpoint — returns ALL home data
│ │ │ └── users/
│ │ │ └── [id].get.ts # User page data endpoint — returns ALL user data
│ │ └── user/
│ │ ├── create.post.ts
│ │ └── [id].delete.ts
│ ├── utils/ # Auto-imported server utilities — flat, one file per domain
│ │ ├── user.ts # createUser, findUser, deleteUser — ALL in one file
│ │ ├── product.ts
│ │ └── app.ts
│ └── contexts/ # NOT auto-imported — explicit imports only
│ ├── shared/
│ │ ├── services/
│ │ │ └── PostgresService.ts
│ │ └── errors/
│ │ └── ServerError.ts
│ └── user/
│ ├── domain/
│ │ ├── User.ts
│ │ ├── UserRepository.ts
│ │ └── UserError.ts
│ ├── application/
│ │ ├── UserCreator.ts
│ │ └── UserFinder.ts
│ └── infrastructure/
│ └── PostgresUserRepository.ts
│
└── shared/
├── types/ # Flat — no subfolders — auto-importable
│ ├── App.ts # App initialization types (interface App)
│ ├── Page.ts # All page types (special — not split by module)
│ └── User.ts # Domain types + AuthUser
└── utils/ # Flat — no subfolders — auto-importable
├── user.ts # Zod schemas and utilities for user module
└── product.tsKey rules:
- contains all Vue/client code (Nuxt 4 default)
app/ - is never auto-imported — always use explicit
server/contexts/statementsimport - is flat: one file per domain, all functions for that domain in the same file
server/utils/ - and
shared/types/are flat (no subfolders) and are auto-importableshared/utils/ - Components are organized by module context (matching the domain/web module), not by technical type
- Container-style components have no suffix —
Container, notUserDetailUserDetailContainer - Composable filenames have no prefix (
use), but the exported function still does (app.ts)useApp - Store filenames use suffix (
.store.ts), exported asuser.store.tsuseUserStore
.
├── app/
│ ├── app.vue # 根组件 — 调用 useApp 初始化
│ ├── error.vue # 全局错误页面
│ ├── pages/ # 基于文件的路由
│ │ ├── index.vue
│ │ └── users/
│ │ └── [id].vue
│ ├── layouts/ # 布局组件
│ │ ├── default.vue
│ │ └── dashboard.vue
│ ├── middleware/ # 路由中间件
│ │ ├── auth.ts # 全局或命名鉴权守卫
│ │ └── role.ts
│ ├── plugins/ # 应用级插件
│ │ ├── analytics.client.ts # 仅客户端插件
│ │ └── sentry.ts
│ ├── components/
│ │ ├── app/ # 共享应用组件
│ │ │ ├── AppHeader.vue
│ │ │ ├── AppFooter.vue
│ │ │ └── AppLoadingState.vue
│ │ ├── ui/ # 独立可复用 UI 组件(无领域逻辑)
│ │ │ ├── BaseCard.vue
│ │ │ └── BaseEmptyState.vue
│ │ ├── home/ # 首页组件
│ │ │ ├── HomeHero.vue
│ │ │ ├── HomeLatestArticles.vue
│ │ │ └── HomeFeaturedProducts.vue
│ │ └── user/ # 用户模块组件(匹配用户领域)
│ │ ├── list/ # 用户列表视图
│ │ │ ├── UserList.vue # 编排用户列表(无 "Container" 后缀)
│ │ │ ├── UserListItem.vue
│ │ │ └── UserListFilters.vue
│ │ ├── detail/ # 用户详情视图
│ │ │ ├── UserDetail.vue # 编排用户详情
│ │ │ ├── UserProfile.vue
│ │ │ └── UserActivityFeed.vue
│ │ └── add/ # 用户新增/编辑视图
│ │ ├── UserAdd.vue # 编排用户创建流程
│ │ └── UserAddForm.vue
│ ├── composables/ # 自动导入的组合式函数(文件名无 "use" 前缀)
│ │ ├── app.ts # 导出 useApp()
│ │ ├── pages.ts # 导出 usePages()
│ │ └── user.ts # 导出 useUser()
│ └── stores/ # Pinia 状态存储(通过 nuxt.config 自动导入)
│ ├── app.store.ts # 导出 useAppStore
│ └── user.store.ts # 导出 useUserStore
│
├── server/
│ ├── api/
│ │ ├── app/
│ │ │ └── index.get.ts # 应用初始化端点
│ │ ├── pages/
│ │ │ ├── index.get.ts # 首页数据端点 — 返回所有首页所需数据
│ │ │ └── users/
│ │ │ └── [id].get.ts # 用户页面数据端点 — 返回所有用户页面所需数据
│ │ └── user/
│ │ ├── create.post.ts
│ │ └── [id].delete.ts
│ ├── utils/ # 自动导入的服务端工具 — 扁平化,每个领域一个文件
│ │ ├── user.ts # createUser, findUser, deleteUser — 全部在一个文件中
│ │ ├── product.ts
│ │ └── app.ts
│ └── contexts/ # 不自动导入 — 仅显式导入
│ ├── shared/
│ │ ├── services/
│ │ │ └── PostgresService.ts
│ │ └── errors/
│ │ └── ServerError.ts
│ └── user/
│ ├── domain/
│ │ ├── User.ts
│ │ ├── UserRepository.ts
│ │ └── UserError.ts
│ ├── application/
│ │ ├── UserCreator.ts
│ │ └── UserFinder.ts
│ └── infrastructure/
│ └── PostgresUserRepository.ts
│
└── shared/
├── types/ # 扁平化 — 无子文件夹 — 可自动导入
│ ├── App.ts # 应用初始化类型(interface App)
│ ├── Page.ts # 所有页面类型(特殊 — 不按模块拆分)
│ └── User.ts # 领域类型 + 鉴权用户类型
└── utils/ # 扁平化 — 无子文件夹 — 可自动导入
├── user.ts # 用户模块的 Zod 校验规则与工具函数
└── product.ts核心规则:
- 包含所有 Vue/客户端代码(Nuxt 4 默认约定)
app/ - 永远不自动导入 — 始终使用显式
server/contexts/语句import - 是扁平化的:每个领域一个文件,该领域的所有函数都在同一个文件中
server/utils/ - 和
shared/types/是扁平化的(无嵌套文件夹)且可自动导入shared/utils/ - 组件按模块上下文组织(匹配领域/网页模块),而非技术类型
- 容器型组件无 后缀 — 例如
Container,而非UserDetailUserDetailContainer - 组合式函数文件名无 前缀(如
use),但导出的函数仍保留app.ts前缀(如use)useApp - 状态存储文件名使用 后缀(如
.store.ts),导出为user.store.tsuseUserStore
Nuxt Config
Nuxt 配置
ts
// nuxt.config.ts
export default defineNuxtConfig({
// Define components path without prefix
components: [
{ path: '@/components', pathPrefix: false },
],
// Define Devtools
devtools: {
enabled: import.meta.env.DEVTOOLS_ENABLED || false,
timeline: {
enabled: true,
},
},
// Define CSS
css: [
'@/assets/css/main.css',
],
// Runtime config
runtimeConfig: {
privateConfig: '',
public: {
publicConfig: ''
}
}
compatibilityDate: '2025-07-15',
// Auto-import stores from app/stores/
pinia: {
storesDirs: ['./stores/**.store.ts'],
},
modules: [
'@pinia/nuxt',
'@nuxt/ui', // if using Nuxt UI
'@nuxtjs/i18n', // if using i18n
],
})ts
// nuxt.config.ts
export default defineNuxtConfig({
// 定义组件路径,无需前缀
components: [
{ path: '@/components', pathPrefix: false },
],
// 定义开发工具
devtools: {
enabled: import.meta.env.DEVTOOLS_ENABLED || false,
timeline: {
enabled: true,
},
},
// 定义 CSS
css: [
'@/assets/css/main.css',
],
// 运行时配置
runtimeConfig: {
privateConfig: '',
public: {
publicConfig: ''
}
}
compatibilityDate: '2025-07-15',
// 自动导入 app/stores/ 中的状态存储
pinia: {
storesDirs: ['./stores/**.store.ts'],
},
modules: [
'@pinia/nuxt',
'@nuxt/ui', // 如果使用 Nuxt UI
'@nuxtjs/i18n', // 如果使用国际化
],
})Auto-Imports
自动导入
Nuxt auto-imports everything in , (via config), , and . Leverage this everywhere except .
app/composables/app/stores/app/components/server/utils/server/contexts/✅ Use auto-imports:
ts
// app/pages/index.vue — no imports needed
const pages = usePages()
const { data } = await useAsyncData('home', () => pages.getHomePage())
const userStore = useUserStore()❌ Never use auto-imports in :
server/contexts/ts
// server/contexts/user/application/UserCreator.ts
import type { UserRepository } from '../domain/UserRepository' // explicit import
import type { CreateUserDto } from '../../../../shared/types/User' // explicit import
export class UserCreator {
constructor(private readonly repository: UserRepository) {}
async create(dto: CreateUserDto): Promise<void> {
// ...
}
}Nuxt 会自动导入 、(通过配置)、 以及 中的所有内容。除了 之外,其他地方都可以充分利用这一特性。
app/composables/app/stores/app/components/server/utils/server/contexts/✅ 推荐使用自动导入:
ts
// app/pages/index.vue — 无需手动导入
const pages = usePages()
const { data } = await useAsyncData('home', () => pages.getHomePage())
const userStore = useUserStore()❌ 禁止在 中使用自动导入:
server/contexts/ts
// server/contexts/user/application/UserCreator.ts
import type { UserRepository } from '../domain/UserRepository' // 显式导入
import type { CreateUserDto } from '../../../../shared/types/User' // 显式导入
export class UserCreator {
constructor(private readonly repository: UserRepository) {}
async create(dto: CreateUserDto): Promise<void> {
// ...
}
}Backend for Frontend (BFF) Pattern
前端后端(BFF)模式
The server layer acts exclusively as a BFF — it aggregates, transforms, and exposes data tailored for the Vue frontend. No business logic lives in the Vue layer.
There are three types of endpoints, each with a clear purpose:
服务层仅作为 BFF 存在 — 它聚合、转换并暴露专为 Vue 前端定制的数据。所有业务逻辑都不放在 Vue 层中。
服务端有三类端点,各有明确用途:
1. App Endpoint
1. 应用初始化端点
Path:
server/api/app/index.get.tsReturns all data needed to bootstrap the application (user session, config, feature flags, translations metadata, etc.). Called once on app mount.
ts
// server/api/app/index.get.ts
import type { App } from '~~/shared/types/App'
export default defineEventHandler(async (event): Promise<App> => {
const [config, user] = await Promise.all([
getAppConfig(event),
getAuthUser(event),
])
return { config, user }
})Composable:
app/composables/app.tsts
// app/composables/app.ts
export function useApp() {
const appStore = useAppStore()
const userStore = useUserStore()
async function getAppData(): Promise<App> {
return $fetch('/api/app')
}
async function init(): Promise<void> {
const data = await getAppData()
appStore.setConfig(data.config)
userStore.setCurrentUser(data.user)
}
return { init }
}Usage in :
app/app.vuevue
<!-- app/app.vue -->
<script setup lang="ts">
const { init } = useApp()
// callOnce ensures this runs once on SSR and not again on client hydration
await callOnce(init)
// If using nuxt-i18n, re-init on locale change:
// const { locale } = useI18n()
// watch(locale, init)
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>Types:
shared/types/App.tsts
// shared/types/App.ts
export interface App {
config: AppConfig
user: User | null
}
export interface AppConfig {
featureFlags: Record<string, boolean>
locale: string
}is not a separate type — it is theAuthUserinterface defined inUser. If the authenticated user shape differs from the domain user, extend fromshared/types/User.tsinUser.User.ts
路径:
server/api/app/index.get.ts返回应用启动所需的所有数据(用户会话、配置、功能开关、国际化元数据等)。仅在应用挂载时调用一次。
ts
// server/api/app/index.get.ts
import type { App } from '~~/shared/types/App'
export default defineEventHandler(async (event): Promise<App> => {
const [config, user] = await Promise.all([
getAppConfig(event),
getAuthUser(event),
])
return { config, user }
})组合式函数:
app/composables/app.tsts
// app/composables/app.ts
export function useApp() {
const appStore = useAppStore()
const userStore = useUserStore()
async function getAppData(): Promise<App> {
return $fetch('/api/app')
}
async function init(): Promise<void> {
const data = await getAppData()
appStore.setConfig(data.config)
userStore.setCurrentUser(data.user)
}
return { init }
}在 中使用:
app/app.vuevue
<!-- app/app.vue -->
<script setup lang="ts">
const { init } = useApp()
// callOnce 确保该逻辑仅在 SSR 时运行一次,客户端 hydration 时不再重复执行
await callOnce(init)
// 如果使用 nuxt-i18n,在语言切换时重新初始化:
// const { locale } = useI18n()
// watch(locale, init)
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>类型定义:
shared/types/App.tsts
// shared/types/App.ts
export interface App {
config: AppConfig
user: User | null
}
export interface AppConfig {
featureFlags: Record<string, boolean>
locale: string
}不是独立类型 — 它是AuthUser中定义的shared/types/User.ts接口。如果鉴权用户的结构与领域用户不同,可在User中基于User.ts接口扩展。User
2. Page Endpoints
2. 页面数据端点
Each Nuxt page calls exactly one server endpoint that returns all data the page needs in a single request. This avoids waterfalls and keeps pages simple.
Convention: mirrors . Each endpoint fetches everything the page needs and returns it in one response.
server/api/pages/{route}.get.tsapp/pages/{route}.vuets
// server/api/pages/index.get.ts → app/pages/index.vue
import type { HomePageData } from '~~/shared/types/Page'
export default defineEventHandler(async (event): Promise<HomePageData> => {
const [banner, products] = await Promise.all([
getHeroBanner(event),
getFeaturedProducts(event),
])
return { banner, products }
})ts
// server/api/pages/users/[id].get.ts → app/pages/users/[id].vue
import type { UserPageData } from '~~/shared/types/Page'
export default defineEventHandler(async (event): Promise<UserPageData> => {
const id = getRouterParam(event, 'id')!
const [user, activity] = await Promise.all([
findUser(event, id),
getUserActivity(event, id),
])
return { user, activity }
})Note:andfindUserare auto-imported fromgetUserActivity.server/utils/user.ts
Composable: — all page fetchers in one place.
app/composables/pages.tsts
// app/composables/pages.ts
export function usePages() {
async function getHomePage(): Promise<HomePageData> {
return $fetch('/api/pages')
}
async function getUserPage(id: string): Promise<UserPageData> {
return $fetch(`/api/pages/users/${id}`)
}
return { getHomePage, getUserPage }
}Types: — all page types together (not split by module, pages are a cross-cutting concern).
shared/types/Page.tsts
// shared/types/Page.ts
export interface HomePageData {
banner: Banner
products: Product[]
}
export interface UserPageData {
user: User
activity: UserActivity[]
}
export interface Banner {
title: string
imageUrl: string
ctaLabel: string
ctaUrl: string
}每个 Nuxt 页面仅调用一个服务端端点,该端点返回页面所需的全部数据。这样可以避免请求瀑布流,保持页面逻辑简洁。
约定: 与 一一对应。每个端点获取页面所需的所有数据,并在一次响应中返回。
server/api/pages/{route}.get.tsapp/pages/{route}.vuets
// server/api/pages/index.get.ts → app/pages/index.vue
import type { HomePageData } from '~~/shared/types/Page'
export default defineEventHandler(async (event): Promise<HomePageData> => {
const [banner, products] = await Promise.all([
getHeroBanner(event),
getFeaturedProducts(event),
])
return { banner, products }
})ts
// server/api/pages/users/[id].get.ts → app/pages/users/[id].vue
import type { UserPageData } from '~~/shared/types/Page'
export default defineEventHandler(async (event): Promise<UserPageData> => {
const id = getRouterParam(event, 'id')!
const [user, activity] = await Promise.all([
findUser(event, id),
getUserActivity(event, id),
])
return { user, activity }
})注意:和findUser是从getUserActivity自动导入的。server/utils/user.ts
组合式函数: — 所有页面数据获取逻辑集中管理。
app/composables/pages.tsts
// app/composables/pages.ts
export function usePages() {
async function getHomePage(): Promise<HomePageData> {
return $fetch('/api/pages')
}
async function getUserPage(id: string): Promise<UserPageData> {
return $fetch(`/api/pages/users/${id}`)
}
return { getHomePage, getUserPage }
}类型定义: — 所有页面类型集中管理(不按模块拆分,页面是跨领域关注点)。
shared/types/Page.tsts
// shared/types/Page.ts
export interface HomePageData {
banner: Banner
products: Product[]
}
export interface UserPageData {
user: User
activity: UserActivity[]
}
export interface Banner {
title: string
imageUrl: string
ctaLabel: string
ctaUrl: string
}3. Use-Case Endpoints
3. 业务用例端点
Business operation endpoints (mutations and domain queries). Organized by module.
ts
// server/api/user/create.post.ts
import type { User } from '~~/shared/types/User'
export default defineEventHandler(async (event): Promise<User> => {
const body = await readValidatedBody(event, createUserSchema.parse)
return createUser(event, body)
})ts
// server/api/user/[id].delete.ts
export default defineEventHandler(async (event): Promise<void> => {
const id = getRouterParam(event, 'id')!
await deleteUser(event, id)
})Composable:
app/composables/user.tsts
// app/composables/user.ts
export function useUser() {
const userStore = useUserStore()
async function createUser(dto: CreateUserDto): Promise<User> {
const user = await $fetch('/api/user/create', {
method: 'POST',
body: dto,
})
userStore.addUser(user)
return user
}
async function deleteUser(id: string): Promise<void> {
await $fetch(`/api/user/${id}`, { method: 'DELETE' })
userStore.removeUser(id)
}
return { createUser, deleteUser }
}业务操作端点(数据变更与领域查询)。按模块组织。
ts
// server/api/user/create.post.ts
import type { User } from '~~/shared/types/User'
export default defineEventHandler(async (event): Promise<User> => {
const body = await readValidatedBody(event, createUserSchema.parse)
return createUser(event, body)
})ts
// server/api/user/[id].delete.ts
export default defineEventHandler(async (event): Promise<void> => {
const id = getRouterParam(event, 'id')!
await deleteUser(event, id)
})组合式函数:
app/composables/user.tsts
// app/composables/user.ts
export function useUser() {
const userStore = useUserStore()
async function createUser(dto: CreateUserDto): Promise<User> {
const user = await $fetch('/api/user/create', {
method: 'POST',
body: dto,
})
userStore.addUser(user)
return user
}
async function deleteUser(id: string): Promise<void> {
await $fetch(`/api/user/${id}`, { method: 'DELETE' })
userStore.removeUser(id)
}
return { createUser, deleteUser }
}Server Layer: Contexts & Utils
服务层:上下文与工具函数
server/contexts/
— Domain logic with explicit imports
server/contexts/server/contexts/
— 显式导入的领域逻辑
server/contexts/Follows DDD (Domain-Driven Design) patterns. Never rely on Nuxt auto-imports here. All imports are explicit to keep the domain layer framework-agnostic and testable.
server/contexts/
└── user/
├── domain/
│ ├── User.ts # Domain entity
│ ├── UserRepository.ts # Repository interface
│ └── UserError.ts # Domain errors
├── application/
│ ├── UserCreator.ts # Use case
│ └── UserFinder.ts # Use case
└── infrastructure/
└── PostgresUserRepository.ts # Concrete implementationDomain entity:
ts
// server/contexts/user/domain/User.ts
export interface User {
id: string
name: string
email: string
createdAt: Date
}Repository interface (DIP):
ts
// server/contexts/user/domain/UserRepository.ts
import type { User } from './User'
import type { CreateUserDto } from '../../../../shared/types/User'
export interface UserRepository {
findById(id: string): Promise<User | null>
create(dto: CreateUserDto): Promise<User>
delete(id: string): Promise<void>
}Use case with dependency injection:
ts
// server/contexts/user/application/UserCreator.ts
import type { UserRepository } from '../domain/UserRepository'
import type { CreateUserDto } from '../../../../shared/types/User'
import type { User } from '../domain/User'
import { UserError } from '../domain/UserError'
export class UserCreator {
constructor(private readonly repository: UserRepository) {}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.repository.findByEmail(dto.email)
if (existing) throw new UserError('EMAIL_ALREADY_EXISTS', dto.email)
return this.repository.create(dto)
}
}Concrete implementation:
ts
// server/contexts/user/infrastructure/PostgresUserRepository.ts
import type { UserRepository } from '../domain/UserRepository'
import type { User } from '../domain/User'
import type { CreateUserDto } from '../../../../shared/types/User'
import { PostgresService } from '../../shared/services/PostgresService'
export class PostgresUserRepository implements UserRepository {
constructor(private readonly db: PostgresService) {}
async findById(id: string): Promise<User | null> {
return this.db.query('SELECT * FROM users WHERE id = $1', [id])
}
async create(dto: CreateUserDto): Promise<User> {
return this.db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[dto.name, dto.email],
)
}
async delete(id: string): Promise<void> {
await this.db.query('DELETE FROM users WHERE id = $1', [id])
}
}遵循领域驱动设计(DDD)模式。永远不要依赖 Nuxt 的自动导入。所有导入都是显式的,以保持领域层与框架无关,便于测试。
server/contexts/
└── user/
├── domain/
│ ├── User.ts # 领域实体
│ ├── UserRepository.ts # 仓储接口
│ └── UserError.ts # 领域错误
├── application/
│ ├── UserCreator.ts # 业务用例
│ └── UserFinder.ts # 业务用例
└── infrastructure/
└── PostgresUserRepository.ts # 具体实现领域实体:
ts
// server/contexts/user/domain/User.ts
export interface User {
id: string
name: string
email: string
createdAt: Date
}仓储接口(依赖倒置原则):
ts
// server/contexts/user/domain/UserRepository.ts
import type { User } from './User'
import type { CreateUserDto } from '../../../../shared/types/User'
export interface UserRepository {
findById(id: string): Promise<User | null>
create(dto: CreateUserDto): Promise<User>
delete(id: string): Promise<void>
}带依赖注入的业务用例:
ts
// server/contexts/user/application/UserCreator.ts
import type { UserRepository } from '../domain/UserRepository'
import type { CreateUserDto } from '../../../../shared/types/User'
import type { User } from '../domain/User'
import { UserError } from '../domain/UserError'
export class UserCreator {
constructor(private readonly repository: UserRepository) {}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.repository.findByEmail(dto.email)
if (existing) throw new UserError('EMAIL_ALREADY_EXISTS', dto.email)
return this.repository.create(dto)
}
}具体实现:
ts
// server/contexts/user/infrastructure/PostgresUserRepository.ts
import type { UserRepository } from '../domain/UserRepository'
import type { User } from '../domain/User'
import type { CreateUserDto } from '../../../../shared/types/User'
import { PostgresService } from '../../shared/services/PostgresService'
export class PostgresUserRepository implements UserRepository {
constructor(private readonly db: PostgresService) {}
async findById(id: string): Promise<User | null> {
return this.db.query('SELECT * FROM users WHERE id = $1', [id])
}
async create(dto: CreateUserDto): Promise<User> {
return this.db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[dto.name, dto.email],
)
}
async delete(id: string): Promise<void> {
await this.db.query('DELETE FROM users WHERE id = $1', [id])
}
}server/utils/
— Use-case orchestrators (auto-imported, flat)
server/utils/server/utils/
— 业务用例编排器(自动导入,扁平化)
server/utils/These are the bridge between API route handlers and the context layer. They wire up dependencies (DI) and expose simple functions to the endpoint handlers.
Key rule: All functions for a given domain live in one file — no subfolders. contains , , , etc.
server/utils/user.tscreateUserfindUserdeleteUserts
// server/utils/user.ts — ALL user utilities in one file
import { UserCreator } from '~~/server/contexts/user/application/UserCreator'
import { UserFinder } from '~~/server/contexts/user/application/UserFinder'
import { PostgresUserRepository } from '~~/server/contexts/user/infrastructure/PostgresUserRepository'
import { PostgresService } from '~~/server/contexts/shared/services/PostgresService'
import type { CreateUserDto } from '~~/shared/types/User'
import type { User } from '~~/shared/types/User'
import type { H3Event } from 'h3'
export async function createUser(event: H3Event, dto: CreateUserDto): Promise<User> {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
const creator = new UserCreator(repository)
return creator.create(dto)
}
export async function findUser(event: H3Event, id: string): Promise<User> {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
const finder = new UserFinder(repository)
return finder.find(id)
}
export async function deleteUser(event: H3Event, id: string): Promise<void> {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
await repository.delete(id)
}
export async function getUserActivity(event: H3Event, id: string): Promise<UserActivity[]> {
// ...
}Key rule: functions are auto-imported in route handlers. classes are never auto-imported — always explicitly imported inside .
server/utils/server/contexts/server/utils/这是 API 路由处理器与上下文层之间的桥梁。它负责依赖注入(DI)并向端点处理器暴露简洁的函数。
核心规则: 同一领域的所有函数都放在一个文件中 — 无嵌套文件夹。 包含 、、 等所有用户相关函数。
server/utils/user.tscreateUserfindUserdeleteUserts
// server/utils/user.ts — 所有用户工具函数都在一个文件中
import { UserCreator } from '~~/server/contexts/user/application/UserCreator'
import { UserFinder } from '~~/server/contexts/user/application/UserFinder'
import { PostgresUserRepository } from '~~/server/contexts/user/infrastructure/PostgresUserRepository'
import { PostgresService } from '~~/server/contexts/shared/services/PostgresService'
import type { CreateUserDto } from '~~/shared/types/User'
import type { User } from '~~/shared/types/User'
import type { H3Event } from 'h3'
export async function createUser(event: H3Event, dto: CreateUserDto): Promise<User> {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
const creator = new UserCreator(repository)
return creator.create(dto)
}
export async function findUser(event: H3Event, id: string): Promise<User> {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
const finder = new UserFinder(repository)
return finder.find(id)
}
export async function deleteUser(event: H3Event, id: string): Promise<void> {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
await repository.delete(id)
}
export async function getUserActivity(event: H3Event, id: string): Promise<UserActivity[]> {
// ...
}核心规则: 中的函数会在路由处理器中自动导入。 中的类永远不会自动导入 — 始终在 中显式导入。
server/utils/server/contexts/server/utils/Shared Layer
共享层
shared/shared/shared/types/
— Types and enums, one file per module
shared/types/shared/types/
— 类型与枚举,每个模块一个文件
shared/types/ts
// shared/types/User.ts
export interface User {
id: string
name: string
email: string
role: UserRole
roles: UserRole[] // for authenticated user — roles array
createdAt: string
}
export interface CreateUserDto {
name: string
email: string
role: UserRole
}
export enum UserRole {
Admin = 'admin',
User = 'user',
}The authenticated user type is(fromUser). There is no separateshared/types/User.tstype — the sameAuthUserinterface represents domain users and authenticated users alike. If extra auth-only fields are needed, use intersection or optional fields.User
Special files:
- — App initialization data (see App Endpoint section)
shared/types/App.ts - — All page response types (not split by module, pages are a special cross-cutting concern)
shared/types/Page.ts
ts
// shared/types/User.ts
export interface User {
id: string
name: string
email: string
role: UserRole
roles: UserRole[] // 鉴权用户的角色数组
createdAt: string
}
export interface CreateUserDto {
name: string
email: string
role: UserRole
}
export enum UserRole {
Admin = 'admin',
User = 'user',
}鉴权用户类型就是(来自User)。没有独立的shared/types/User.ts类型 — 同一个AuthUser接口既表示领域用户,也表示鉴权用户。如果需要额外的鉴权专属字段,可使用交叉类型或可选字段。User
特殊文件:
- — 应用初始化数据类型(参考应用初始化端点章节)
shared/types/App.ts - — 所有页面响应类型(不按模块拆分,页面是特殊的跨领域关注点)
shared/types/Page.ts
shared/utils/
— Zod schemas and utilities, one file per module
shared/utils/shared/utils/
— Zod 校验规则与工具函数,每个模块一个文件
shared/utils/ts
// shared/utils/user.ts — ALL user schemas and utilities in one file
import { z } from 'zod'
import { UserRole } from '../types/User'
export const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.nativeEnum(UserRole),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
export function formatUserDisplayName(user: { name: string; email: string }): string {
return `${user.name} <${user.email}>`
}ts
// shared/utils/user.ts — 所有用户校验规则与工具函数都在一个文件中
import { z } from 'zod'
import { UserRole } from '../types/User'
export const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.nativeEnum(UserRole),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
export function formatUserDisplayName(user: { name: string; email: string }): string {
return `${user.name} <${user.email}>`
}Component Architecture
组件架构
Components follow a strict three-layer hierarchy. Each layer has a single, well-defined responsibility.
组件遵循严格的三层层级结构。每一层都有单一、明确的职责。
Layer 1: Page Component (View)
第一层:页面组件(视图层)
Location:
app/pages/Rules:
- As simple as possible — just data fetching and layout composition
- Uses to fetch page data via
useAsyncDatacomposable — one single endpoint call per pageusePages - Renders a single orchestrating component, passing data as props
- No business logic, no direct store access (except reading loading state)
vue
<!-- app/pages/index.vue -->
<script setup lang="ts">
const { getHomePage } = usePages()
const { data, status } = await useAsyncData('home', getHomePage)
</script>
<template>
<div>
<Home
v-if="data"
:data="data"
/>
<AppLoadingState v-else-if="status === 'pending'" />
</div>
</template>vue
<!-- app/pages/users/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { getUserPage } = usePages()
const { data, status } = await useAsyncData(
`user-${route.params.id}`,
() => getUserPage(route.params.id as string),
)
</script>
<template>
<UserDetail
v-if="data"
:data="data"
/>
</template>位置:
app/pages/规则:
- 尽可能简洁 — 仅负责数据获取与布局组合
- 使用 通过
useAsyncData组合式函数获取页面数据 — 每个页面仅调用一个端点usePages - 渲染一个编排组件,将数据作为 props 传递
- 无业务逻辑,无直接状态存储访问(仅允许读取加载状态)
vue
<!-- app/pages/index.vue -->
<script setup lang="ts">
const { getHomePage } = usePages()
const { data, status } = await useAsyncData('home', getHomePage)
</script>
<template>
<div>
<Home
v-if="data"
:data="data"
/>
<AppLoadingState v-else-if="status === 'pending'" />
</div>
</template>vue
<!-- app/pages/users/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { getUserPage } = usePages()
const { data, status } = await useAsyncData(
`user-${route.params.id}`,
() => getUserPage(route.params.id as string),
)
</script>
<template>
<UserDetail
v-if="data"
:data="data"
/>
</template>Layer 2: Orchestrating Component (no "Container" suffix)
第二层:编排组件(无 "Container" 后缀)
Location: — inside the relevant module subfolder.
app/components/{module}/Rules:
- Named after the view it orchestrates (e.g. ,
UserDetail,UserList) — noHomesuffixContainer - Receives page data as props
- Connects to Pinia stores for reactive state and actions
- Calls use-case composables for mutations
- Computes derived state
- Passes only what each child component needs
- Never contains raw HTML — only child presentational components
vue
<!-- app/components/user/detail/UserDetail.vue -->
<script setup lang="ts">
import type { UserPageData } from '~/shared/types/Page'
const props = defineProps<{
data: UserPageData
}>()
const userStore = useUserStore()
const { deleteUser } = useUser()
// Hydrate store with server data
userStore.setUser(props.data.user)
// Derived state
const isCurrentUser = computed(() =>
userStore.currentUser?.id === props.data.user.id
)
async function handleDelete(): Promise<void> {
await deleteUser(props.data.user.id)
await navigateTo('/users')
}
</script>
<template>
<div>
<UserProfile
:user="data.user"
:is-current-user="isCurrentUser"
@delete="handleDelete"
/>
<UserActivityFeed :activities="data.activity" />
</div>
</template>vue
<!-- app/components/home/HomeLatestArticles.vue — presentational, part of Home -->
<script setup lang="ts">
import type { Article } from '~/shared/types/Page'
defineProps<{
articles: Article[]
}>()
</script>
<template>
<section>
<h2>Latest Articles</h2>
<ul>
<li v-for="article in articles" :key="article.id">
{{ article.title }}
</li>
</ul>
</section>
</template>Component folder anatomy for a module (e.g. ):
usercomponents/user/
├── list/
│ ├── UserList.vue # Orchestrates list view (connects store, composables)
│ ├── UserListItem.vue # Presentational — one user row
│ └── UserListFilters.vue # Presentational — filter controls
├── detail/
│ ├── UserDetail.vue # Orchestrates detail view
│ ├── UserProfile.vue # Presentational — user info card
│ └── UserActivityFeed.vue # Presentational — activity list
└── add/
├── UserAdd.vue # Orchestrates add/create flow
└── UserAddForm.vue # Presentational — create form位置: — 对应模块的子文件夹中。
app/components/{module}/规则:
- 以其编排的视图命名(例如 、
UserDetail、UserList)— 无Home后缀Container - 通过 props 接收页面数据
- 连接 Pinia 状态存储以处理响应式状态与操作
- 调用业务用例组合式函数处理数据变更
- 计算派生状态
- 仅传递子组件所需的数据
- 不包含原生 HTML — 仅渲染子展示组件
vue
<!-- app/components/user/detail/UserDetail.vue -->
<script setup lang="ts">
import type { UserPageData } from '~/shared/types/Page'
const props = defineProps<{
data: UserPageData
}>()
const userStore = useUserStore()
const { deleteUser } = useUser()
// 用服务端数据初始化状态存储
userStore.setUser(props.data.user)
// 派生状态
const isCurrentUser = computed(() =>
userStore.currentUser?.id === props.data.user.id
)
async function handleDelete(): Promise<void> {
await deleteUser(props.data.user.id)
await navigateTo('/users')
}
</script>
<template>
<div>
<UserProfile
:user="data.user"
:is-current-user="isCurrentUser"
@delete="handleDelete"
/>
<UserActivityFeed :activities="data.activity" />
</div>
</template>vue
<!-- app/components/home/HomeLatestArticles.vue — 展示组件,属于首页模块 -->
<script setup lang="ts">
import type { Article } from '~/shared/types/Page'
defineProps<{
articles: Article[]
}>()
</script>
<template>
<section>
<h2>最新文章</h2>
<ul>
<li v-for="article in articles" :key="article.id">
{{ article.title }}
</li>
</ul>
</section>
</template>模块组件文件夹结构示例(如 模块):
usercomponents/user/
├── list/
│ ├── UserList.vue # 编排列表视图(连接状态存储、组合式函数)
│ ├── UserListItem.vue # 展示组件 — 单个用户行
│ └── UserListFilters.vue # 展示组件 — 筛选控件
├── detail/
│ ├── UserDetail.vue # 编排详情视图
│ ├── UserProfile.vue # 展示组件 — 用户信息卡片
│ └── UserActivityFeed.vue # 展示组件 — 活动列表
└── add/
├── UserAdd.vue # 编排新增/创建流程
└── UserAddForm.vue # 展示组件 — 创建表单Layer 3: Presentational Component
第三层:展示组件
Location: Inside the relevant module subfolder, or for truly generic components.
app/components/ui/Rules:
- Completely independent — no Pinia, no composables, no $fetch
- Communicates only through ,
props, andv-modelemit - Fully reusable within its module (or across the app if in )
ui/ - Uses Nuxt UI components internally
- Can contain local UI state (e.g., open/close toggles)
vue
<!-- app/components/user/detail/UserProfile.vue -->
<script setup lang="ts">
import type { User } from '~/shared/types/User'
const props = defineProps<{
user: User
isCurrentUser: boolean
}>()
const emit = defineEmits<{
delete: []
}>()
const showConfirm = ref(false)
function confirmDelete(): void {
showConfirm.value = true
}
function handleConfirm(): void {
showConfirm.value = false
emit('delete')
}
</script>
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold">{{ user.name }}</h1>
<UBadge :label="user.role" />
</div>
</template>
<p class="text-gray-500">{{ user.email }}</p>
<template #footer>
<UButton
v-if="isCurrentUser"
color="red"
variant="ghost"
@click="confirmDelete"
>
Delete account
</UButton>
</template>
<UModal v-model="showConfirm">
<UCard>
<p>Are you sure you want to delete your account?</p>
<template #footer>
<div class="flex gap-2">
<UButton color="red" @click="handleConfirm">Confirm</UButton>
<UButton variant="ghost" @click="showConfirm = false">Cancel</UButton>
</div>
</template>
</UCard>
</UModal>
</UCard>
</template>Summary table:
| Layer | Location | Pinia | $fetch | Props | Emits |
|---|---|---|---|---|---|
| Page | | ❌ | ❌ (via | ❌ | ❌ |
| Orchestrating | | ✅ | ✅ (via composables) | ✅ | ❌ |
| Presentational | | ❌ | ❌ | ✅ | ✅ |
位置: 对应模块的子文件夹中,或 (通用组件)。
app/components/ui/规则:
- 完全独立 — 无 Pinia、无组合式函数、无 $fetch
- 仅通过 、
props和v-model通信emit - 在所属模块内可复用(若在 中则全应用可复用)
ui/ - 内部使用 Nuxt UI 组件
- 可包含本地 UI 状态(如展开/收起切换)
vue
<!-- app/components/user/detail/UserProfile.vue -->
<script setup lang="ts">
import type { User } from '~/shared/types/User'
const props = defineProps<{
user: User
isCurrentUser: boolean
}>()
const emit = defineEmits<{
delete: []
}>()
const showConfirm = ref(false)
function confirmDelete(): void {
showConfirm.value = true
}
function handleConfirm(): void {
showConfirm.value = false
emit('delete')
}
</script>
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold">{{ user.name }}</h1>
<UBadge :label="user.role" />
</div>
</template>
<p class="text-gray-500">{{ user.email }}</p>
<template #footer>
<UButton
v-if="isCurrentUser"
color="red"
variant="ghost"
@click="confirmDelete"
>
删除账户
</UButton>
</template>
<UModal v-model="showConfirm">
<UCard>
<p>确定要删除你的账户吗?</p>
<template #footer>
<div class="flex gap-2">
<UButton color="red" @click="handleConfirm">确认</UButton>
<UButton variant="ghost" @click="showConfirm = false">取消</UButton>
</div>
</template>
</UCard>
</UModal>
</UCard>
</template>层级总结表:
| 层级 | 位置 | Pinia | $fetch | Props | Emits |
|---|---|---|---|---|---|
| 页面组件 | | ❌ | ❌(通过 | ❌ | ❌ |
| 编排组件 | | ✅ | ✅(通过组合式函数) | ✅ | ❌ |
| 展示组件 | | ❌ | ❌ | ✅ | ✅ |
State Management with Pinia
Pinia 状态管理
Stores hold reactive client-side state and are organized by domain module.
ts
// app/stores/user.store.ts
import type { User } from '~/shared/types/User'
export const useUserStore = defineStore('user', () => {
// State
const currentUser = ref<User | null>(null)
const users = ref<User[]>([])
// Getters
const isAuthenticated = computed(() => currentUser.value !== null)
const isAdmin = computed(() =>
currentUser.value?.roles.includes(UserRole.Admin) ?? false
)
// Actions
function setCurrentUser(user: User | null): void {
currentUser.value = user
}
function setUsers(list: User[]): void {
users.value = list
}
function addUser(user: User): void {
users.value.push(user)
}
function removeUser(id: string): void {
users.value = users.value.filter(u => u.id !== id)
}
return {
currentUser,
users,
isAuthenticated,
isAdmin,
setCurrentUser,
setUsers,
addUser,
removeUser,
}
})Key rules:
- Always use with the setup store syntax (composition API style)
defineStore - Store files use suffix:
.store.ts,user.store.tsapp.store.ts - Exported store function keeps the prefix:
use,useUserStoreuseAppStore - Stores are auto-imported via in
imports.dirs: ['stores']nuxt.config.ts - Stores are only accessed in orchestrating components and composables — never in presentational components
- Stores are hydrated from server data in orchestrating components or the flow
useApp.init()
状态存储保存客户端响应式状态,按领域模块组织。
ts
// app/stores/user.store.ts
import type { User } from '~/shared/types/User'
export const useUserStore = defineStore('user', () => {
// 状态
const currentUser = ref<User | null>(null)
const users = ref<User[]>([])
// 计算属性
const isAuthenticated = computed(() => currentUser.value !== null)
const isAdmin = computed(() =>
currentUser.value?.roles.includes(UserRole.Admin) ?? false
)
// 操作
function setCurrentUser(user: User | null): void {
currentUser.value = user
}
function setUsers(list: User[]): void {
users.value = list
}
function addUser(user: User): void {
users.value.push(user)
}
function removeUser(id: string): void {
users.value = users.value.filter(u => u.id !== id)
}
return {
currentUser,
users,
isAuthenticated,
isAdmin,
setCurrentUser,
setUsers,
addUser,
removeUser,
}
})核心规则:
- 始终使用 的组合式 API 语法(setup store)
defineStore - 状态存储文件使用 后缀:
.store.ts、user.store.tsapp.store.ts - 导出的状态存储函数保留 前缀:
use、useUserStoreuseAppStore - 通过 中的
nuxt.config.ts自动导入状态存储imports.dirs: ['stores'] - 仅在编排组件和组合式函数中访问状态存储 — 永远不要在展示组件中访问
- 在编排组件或 流程中用服务端数据初始化状态存储
useApp.init()
Layouts, Middleware & Plugins
布局、中间件与插件
Layouts — app/layouts/
app/layouts/布局 — app/layouts/
app/layouts/Layouts wrap pages and provide shared structure (header, sidebar, footer). Nuxt automatically wraps pages using in .
<NuxtLayout>app.vuevue
<!-- app/layouts/default.vue -->
<template>
<div class="flex flex-col min-h-screen">
<AppHeader />
<main class="flex-1">
<slot />
</main>
<AppFooter />
</div>
</template>vue
<!-- app/layouts/dashboard.vue -->
<template>
<div class="flex">
<DashboardSidebar />
<main class="flex-1 p-6">
<slot />
</main>
</div>
</template>Use a specific layout in a page:
vue
<!-- app/pages/dashboard/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'dashboard' })
</script>Disable layout:
vue
<script setup lang="ts">
definePageMeta({ layout: false })
</script>布局组件包裹页面,提供共享结构(头部、侧边栏、底部)。Nuxt 会自动在 中通过 包裹页面。
app.vue<NuxtLayout>vue
<!-- app/layouts/default.vue -->
<template>
<div class="flex flex-col min-h-screen">
<AppHeader />
<main class="flex-1">
<slot />
</main>
<AppFooter />
</div>
</template>vue
<!-- app/layouts/dashboard.vue -->
<template>
<div class="flex">
<DashboardSidebar />
<main class="flex-1 p-6">
<slot />
</main>
</div>
</template>在页面中使用指定布局:
vue
<!-- app/pages/dashboard/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'dashboard' })
</script>禁用布局:
vue
<script setup lang="ts">
definePageMeta({ layout: false })
</script>Middleware — app/middleware/
app/middleware/中间件 — app/middleware/
app/middleware/Middleware runs before a route is rendered. Use it for authentication guards, role checks, and redirects.
Types:
- Named middleware — explicitly applied per page via
definePageMeta - Global middleware — filename ends with — runs on every route change
.global.ts
ts
// app/middleware/auth.ts — named middleware
export default defineNuxtRouteMiddleware((to, from) => {
const userStore = useUserStore()
if (!userStore.isAuthenticated) {
return navigateTo('/login')
}
})ts
// app/middleware/role.ts — named middleware with meta
export default defineNuxtRouteMiddleware((to) => {
const userStore = useUserStore()
const requiredRole = to.meta.requiredRole as UserRole | undefined
if (requiredRole && !userStore.currentUser?.roles.includes(requiredRole)) {
return navigateTo('/unauthorized')
}
})Apply middleware in a page:
vue
<!-- app/pages/admin/index.vue -->
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'role'],
requiredRole: UserRole.Admin,
})
</script>Global middleware example:
ts
// app/middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to) => {
if (import.meta.client) {
trackPageView(to.fullPath)
}
})中间件在路由渲染前运行。用于鉴权守卫、角色校验与页面重定向。
类型:
- 命名中间件 — 通过 显式应用到指定页面
definePageMeta - 全局中间件 — 文件名以 结尾 — 在每次路由变更时运行
.global.ts
ts
// app/middleware/auth.ts — 命名中间件
export default defineNuxtRouteMiddleware((to, from) => {
const userStore = useUserStore()
if (!userStore.isAuthenticated) {
return navigateTo('/login')
}
})ts
// app/middleware/role.ts — 带元信息的命名中间件
export default defineNuxtRouteMiddleware((to) => {
const userStore = useUserStore()
const requiredRole = to.meta.requiredRole as UserRole | undefined
if (requiredRole && !userStore.currentUser?.roles.includes(requiredRole)) {
return navigateTo('/unauthorized')
}
})在页面中应用中间件:
vue
<!-- app/pages/admin/index.vue -->
<script setup lang="ts">
definePageMeta({
middleware: ['auth', 'role'],
requiredRole: UserRole.Admin,
})
</script>全局中间件示例:
ts
// app/middleware/analytics.global.ts
export default defineNuxtRouteMiddleware((to) => {
if (import.meta.client) {
trackPageView(to.fullPath)
}
})Plugins — app/plugins/
app/plugins/插件 — app/plugins/
app/plugins/Plugins run once when the Nuxt app is created. Use them to register third-party integrations, provide global helpers, or configure libraries.
Filename conventions:
- — runs on both server and client
plugin.ts - — runs only on the client
plugin.client.ts - — runs only on the server
plugin.server.ts - Plugins are ordered by filename (prefix with numbers if order matters: )
01.analytics.client.ts
ts
// app/plugins/sentry.ts — isomorphic plugin
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
Sentry.init({
dsn: config.public.sentryDsn,
environment: config.public.environment,
})
nuxtApp.vueApp.config.errorHandler = (error) => {
Sentry.captureException(error)
}
})ts
// app/plugins/analytics.client.ts — client-only plugin
export default defineNuxtPlugin(() => {
// Safe to access window/document here
const analytics = new Analytics({ token: useRuntimeConfig().public.analyticsToken })
return {
provide: {
analytics,
},
}
})Access provided values in components:
ts
const { $analytics } = useNuxtApp()
$analytics.track('page_view')插件在 Nuxt 应用创建时运行一次。用于注册第三方集成、提供全局工具或配置库。
文件名约定:
- — 在服务端和客户端都运行
plugin.ts - — 仅在客户端运行
plugin.client.ts - — 仅在服务端运行
plugin.server.ts - 插件按文件名排序(若需指定顺序,可添加数字前缀:)
01.analytics.client.ts
ts
// app/plugins/sentry.ts — 同构插件
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
Sentry.init({
dsn: config.public.sentryDsn,
environment: config.public.environment,
})
nuxtApp.vueApp.config.errorHandler = (error) => {
Sentry.captureException(error)
}
})ts
// app/plugins/analytics.client.ts — 仅客户端插件
export default defineNuxtPlugin(() => {
// 此处可安全访问 window/document
const analytics = new Analytics({ token: useRuntimeConfig().public.analyticsToken })
return {
provide: {
analytics,
},
}
})在组件中访问插件提供的内容:
ts
const { $analytics } = useNuxtApp()
$analytics.track('page_view')Composables
组合式函数
Composables encapsulate reactive logic and API calls. They are the primary way Vue components interact with the server.
Conventions:
- Exported function always has prefix:
use,useUseruseApp - Filename has no prefix:
use,user.ts,app.tspages.ts - Located in (flat, auto-imported)
app/composables/ - One file per domain module
- Use-case composables call directly —
$fetchis the page's responsibilityuseAsyncData - Composables may read and write Pinia stores
Use-case composable pattern:
ts
// app/composables/user.ts
export function useUser() {
const userStore = useUserStore()
const toast = useToast()
async function createUser(dto: CreateUserDto): Promise<User | null> {
try {
const user = await $fetch<User>('/api/user/create', {
method: 'POST',
body: dto,
})
userStore.addUser(user)
toast.add({ title: 'User created', color: 'green' })
return user
} catch (error) {
toast.add({ title: 'Error creating user', color: 'red' })
return null
}
}
async function deleteUser(id: string): Promise<boolean> {
try {
await $fetch(`/api/user/${id}`, { method: 'DELETE' })
userStore.removeUser(id)
return true
} catch {
toast.add({ title: 'Error deleting user', color: 'red' })
return false
}
}
return { createUser, deleteUser }
}组合式函数封装响应式逻辑与 API 调用。是 Vue 组件与服务端交互的主要方式。
约定:
- 导出的函数始终带 前缀:
use、useUseruseApp - 文件名无 前缀:
use、user.ts、app.tspages.ts - 位于 (扁平化,自动导入)
app/composables/ - 每个领域模块一个文件
- 业务用例组合式函数直接调用 —
$fetch是页面组件的职责useAsyncData - 组合式函数可读写 Pinia 状态存储
业务用例组合式函数模式:
ts
// app/composables/user.ts
export function useUser() {
const userStore = useUserStore()
const toast = useToast()
async function createUser(dto: CreateUserDto): Promise<User | null> {
try {
const user = await $fetch<User>('/api/user/create', {
method: 'POST',
body: dto,
})
userStore.addUser(user)
toast.add({ title: '用户创建成功', color: 'green' })
return user
} catch (error) {
toast.add({ title: '用户创建失败', color: 'red' })
return null
}
}
async function deleteUser(id: string): Promise<boolean> {
try {
await $fetch(`/api/user/${id}`, { method: 'DELETE' })
userStore.removeUser(id)
return true
} catch {
toast.add({ title: '用户删除失败', color: 'red' })
return false
}
}
return { createUser, deleteUser }
}Nuxt UI
Nuxt UI
Nuxt UI provides a design system built on Tailwind CSS. Use its components as building blocks inside presentational components.
Core components:
| Component | Purpose |
|---|---|
| All interactive buttons |
| Content containers with header/footer slots |
| Dialog overlays |
| Form containers with validation |
| Form inputs |
| Data tables |
| Status labels |
| Feedback messages |
| Navigation menus |
| Toast notifications |
Form with Zod validation:
vue
<!-- app/components/user/add/UserAddForm.vue -->
<script setup lang="ts">
import type { CreateUserDto } from '~/shared/types/User'
import { createUserSchema } from '~/shared/utils/user'
const props = defineProps<{
loading: boolean
}>()
const emit = defineEmits<{
submit: [dto: CreateUserDto]
}>()
const state = reactive<CreateUserDto>({
name: '',
email: '',
role: UserRole.User,
})
async function handleSubmit(): Promise<void> {
emit('submit', { ...state })
}
</script>
<template>
<UForm
:schema="createUserSchema"
:state="state"
@submit="handleSubmit"
>
<UFormField label="Name" name="name">
<UInput v-model="state.name" />
</UFormField>
<UFormField label="Email" name="email">
<UInput v-model="state.email" type="email" />
</UFormField>
<UFormField label="Role" name="role">
<USelect
v-model="state.role"
:options="Object.values(UserRole)"
/>
</UFormField>
<UButton type="submit" :loading="loading">
Create User
</UButton>
</UForm>
</template>Toast notifications (use in orchestrating components or composables):
ts
const toast = useToast()
async function handleCreate(dto: CreateUserDto): Promise<void> {
try {
await createUser(dto)
toast.add({ title: 'User created', color: 'green' })
} catch {
toast.add({ title: 'Failed to create user', color: 'red' })
}
}Nuxt UI 提供基于 Tailwind CSS 的设计系统。在展示组件中使用其组件作为基础构建块。
核心组件:
| 组件 | 用途 |
|---|---|
| 所有交互按钮 |
| 带头部/底部插槽的内容容器 |
| 对话框浮层 |
| 带校验的表单容器 |
| 表单输入控件 |
| 数据表格 |
| 状态标签 |
| 反馈提示 |
| 导航菜单 |
| 消息提示框 |
带 Zod 校验的表单:
vue
<!-- app/components/user/add/UserAddForm.vue -->
<script setup lang="ts">
import type { CreateUserDto } from '~/shared/types/User'
import { createUserSchema } from '~/shared/utils/user'
const props = defineProps<{
loading: boolean
}>()
const emit = defineEmits<{
submit: [dto: CreateUserDto]
}>()
const state = reactive<CreateUserDto>({
name: '',
email: '',
role: UserRole.User,
})
async function handleSubmit(): Promise<void> {
emit('submit', { ...state })
}
</script>
<template>
<UForm
:schema="createUserSchema"
:state="state"
@submit="handleSubmit"
>
<UFormField label="姓名" name="name">
<UInput v-model="state.name" />
</UFormField>
<UFormField label="邮箱" name="email">
<UInput v-model="state.email" type="email" />
</UFormField>
<UFormField label="角色" name="role">
<USelect
v-model="state.role"
:options="Object.values(UserRole)"
/>
</UFormField>
<UButton type="submit" :loading="loading">
创建用户
</UButton>
</UForm>
</template>消息提示框(在编排组件或组合式函数中使用):
ts
const toast = useToast()
async function handleCreate(dto: CreateUserDto): Promise<void> {
try {
await createUser(dto)
toast.add({ title: '用户创建成功', color: 'green' })
} catch {
toast.add({ title: '用户创建失败', color: 'red' })
}
}Data Fetching Patterns
数据获取模式
useAsyncData
in pages
useAsyncData页面组件中的 useAsyncData
useAsyncDataUse in pages to fetch data server-side with proper SSR support and deduplication.
useAsyncDatats
// Basic usage — one endpoint call per page
const { data, status, refresh } = await useAsyncData('home', () => usePages().getHomePage())
// With route-based key and param
const route = useRoute()
const { data } = await useAsyncData(
`user-${route.params.id}`,
() => usePages().getUserPage(route.params.id as string),
)在页面组件中使用 进行服务端数据获取,支持 SSR 与请求去重。
useAsyncDatats
// 基础用法 — 每个页面仅调用一个端点
const { data, status, refresh } = await useAsyncData('home', () => usePages().getHomePage())
// 带路由参数的动态键
const route = useRoute()
const { data } = await useAsyncData(
`user-${route.params.id}`,
() => usePages().getUserPage(route.params.id as string),
)$fetch
in composables
$fetch组合式函数中的 $fetch
$fetchUse for mutations and imperative calls from composables (no SSR caching needed).
$fetchts
const user = await $fetch<User>('/api/user/create', {
method: 'POST',
body: dto,
})在组合式函数中使用 处理数据变更与命令式调用(无需 SSR 缓存)。
$fetchts
const user = await $fetch<User>('/api/user/create', {
method: 'POST',
body: dto,
})callOnce
in app.vue
callOnceapp.vueapp.vue
中的 callOnce
app.vuecallOnceEnsures the app initialization runs exactly once during SSR and is not repeated on client hydration.
ts
await callOnce(async () => {
const { init } = useApp()
await init()
})确保应用初始化逻辑仅在 SSR 时运行一次,客户端 hydration 时不再重复执行。
ts
await callOnce(async () => {
const { init } = useApp()
await init()
})Error Handling
错误处理
Server side — throw :
createErrorts
// server/api/user/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const user = await findUser(event, id)
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
return user
})Domain errors in contexts:
ts
// server/contexts/user/domain/UserError.ts
export class UserError extends Error {
constructor(
public readonly code: 'EMAIL_ALREADY_EXISTS' | 'NOT_FOUND',
public readonly detail?: string,
) {
super(`UserError: ${code}`)
}
}Map domain errors to HTTP errors in :
server/utils/user.tsts
export async function createUser(event: H3Event, dto: CreateUserDto): Promise<User> {
try {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
const creator = new UserCreator(repository)
return await creator.create(dto)
} catch (error) {
if (error instanceof UserError) {
throw createError({ statusCode: 409, message: error.message })
}
throw createError({ statusCode: 500, message: 'Internal server error' })
}
}Client side — handle errors in composables, never let them bubble raw to the UI:
ts
// app/composables/user.ts
async function createUser(dto: CreateUserDto): Promise<User | null> {
try {
return await $fetch<User>('/api/user/create', { method: 'POST', body: dto })
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Unknown error'
toast.add({ title: msg, color: 'red' })
return null
}
}Global error page:
vue
<!-- app/error.vue -->
<script setup lang="ts">
const props = defineProps<{
error: { statusCode: number; message: string }
}>()
function handleClear(): void {
clearError({ redirect: '/' })
}
</script>
<template>
<div>
<h1>{{ error.statusCode }}</h1>
<p>{{ error.message }}</p>
<UButton @click="handleClear">Go home</UButton>
</div>
</template>服务端 — 抛出 :
createErrorts
// server/api/user/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')!
const user = await findUser(event, id)
if (!user) {
throw createError({ statusCode: 404, message: '用户不存在' })
}
return user
})上下文层的领域错误:
ts
// server/contexts/user/domain/UserError.ts
export class UserError extends Error {
constructor(
public readonly code: 'EMAIL_ALREADY_EXISTS' | 'NOT_FOUND',
public readonly detail?: string,
) {
super(`UserError: ${code}`)
}
}在 中将领域错误映射为 HTTP 错误:
server/utils/user.tsts
export async function createUser(event: H3Event, dto: CreateUserDto): Promise<User> {
try {
const db = new PostgresService()
const repository = new PostgresUserRepository(db)
const creator = new UserCreator(repository)
return await creator.create(dto)
} catch (error) {
if (error instanceof UserError) {
throw createError({ statusCode: 409, message: error.message })
}
throw createError({ statusCode: 500, message: '内部服务错误' })
}
}客户端 — 在组合式函数中处理错误,不要让原始错误冒泡到 UI:
ts
// app/composables/user.ts
async function createUser(dto: CreateUserDto): Promise<User | null> {
try {
return await $fetch<User>('/api/user/create', { method: 'POST', body: dto })
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : '未知错误'
toast.add({ title: msg, color: 'red' })
return null
}
}全局错误页面:
vue
<!-- app/error.vue -->
<script setup lang="ts">
const props = defineProps<{
error: { statusCode: number; message: string }
}>()
function handleClear(): void {
clearError({ redirect: '/' })
}
</script>
<template>
<div>
<h1>{{ error.statusCode }}</h1>
<p>{{ error.message }}</p>
<UButton @click="handleClear">返回首页</UButton>
</div>
</template>Best Practices
最佳实践
Apply SOLID principles to the Nuxt stack
在 Nuxt 栈中应用 SOLID 原则
| Principle | Nuxt application |
|---|---|
| SRP | Pages only fetch. Orchestrating components only coordinate. Presentational components only render. |
| OCP | Add new pages/use cases without touching existing endpoints or stores. |
| LSP | Repository implementations are fully substitutable for their interfaces. |
| ISP | Small, focused composables ( |
| DIP | |
| 原则 | Nuxt 应用中的实践 |
|---|---|
| 单一职责原则(SRP) | 页面组件仅负责数据获取。编排组件仅负责协调。展示组件仅负责渲染。 |
| 开闭原则(OCP) | 新增页面/用例时无需修改现有端点或状态存储。 |
| 里氏替换原则(LSP) | 仓储实现可完全替代其接口。 |
| 接口隔离原则(ISP) | 小而专注的组合式函数( |
| 依赖倒置原则(DIP) | |
Keep pages dumb
保持页面组件简洁
Pages should contain no business logic. They call one endpoint and render one orchestrating component.
vue
<!-- ✅ Good — page is just a loader -->
<script setup lang="ts">
const { getHomePage } = usePages()
const { data } = await useAsyncData('home', getHomePage)
</script>
<!-- ❌ Bad — page doing too much -->
<script setup lang="ts">
const { data } = await useAsyncData('home', () => $fetch('/api/pages'))
const userStore = useUserStore()
const filtered = computed(() => data.value?.products.filter(p => p.active))
userStore.setProducts(filtered.value ?? [])
</script>页面组件不应包含任何业务逻辑。仅调用一个端点并渲染一个编排组件。
vue
<!-- ✅ 良好实践 — 页面仅作为加载器 -->
<script setup lang="ts">
const { getHomePage } = usePages()
const { data } = await useAsyncData('home', getHomePage)
</script>
<!-- ❌ 不良实践 — 页面职责过重 -->
<script setup lang="ts">
const { data } = await useAsyncData('home', () => $fetch('/api/pages'))
const userStore = useUserStore()
const filtered = computed(() => data.value?.products.filter(p => p.active))
userStore.setProducts(filtered.value ?? [])
</script>Keep presentational components pure
保持展示组件纯净
Presentational components must never import or call anything from outside their own file except types, Nuxt UI, and Vue primitives. If a component needs data from a store, pass it as a prop from the orchestrating component.
vue
<!-- ✅ Good -->
<script setup lang="ts">
import type { User } from '~/shared/types/User'
defineProps<{ user: User }>()
</script>
<!-- ❌ Bad — presentational touching Pinia -->
<script setup lang="ts">
const userStore = useUserStore()
</script>展示组件除了类型定义、Nuxt UI 和 Vue 原生 API 外,不应导入或调用任何外部内容。如果组件需要状态存储中的数据,请从编排组件通过 props 传递。
vue
<!-- ✅ 良好实践 -->
<script setup lang="ts">
import type { User } from '~/shared/types/User'
defineProps<{ user: User }>()
</script>
<!-- ❌ 不良实践 — 展示组件直接操作 Pinia -->
<script setup lang="ts">
const userStore = useUserStore()
</script>One composable per domain module
每个领域模块一个组合式函数
Split composables by bounded context, not by technical concern. Avoid composables like or that grow to touch everything.
useApiuseFetchapp/composables/
├── app.ts # exports useApp() — App bootstrap
├── pages.ts # exports usePages() — All page fetchers
├── user.ts # exports useUser() — User use cases
├── order.ts # exports useOrder() — Order use cases
└── product.ts # exports useProduct()按限界上下文拆分组合式函数,而非按技术关注点。避免创建 或 这类全能型组合式函数。
useApiuseFetchapp/composables/
├── app.ts # 导出 useApp() — 应用初始化
├── pages.ts # 导出 usePages() — 所有页面数据获取
├── user.ts # 导出 useUser() — 用户业务用例
├── order.ts # 导出 useOrder() — 订单业务用例
└── product.ts # 导出 useProduct()Never put business logic in the Vue layer
不要在 Vue 层放置业务逻辑
Business logic (validation beyond form UX, domain rules, calculations) belongs in . The Vue layer (pages, orchestrating components, composables) should only orchestrate calls and manage UI state.
server/contexts/ts
// ❌ Bad — business rule in a composable
export function useUser() {
async function createUser(dto: CreateUserDto) {
if (dto.role === UserRole.Admin && !currentUser.isAdmin) {
throw new Error('Only admins can create admins')
}
return $fetch('/api/user/create', { method: 'POST', body: dto })
}
}
// ✅ Good — business rule enforced in context application layer
// server/contexts/user/application/UserCreator.ts
export class UserCreator {
async create(dto: CreateUserDto, requesterId: string): Promise<User> {
if (dto.role === UserRole.Admin) {
await this.authService.requireAdmin(requesterId)
}
return this.repository.create(dto)
}
}业务逻辑(除表单 UX 之外的校验、领域规则、计算)应放在 中。Vue 层(页面组件、编排组件、组合式函数)仅负责协调调用与管理 UI 状态。
server/contexts/ts
// ❌ 不良实践 — 业务规则放在组合式函数中
export function useUser() {
async function createUser(dto: CreateUserDto) {
if (dto.role === UserRole.Admin && !currentUser.isAdmin) {
throw new Error('仅管理员可创建管理员用户')
}
return $fetch('/api/user/create', { method: 'POST', body: dto })
}
}
// ✅ 良好实践 — 业务规则在上下文应用层中实现
// server/contexts/user/application/UserCreator.ts
export class UserCreator {
async create(dto: CreateUserDto, requesterId: string): Promise<User> {
if (dto.role === UserRole.Admin) {
await this.authService.requireAdmin(requesterId)
}
return this.repository.create(dto)
}
}Validate at the boundary
在边界处校验数据
Use Zod schemas from to validate all incoming data at the API boundary using or . Never trust unvalidated input inside or .
shared/utils/readValidatedBodygetValidatedQueryserver/utils/server/contexts/ts
// server/api/user/create.post.ts
export default defineEventHandler(async (event) => {
// Validate at the edge — if this throws, H3 returns 422 automatically
const body = await readValidatedBody(event, createUserSchema.parse)
return createUser(event, body)
})使用 中的 Zod 校验规则,在 API 边界通过 或 校验所有输入数据。永远不要在 或 中信任未校验的输入。
shared/utils/readValidatedBodygetValidatedQueryserver/utils/server/contexts/ts
// server/api/user/create.post.ts
export default defineEventHandler(async (event) => {
// 在边界处校验 — 若校验失败,H3 自动返回 422 错误
const body = await readValidatedBody(event, createUserSchema.parse)
return createUser(event, body)
})Use callOnce
for app initialization
callOnce使用 callOnce
进行应用初始化
callOnceNever initialize the app inside or a without . This prevents double-fetching during SSR hydration.
onMountedwatchcallOncets
// ✅ Good
await callOnce(init)
// ❌ Bad — runs twice (server + client)
onMounted(() => init())永远不要在 或 中直接初始化应用而不使用 。这会避免 SSR hydration 时重复请求数据。
onMountedwatchcallOncets
// ✅ 良好实践
await callOnce(init)
// ❌ 不良实践 — 执行两次(服务端 + 客户端)
onMounted(() => init())Co-locate types with their domain
类型与领域代码共存
Types live in , Zod schemas and utils in . Never define types inline inside Vue components or server route handlers.
shared/types/ModuleName.tsshared/utils/moduleName.tsts
// ✅ Good — shared/types/Product.ts
export interface Product {
id: string
name: string
price: number
currency: Currency
}
export enum Currency {
EUR = 'EUR',
USD = 'USD',
}类型定义放在 ,Zod 校验规则与工具函数放在 。永远不要在 Vue 组件或服务端路由处理器中内联定义类型。
shared/types/ModuleName.tsshared/utils/moduleName.tsts
// ✅ 良好实践 — shared/types/Product.ts
export interface Product {
id: string
name: string
price: number
currency: Currency
}
export enum Currency {
EUR = 'EUR',
USD = 'USD',
}Avoid over-engineering with YAGNI
避免过度设计,遵循 YAGNI 原则
Nuxt provides excellent conventions — don't add abstraction layers that duplicate framework features.
- Don't create a generic base class unless you have multiple implementations that truly share behavior.
Repository<T> - Don't add an event bus if Nuxt's built-in SSE or simple store reactivity covers the need.
- Do start with the simplest solution and refactor when the need is proven.
Nuxt 已提供优秀的约定 — 不要添加重复框架功能的抽象层。
- 不要创建通用的 基类,除非你有多个真正共享行为的实现。
Repository<T> - 不要添加事件总线,如果 Nuxt 内置的 SSE 或简单的状态存储响应式已满足需求。
- 要从最简单的解决方案开始,在需求明确时再重构。
Naming conventions
命名约定
| Artifact | Convention | Example |
|---|---|---|
| Pages | | |
| Components | | |
| Composable files | | |
| Composable functions | | |
| Store files | | |
| Store functions | | |
| Server utils | | |
| Context classes | | |
| Types/interfaces | | |
| Enums | | |
| API endpoints | | |
| Shared type files | | |
| Shared util files | | |
| Component folders | | |
| 元素 | 约定 | 示例 |
|---|---|---|
| 页面组件 | 短横线命名法 | |
| 组件 | 大驼峰命名法 | |
| 组合式函数文件 | 小驼峰命名法(无 | |
| 组合式函数 | 小驼峰命名法(带 | |
| 状态存储文件 | 小驼峰命名法 + | |
| 状态存储函数 | 小驼峰命名法(带 | |
| 服务端工具函数 | 小驼峰命名法,扁平化,每个领域一个文件 | |
| 上下文类 | 大驼峰命名法 | |
| 类型/接口 | 大驼峰命名法 | |
| 枚举 | 大驼峰命名法的枚举值 | |
| API 端点 | | |
| 共享类型文件 | 大驼峰命名法 | |
| 共享工具函数文件 | 小驼峰命名法 | |
| 组件文件夹 | 短横线命名法(按模块) | |