Vue Router、表单与组件架构 / Vue Router, Forms, and Component Architecture
📅 创建时间:2026-07-28 🏷️ 标签:#VueRouter #NavigationGuards #VeeValidate #Zod #ComponentDesign #FileArchitecture 📚 前置知识:[[../02-reactivity-and-composition-api]]
📋 本章目标
- 理解 Vue Router 4 三级导航守卫体系(全局 / 路由级 / 组件内)及其解析流程
- 掌握动态路由参数传递、props 解耦、过渡动画与 KeepAlive 缓存策略
- 能够使用 VeeValidate + Zod 构建类型安全的表单验证方案,包括自定义组件
- 理解透传 Attributes、provide/inject 依赖注入、组合式组件三种核心设计模式
- 建立 Feature-based 目录结构的心智模型,合理组织 Composables 与路由懒加载
- 能够在真实项目中综合运用路由守卫、表单验证与组件架构进行工程化开发
第1部分:Vue Router 4 导航守卫体系
1.1 三级守卫全景
Vue Router 4 提供三级导航守卫,形成洋葱模型:全局守卫包裹路由级守卫,路由级守卫再包裹组件内守卫。理解这个层次是设计鉴权、数据预取和页面过渡的基础。
┌─────────────────────────────────────────────────────────────┐
│ Vue Router 4 三级导航守卫体系 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 全局守卫(router 实例上注册) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ beforeEach → 导航触发时最先执行 │ │
│ │ beforeResolve → 组件内守卫解析完成后执行 │ │
│ │ afterEach → 导航完成(无 next,不阻塞导航) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 路由级守卫(路由配置中定义) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ beforeEnter → 进入该路由前执行 │ │
│ │ 可复用守卫函数数组 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 组件内守卫(组件选项 / Composition API) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ beforeRouteEnter → 组件创建前(无法访问 this) │ │
│ │ beforeRouteUpdate → 路由参数变化但组件复用时 │ │
│ │ beforeRouteLeave → 离开当前路由前 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键原则:每个守卫都接收 (to, from, next?) 参数 │
│ 返回 false 取消导航,返回路由对象重定向,next 为兼容旧版 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 全局守卫实战:登录鉴权
// src/router/guards/auth.ts
import type { Router } from 'vue-router'
import { useSessionStore } from '@/stores/session'
export function registerAuthGuard(router: Router) {
router.beforeEach(async (to, from) => {
const session = useSessionStore()
// 白名单路由无需登录
const publicPages = ['/login', '/register', '/forgot-password']
if (publicPages.includes(to.path)) {
// 已登录用户访问登录页 → 重定向到首页
if (session.isAuthenticated && to.path === '/login') {
return { path: '/' }
}
return true
}
// 未登录 → 保存目标地址后跳转登录
if (!session.isAuthenticated) {
return {
path: '/login',
query: { redirect: to.fullPath },
}
}
// 已登录 → 按需校验权限
if (to.meta.requiredRole) {
const hasRole = session.user?.roles.includes(to.meta.requiredRole as string)
if (!hasRole) {
return { path: '/403' }
}
}
return true
})
}// src/router/guards/progress.ts
import type { Router } from 'vue-router'
import NProgress from 'nprogress'
export function registerProgressGuard(router: Router) {
router.beforeEach(() => {
NProgress.start()
return true
})
router.afterEach(() => {
NProgress.done()
})
}1.3 导航解析完整流程图
┌─────────────────────────────────────────────────────────────┐
│ 导航解析完整流程(洋葱模型) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户点击 <router-link> 或调用 router.push() │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 1. 触发导航 │ │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 2. 失活组件中 │ ← beforeRouteLeave │
│ │ 调用离开守卫 │ (可阻止用户离开未保存表单) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 3. 全局 │ ← beforeEach │
│ │ beforeEach │ (鉴权、权限检查、重定向) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 4. 路由配置中 │ ← beforeEnter │
│ │ beforeEnter │ (路由级数据预加载、角色校验) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 5. 新组件内 │ ← beforeRouteEnter / beforeRouteUpdate│
│ │ 进入守卫 │ (组件级数据获取) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 6. 全局 │ ← beforeResolve │
│ │ beforeResolve │ (所有组件守卫解析后的最后关卡) │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 7. 导航确认 │ 执行导航 │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 8. 全局 │ ← afterEach(无 next,纯副作用) │
│ │ afterEach │ (页面标题更新、埋点、进度条完成) │
│ └──────────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 9. DOM 更新 │ │
│ └──────────────────┘ │
│ │
│ 任意守卫返回 false 或调用 next(false) → 导航中止 │
│ 任意守卫返回路由对象或调用 next('/path') → 重定向 │
│ │
└─────────────────────────────────────────────────────────────┘1.4 组件内守卫:未保存表单保护
<script setup lang="ts">
import { onBeforeRouteLeave } from 'vue-router'
import { ref } from 'vue'
const hasUnsavedChanges = ref(false)
const form = ref({ title: '', content: '' })
// 监听表单变更
watch(form, () => {
hasUnsavedChanges.value = true
}, { deep: true })
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = window.confirm('你有未保存的更改,确定离开吗?')
if (!answer) return false
}
return true
})
</script>第2部分:动态路由与数据获取
2.1 路由参数与 Props 解耦
路由参数通过 params 和 query 传递,但组件直接依赖 useRoute() 会造成强耦合——组件只能在特定路由下使用。通过 props 将路由参数解耦为普通 Props,组件变得可复用、可测试。
┌─────────────────────────────────────────────────────────────┐
│ 路由参数传递方式对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 方式一:useRoute() 耦合(不推荐) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const route = useRoute() │ │
│ │ const projectId = route.params.id // 强依赖路由 │ │
│ │ // 组件只能在 /projects/:id 下使用 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方式二:Props 解耦(推荐) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ // 路由配置 │ │
│ │ { path: '/projects/:id', │ │
│ │ component: ProjectDetail, │ │
│ │ props: true } // 自动将 params 映射为 props │ │
│ │ │ │
│ │ // 组件定义 │ │
│ │ const props = defineProps<{ id: string }>() │ │
│ │ // 组件可脱离路由在任何地方使用并测试 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 方式三:函数模式 Props(高级) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ props: (route) => ({ │ │
│ │ id: Number(route.params.id), │ │
│ │ search: route.query.q ?? '', │ │
│ │ }) │ │
│ │ // 可在映射阶段做类型转换和默认值处理 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.2 动态路由数据获取模式
// src/composables/useRouteData.ts
import { computed, ref, watchEffect, type Ref } from 'vue'
import { useRoute } from 'vue-router'
export function useRouteData<T>(
fetcher: (params: Record<string, string>) => Promise<T>,
paramKeys: string[],
): { data: Ref<T | null>; loading: Ref<boolean>; error: Ref<Error | null> } {
const route = useRoute()
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref<Error | null>(null)
const params = computed(() => {
const result: Record<string, string> = {}
for (const key of paramKeys) {
result[key] = String(route.params[key] ?? '')
}
return result
})
watchEffect(async (onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
loading.value = true
error.value = null
try {
const result = await fetcher(params.value)
if (!cancelled) {
data.value = result
}
} catch (e) {
if (!cancelled) {
error.value = e as Error
}
} finally {
if (!cancelled) {
loading.value = false
}
}
})
return { data, loading, error }
}2.3 路由过渡动画与 KeepAlive 缓存
┌─────────────────────────────────────────────────────────────┐
│ 路由过渡动画 + KeepAlive 缓存架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ <router-view v-slot="{ Component, route }"> │
│ ┌─────────────────────────────────────────────────┐ │
│ │ <transition :name="route.meta.transitionName" │ │
│ │ mode="out-in"> │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ <keep-alive :include="cachedViews"> │ │ │
│ │ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ │ <component :is="Component" │ │ │ │
│ │ │ │ :key="route.path" │ │ │ │
│ │ │ │ /> │ │ │ │
│ │ │ └─────────────────────────────────┘ │ │ │
│ │ │ </keep-alive> │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ </transition> │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ KeepAlive 策略: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ include: ['ProjectList', 'SearchResult'] → 白名单 │ │
│ │ exclude: ['Editor'] → 黑名单 │ │
│ │ max: 10 → 最大缓存数 │ │
│ │ │ │
│ │ 通过 onActivated / onDeactivated 监听缓存状态 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Transition 策略: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 路由 meta.transitionName 控制: │ │
│ │ 'slide-left' → 进入下一级页面 │ │
│ │ 'slide-right' → 返回上一级页面 │ │
│ │ 'fade' → 同级切换 │ │
│ │ │ │
│ │ 基于路由深度自动推断过渡方向(router-level) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘<!-- src/layouts/AppLayout.vue -->
<template>
<router-view v-slot="{ Component, route }">
<transition
:name="(route.meta.transition as string) ?? 'fade'"
mode="out-in"
>
<keep-alive :include="keepAliveList" :max="10">
<component :is="Component" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup lang="ts">
const keepAliveList = ['ProjectList', 'SearchResult', 'Dashboard']
</script>
<style scoped>
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
}
.slide-left-leave-to {
transform: translateX(-100%);
}
</style>第3部分:VeeValidate + Zod 表单验证
3.1 验证架构概览
VeeValidate 是 Vue 生态的声明式表单验证库,Zod 是 TypeScript-first 的 Schema 验证库。两者通过 @vee-validate/zod 集成,形成从类型定义到运行时验证的完整链路。
┌─────────────────────────────────────────────────────────────┐
│ VeeValidate + Zod 表单验证架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 开发阶段 运行时 │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ Zod Schema 定义 │ ──推断──→│ TypeScript 类型 │ │
│ │ │ │ (编译时安全) │ │
│ │ z.object({ │ └──────────────────┘ │
│ │ name: z.string() │ │
│ │ .min(2) │ ┌──────────────────┐ │
│ │ .max(50), │ ──校验──→│ VeeValidate │ │
│ │ email: z.string() │ │ (运行时验证) │ │
│ │ .email(), │ └──────────────────┘ │
│ │ }) │ │
│ └──────────────────────┘ │
│ │
│ 一条 Schema = 类型定义 + 验证规则 + 错误消息 │
│ 不再需要手动同步 TypeScript 接口和验证函数 │
│ │
└─────────────────────────────────────────────────────────────┘3.2 完整表单实战
// src/features/projects/model/projectSchema.ts
import { z } from 'zod'
export const projectSchema = z.object({
name: z
.string()
.min(2, '项目名称至少 2 个字符')
.max(50, '项目名称不能超过 50 个字符'),
description: z
.string()
.max(500, '描述不能超过 500 个字符')
.optional()
.default(''),
status: z.enum(['draft', 'active', 'archived'], {
required_error: '请选择项目状态',
}),
budget: z
.number({ invalid_type_error: '请输入有效数字' })
.positive('预算必须为正数')
.max(1_000_000, '预算不能超过 100 万'),
tags: z
.array(z.string().min(1))
.min(1, '至少选择一个标签')
.max(5, '最多选择 5 个标签'),
startDate: z.date({ required_error: '请选择开始日期' }),
})
// Zod 自动推断 TypeScript 类型
export type ProjectForm = z.infer<typeof projectSchema><!-- src/features/projects/components/ProjectForm.vue -->
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { projectSchema, type ProjectForm } from '../model/projectSchema'
const props = defineProps<{
initialValues?: Partial<ProjectForm>
}>()
const emit = defineEmits<{
submit: [values: ProjectForm]
}>()
const { handleSubmit, errors, values, isSubmitting, resetForm } = useForm({
validationSchema: toTypedSchema(projectSchema),
initialValues: props.initialValues,
})
const onSubmit = handleSubmit(async (formValues) => {
emit('submit', formValues as ProjectForm)
})
</script>
<template>
<form @submit="onSubmit" novalidate>
<!-- 项目名称 -->
<div class="form-field">
<label for="name">项目名称 *</label>
<input
id="name"
v-model="values.name"
type="text"
:aria-describedby="errors.name ? 'name-error' : undefined"
:aria-invalid="!!errors.name"
/>
<p v-if="errors.name" id="name-error" class="error" role="alert">
{{ errors.name }}
</p>
</div>
<!-- 项目状态 -->
<div class="form-field">
<label for="status">项目状态 *</label>
<select
id="status"
v-model="values.status"
:aria-describedby="errors.status ? 'status-error' : undefined"
>
<option value="">请选择...</option>
<option value="draft">草稿</option>
<option value="active">进行中</option>
<option value="archived">已归档</option>
</select>
<p v-if="errors.status" id="status-error" class="error" role="alert">
{{ errors.status }}
</p>
</div>
<!-- 预算 -->
<div class="form-field">
<label for="budget">预算</label>
<input
id="budget"
v-model.number="values.budget"
type="number"
:aria-describedby="errors.budget ? 'budget-error' : undefined"
/>
<p v-if="errors.budget" id="budget-error" class="error" role="alert">
{{ errors.budget }}
</p>
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : '保存项目' }}
</button>
</form>
</template>3.3 自定义组件验证
<!-- src/shared/ui/TagInput.vue — 自定义表单组件 -->
<script setup lang="ts">
import { useField } from 'vee-validate'
const props = defineProps<{
name: string
label: string
}>()
// useField 将自定义组件接入 VeeValidate 的验证体系
const { value, errorMessage, handleBlur, handleChange } = useField<string[]>(
() => props.name,
undefined,
{ initialValue: [] },
)
const inputText = ref('')
function addTag() {
const tag = inputText.value.trim()
if (tag && !value.value.includes(tag)) {
handleChange([...value.value, tag])
inputText.value = ''
}
}
function removeTag(tag: string) {
handleChange(value.value.filter((t) => t !== tag))
}
</script>
<template>
<div class="tag-input" :class="{ 'has-error': errorMessage }">
<label :for="name">{{ label }}</label>
<div class="tags">
<span v-for="tag in value" :key="tag" class="tag">
{{ tag }}
<button type="button" @click="removeTag(tag)" :aria-label="`移除标签 ${tag}`">
×
</button>
</span>
</div>
<input
:id="name"
v-model="inputText"
type="text"
@keydown.enter.prevent="addTag"
@blur="handleBlur"
/>
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
</div>
</template>3.4 服务端错误与表单级处理
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { projectSchema } from '../model/projectSchema'
const { handleSubmit, setFieldError, setErrors, isSubmitting } = useForm({
validationSchema: toTypedSchema(projectSchema),
})
const onSubmit = handleSubmit(async (values) => {
try {
await projectApi.create(values)
router.push({ name: 'projects' })
} catch (err) {
if (err instanceof ValidationError) {
// 服务端返回字段级错误 → 精确映射到对应字段
for (const [field, messages] of Object.entries(err.fields)) {
setFieldError(field, messages.join(', '))
}
} else if (err instanceof ApiError) {
// 表单级错误(如"项目名称已存在")
setErrors({ name: err.message })
} else {
// 未知错误 → 全局错误处理
setErrors({ _form: '提交失败,请稍后重试' })
}
}
})
</script>第4部分:组件设计模式
4.1 透传 Attributes(Fallthrough Attributes)
┌─────────────────────────────────────────────────────────────┐
│ 透传 Attributes 机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 父组件传递的未被声明为 Props 的 attribute: │
│ │
│ <CustomInput │
│ class="form-control" ← 透传 │
│ placeholder="输入..." ← 透传 │
│ :model-value="text" ← Props(声明了) │
│ @update:model-value ← Emits(声明了) │
│ /> │
│ │
│ 单根节点组件:自动透传到根元素 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <template> │ │
│ │ <div> ← 自动接收 class, placeholder, style 等 │ │
│ │ <input :value="modelValue" /> │ │
│ │ </div> │ │
│ │ </template> │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 多根节点组件:需要显式绑定 $attrs │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <template> │ │
│ │ <label>{{ label }}</label> │ │
│ │ <input v-bind="$attrs" /> ← 显式指定透传目标 │ │
│ │ </template> │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ inheritAttrs: false → 禁止自动透传,手动控制 │
│ useAttrs() → 在 <script setup> 中访问透传属性 │
│ │
└─────────────────────────────────────────────────────────────┘<!-- src/shared/ui/BaseInput.vue — 透传实战 -->
<script setup lang="ts">
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
modelValue: string
label: string
error?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 手动分离透传属性,精确控制目标元素
const attrs = useAttrs()
</script>
<template>
<div class="base-input" :class="{ 'has-error': error }">
<label :for="attrs.id as string | undefined">{{ label }}</label>
<input
v-bind="attrs"
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<p v-if="error" class="error" role="alert">{{ error }}</p>
</div>
</template>4.2 依赖注入(provide/inject)
provide/inject 是 Vue 的深层组件通信机制,适合向任意深度的子树注入上下文——主题、表单状态、服务实例等。与 Props 逐层传递不同,中间组件无需感知被传递的数据。
┌─────────────────────────────────────────────────────────────┐
│ provide/inject 依赖注入模式 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 祖先组件 (Provider) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ provide(FORM_CONTEXT_KEY, { │ │
│ │ values: readonly(values), │ │
│ │ setFieldValue, │ │
│ │ validate, │ │
│ │ }) │ │
│ └────────────────────────┬────────────────────────────┘ │
│ │ 无需 Props 逐层传递 │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 中间组件A │ │ 中间组件B │ │ 中间组件C │ │
│ │ (不感知) │ │ (不感知) │ │ (不感知) │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 叶子组件 │ │ 叶子组件 │ │ 叶子组件 │ │
│ │ inject() │ │ inject() │ │ inject() │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ 最佳实践: │
│ • 使用 Symbol 作为注入键,避免命名冲突 │
│ • 用 Composable 封装 inject,提供类型安全和默认值 │
│ • 可变状态的修改方法由 Provider 暴露,不直接让消费者写入 │
│ • readonly() 包裹暴露的响应式对象 │
│ │
└─────────────────────────────────────────────────────────────┘// src/features/projects/composables/useProjectContext.ts
import type { InjectionKey, Ref } from 'vue'
import { inject, provide, readonly, ref } from 'vue'
export interface ProjectContext {
projectId: Ref<string>
canEdit: Ref<boolean>
refresh: () => Promise<void>
}
const PROJECT_KEY: InjectionKey<ProjectContext> = Symbol('ProjectContext')
// Provider — 在 ProjectLayout 页面中调用
export function provideProjectContext(projectId: string, canEdit: boolean, refresh: () => Promise<void>) {
const ctx: ProjectContext = {
projectId: ref(projectId),
canEdit: readonly(ref(canEdit)),
refresh,
}
provide(PROJECT_KEY, ctx)
return ctx
}
// Consumer — 在任意深层子组件中调用
export function useProjectContext(): ProjectContext {
const ctx = inject(PROJECT_KEY)
if (!ctx) {
throw new Error(
'useProjectContext() must be used within <ProjectLayout>',
)
}
return ctx
}4.3 组合式组件(Compound Components)
组合式组件是一种不依赖 Slot Props 的组件组合模式:通过 provide/inject 在父子组件间共享隐式上下文,使子组件可以任意排列组合。
<!-- src/shared/ui/Tabs/index.vue — 组合式组件根 -->
<script setup lang="ts">
import type { Ref } from 'vue'
import { provide, ref, readonly } from 'vue'
interface TabsContext {
activeTab: Ref<string>
selectTab: (id: string) => void
registerTab: (id: string) => void
}
const TABS_KEY = Symbol('TabsContext') as InjectionKey<TabsContext>
const props = defineProps<{ defaultTab?: string }>()
const activeTab = ref(props.defaultTab ?? '')
const tabIds = ref<string[]>([])
provide(TABS_KEY, {
activeTab: readonly(activeTab),
selectTab: (id: string) => { activeTab.value = id },
registerTab: (id: string) => {
if (!tabIds.value.includes(id)) {
tabIds.value.push(id)
}
if (!activeTab.value) {
activeTab.value = id
}
},
})
</script>
<template>
<div class="tabs">
<slot />
</div>
</template><!-- src/shared/ui/Tabs/TabPanel.vue — 子组件 -->
<script setup lang="ts">
import { computed } from 'vue'
import { useTabsContext } from './useTabsContext'
const props = defineProps<{ id: string; label: string }>()
const { activeTab, selectTab } = useTabsContext()
const isActive = computed(() => activeTab.value === props.id)
</script>
<template>
<button
:class="['tab', { active: isActive }]"
@click="selectTab(id)"
role="tab"
:aria-selected="isActive"
>
{{ label }}
</button>
</template><!-- 使用示例 — 任意排列组合 -->
<Tabs default-tab="info">
<TabPanel id="info" label="基本信息" />
<TabPanel id="members" label="成员管理" />
<TabContent id="info">
<ProjectInfo />
</TabContent>
<TabContent id="members">
<MemberList />
</TabContent>
</Tabs>第5部分:文件组织架构
5.1 Feature-based 目录结构
┌─────────────────────────────────────────────────────────────┐
│ Feature-based 目录结构全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ src/ │
│ ├── app/ ← 应用装配层(一次性配置) │
│ │ ├── router/ ← 路由配置、守卫注册 │
│ │ ├── plugins/ ← Pinia、全局组件注册 │
│ │ └── App.vue ← 根组件 │
│ │ │
│ ├── pages/ ← 路由页面(薄层,组织 Feature) │
│ │ ├── HomePage.vue │
│ │ └── projects/ │
│ │ ├── ProjectListPage.vue │
│ │ └── ProjectDetailPage.vue │
│ │ │
│ ├── features/ ← 业务领域(高内聚) │
│ │ ├── projects/ │
│ │ │ ├── api/ ← 领域 API 封装 │
│ │ │ ├── components/ ← 领域专用组件 │
│ │ │ ├── composables/← 领域专用 Composable │
│ │ │ ├── model/ ← 类型、Schema、常量 │
│ │ │ └── index.ts ← 公共 API 导出(控制可见性) │
│ │ ├── billing/ │
│ │ └── auth/ │
│ │ │
│ ├── shared/ ← 共享模块(低耦合) │
│ │ ├── ui/ ← 通用 UI 组件 │
│ │ ├── lib/ ← 工具函数、HTTP 客户端 │
│ │ └── types/ ← 全局类型定义 │
│ │ │
│ ├── composables/ ← 全局 Composable │
│ └── main.ts │
│ │
│ 原则: │
│ • Feature 通过 index.ts 暴露公共 API,禁止深层导入 │
│ • Feature 间通过 Composable 或共享模型通信 │
│ • Page 只做路由解析 → 委托 Feature 组件 │
│ │
└─────────────────────────────────────────────────────────────┘5.2 路由懒加载与代码分割
// src/app/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
// 路由级代码分割:每个页面独立 chunk
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('@/pages/HomePage.vue'),
meta: { title: '首页' },
},
{
path: 'projects',
// 路由分组 → 共享一个异步 chunk
component: () => import('@/pages/projects/ProjectLayout.vue'),
children: [
{
path: '',
name: 'projects',
component: () => import('@/pages/projects/ProjectListPage.vue'),
},
{
path: ':id',
name: 'project-detail',
component: () => import('@/pages/projects/ProjectDetailPage.vue'),
props: true,
},
{
path: ':id/settings',
name: 'project-settings',
component: () => import('@/pages/projects/ProjectSettingsPage.vue'),
props: true,
meta: { requiredRole: 'admin' },
},
],
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
// 滚动行为:切换路由时回到顶部
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
},
})
export default router5.3 Composables 组织策略
┌─────────────────────────────────────────────────────────────┐
│ Composables 分层组织策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 层级 位置 示例 │
│ ─────────────────────────────────────────────────────── │
│ 全局 src/composables/ useEventListener │
│ (框架级抽象) useMediaQuery │
│ useDebounce │
│ ─────────────────────────────────────────────────────── │
│ 领域 features/X/ useProjectList │
│ (业务逻辑) composables/ useProjectContext │
│ useInvoiceExport │
│ ─────────────────────────────────────────────────────── │
│ 页面 pages/X/ useProjectFilters │
│ (页面级状态) (与页面同目录) useTimelineData │
│ useSidebarState │
│ ─────────────────────────────────────────────────────── │
│ 组件 components/ useFormValidation │
│ (组件内封装) (组件内部或同目录) useDropdownPosition │
│ │
│ 命名约定: │
│ • 所有 Composable 以 use 开头 │
│ • 文件名与导出函数同名(kebab-case 文件名) │
│ • 参数接受 Ref | getter | 普通值,内部统一 normalize │
│ • 返回 Ref,保持响应式连接 │
│ • 副作用有明确的 start/stop/cleanup 策略 │
│ │
└─────────────────────────────────────────────────────────────┘5.4 代码分割策略总结
┌─────────────────────────────────────────────────────────────┐
│ 代码分割策略决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的场景 推荐策略 │
│ ──────────────────────────────────────────────────────── │
│ 路由页面 → 路由级动态 import │
│ (每个 route.component) 独立 chunk,按需加载 │
│ │
│ 大型第三方库 → 手动 split 到独立 chunk │
│ (图表、编辑器、PDF) build.rollupOptions.output │
│ │
│ 条件渲染的重组件 → defineAsyncComponent │
│ (Modal、Drawer、富文本) 配合 <Suspense> 使用 │
│ │
│ 共享但体积大的模块 → 分组 chunk │
│ (多个页面共同引用的 Feature) 特殊的 import 注释分组 │
│ │
│ Vite 自动处理: │
│ • 共享依赖自动提取到 vendor chunk │
│ • 多页面共同引用的动态 import 合并到同一 chunk │
│ • CSS 按需注入(组件使用时才加载样式) │
│ │
│ 手动控制 chunk 名称(Vite): │
│ import(/* webpackChunkName: "editor" */ │
│ '@/features/editor/MonacoEditor.vue') │
│ │
└─────────────────────────────────────────────────────────────┘// src/shared/lib/lazyComponents.ts — 异步组件封装
import { defineAsyncComponent, h } from 'vue'
import GenericError from '@/shared/ui/GenericError.vue'
import GenericSkeleton from '@/shared/ui/GenericSkeleton.vue'
export function createLazyComponent(
loader: () => Promise<any>,
options?: { skeleton?: boolean; retries?: number },
) {
return defineAsyncComponent({
loader,
loadingComponent: options?.skeleton ? GenericSkeleton : undefined,
errorComponent: GenericError,
delay: 200, // 200ms 后才显示 loading
timeout: 15000, // 15s 超时
onError(error, retry, fail, attempts) {
if (attempts <= (options?.retries ?? 2)) {
retry()
} else {
fail()
}
},
})
}第6部分:架构决策与最佳实践
6.1 路由状态 vs 组件状态 vs Pinia Store
┌─────────────────────────────────────────────────────────────┐
│ 状态归属决策矩阵 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 状态类型 存放位置 判断依据 │
│ ────────────────────────────────────────────────────── │
│ 当前页码/筛选条件 URL query 需要分享链接 │
│ 当前选中资源 ID URL params 需要浏览器历史 │
│ 搜索结果列表 组件内 ref 只在一页使用 │
│ 表单输入值 组件内 reactive 提交前不共享 │
│ 用户会话信息 Pinia Store 跨页面全局使用 │
│ UI 偏好/主题 Pinia + localStorage 需要持久化 │
│ 服务端数据列表 Nuxt useAsyncData 服务端拥有 │
│ 或独立 Composable 需要缓存策略 │
│ 展开/折叠状态 组件内 ref 纯 UI 状态 │
│ │
│ 反模式警示: │
│ ✗ 将 URL 分页参数复制到 Pinia │
│ ✗ 将单个组件的表单字段放入全局 Store │
│ ✗ 将服务端数据无条件同步到 Pinia(双源问题) │
│ ✗ 用 watch 手动同步两个状态源 │
│ │
└─────────────────────────────────────────────────────────────┘6.2 组件职责分层
┌─────────────────────────────────────────────────────────────┐
│ 组件职责分层规范 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Page 层(src/pages/) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 唯一职责:路由参数解析 → 委托 Feature 组件 │ │
│ │ • 解析 route.params/query → 传给子组件 Props │ │
│ │ • 处理加载骨架、404 空状态、全局错误边界 │ │
│ │ • 页面级 <title> 设置(watch route meta) │ │
│ │ • 不包含:业务逻辑、复杂状态、直接 API 调用 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Feature 层(src/features/) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 实现一个完整的业务用例 │ │
│ │ • 组合领域 Composable + UI 组件 + API 调用 │ │
│ │ • 拥有该领域的表单验证、提交逻辑 │ │
│ │ • 通过 index.ts 控制对外可见性 │ │
│ │ • 可以有自己的子 Composable 和子组件 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ UI 层(src/shared/ui/) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 可复用交互和视觉契约 │ │
│ │ • 不包含业务逻辑(不调用 API、不访问 Store) │ │
│ │ • 通过 Props/Emits/透传与外界通信 │ │
│ │ • 支持 v-model、可访问性(aria-*)、插槽扩展 │ │
│ │ • 可独立在 Storybook / Histoire 中展示和测试 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
总结1:导航守卫是洋葱,不是链条
Vue Router 的三级守卫形成洋葱模型:全局 beforeEach 是最外层,组件 beforeRouteLeave 是最内层。守卫不是依次执行的线性链条,而是层层包裹的拦截器。理解这个模型才能正确设计鉴权流——例如,在 beforeEach 中检查全局登录态,在 beforeEnter 中检查路由级权限,在 beforeRouteLeave 中保护未保存表单。
总结2:路由参数通过 Props 解耦
useRoute() 让组件与路由强耦合——组件只能在特定路由结构下使用。使用路由配置的 props: true 或函数模式 Props,将 params/query 映射为普通组件 Props,组件即可脱离路由独立测试和复用。这是组件可测试性的关键一步。
总结3:Zod Schema = 类型 + 验证 + 错误消息
Zod 的一条 Schema 同时提供 TypeScript 类型推导和运行时验证规则。配合 @vee-validate/zod 的 toTypedSchema,你不需要手动维护"TypeScript 接口 + 验证函数 + 错误消息"三份代码。服务端仍必须独立重新校验——前端的 Zod 验证只改善用户体验,不提供安全边界。
总结4:Feature-based 目录优于技术分层
按技术分层(components/、composables/、api/ 平铺)在项目增长时会迫使开发者跨目录跳转来完成一个业务改动。Feature-based 目录将同一领域的组件、Composable、API、类型集中在一起,通过 index.ts 控制公共 API,实现高内聚低耦合。
总结5:状态放对位置比选对工具更重要
URL 参数放路由、UI 状态放组件、跨页面共享状态放 Pinia、服务端数据用 Nuxt 数据层。错误的状态归属比选错工具代价更大——将分页参数放入 Pinia 会导致后退按钮失效,将单个组件的表单状态放入全局 Store 会导致状态泄漏和难以追踪的 Bug。
章节测试
测试1:Vue Router 4 的 beforeEach 和 beforeResolve 有什么区别?在什么场景下需要使用 beforeResolve 而不是 beforeEach?
测试2:以下路由配置中,组件如何获取 projectId?
{ path: '/projects/:projectId', component: ProjectDetail, props: true }A. const route = useRoute(); route.params.projectId B. defineProps<{ projectId: string }>() C. const route = useRoute(); route.query.projectId D. inject('projectId')
测试3:用 watch 同步路由 params 到组件内 ref 存在什么隐患?正确的做法是什么?
测试4:以下代码有什么问题?
const form = useForm({ validationSchema: toTypedSchema(projectSchema) })
await form.handleSubmit(async (values) => {
await api.create(values) // 只做了前端 Zod 校验
})测试5:一个 Feature 的 index.ts 应该暴露什么?不应该暴露什么?
测试6:什么时候应该使用 provide/inject 而不是 Props 逐层传递?使用 provide/inject 需要注意哪些风险?
参考答案
测试1答案
beforeEach 在所有守卫的最外层执行,此时组件还未被解析,甚至目标组件还不确定(可能重定向)。beforeResolve 在所有组件内守卫(beforeRouteEnter 等)解析完成后、导航确认前执行——此时目标组件已经确定。
使用 beforeResolve 的场景:需要在确认"用户确实会进入这个路由"之后才执行的逻辑,例如为用户确实要访问的页面预取关键数据。如果 beforeEach 中已经做了重定向,beforeResolve 中的数据获取就不会被浪费。
测试2答案
答案:B。props: true 将 params.projectId 自动映射为组件的 projectId prop。组件使用 defineProps<{ projectId: string }>() 接收,这样就解耦了对 Vue Router 的依赖。A 也能工作,但造成了组件与路由的强耦合。
测试3答案
存在两个隐患:
- 竞态条件:用户快速切换路由时,旧的异步请求可能在新请求之后返回,导致数据错乱。需要用
onCleanup或AbortController取消旧请求。 - 冗余状态:手动将路由参数同步到 ref 会创建"双源"问题——路由和 ref 都是状态源,当它们不一致时难以调试。
正确做法:路由参数直接使用 props: true 传递给组件作为 Props,或使用 computed 从 useRoute() 派生,避免手动同步。数据获取用 watchEffect + onCleanup 或专门的请求库处理竞态。
测试4答案
问题:只做了前端校验,没有处理服务端可能返回的校验错误。前端 Zod 校验只改善用户体验,不能作为安全边界。
改进:
await form.handleSubmit(async (values) => {
try {
await api.create(values)
} catch (err) {
if (err instanceof ValidationError) {
// 映射服务端字段级错误
for (const [field, messages] of Object.entries(err.fields)) {
form.setFieldError(field, messages.join(', '))
}
}
throw err // 让 VeeValidate 知道提交失败
}
})测试5答案
应该暴露:
- 页面级组件(Feature 的主入口组件)
- 公共 Composable(该领域的状态管理入口)
- 公共类型定义(其他 Feature 可能需要引用的类型)
- API 函数(如果其他 Feature 需要调用)
不应该暴露:
- 内部子组件(只能通过 Feature 主组件使用的私有组件)
- 内部 Composable 实现细节
- 内部常量、工具函数
- API 层的传输格式细节
原则:index.ts 是 Feature 的"合同"。暴露出去的 API 应保持向后兼容;内部实现可以自由重构。
测试6答案
使用 provide/inject 的场景:
- 深层组件树通信(3 层以上),避免 Props drilling
- 向整个子树注入上下文(主题、表单上下文、权限信息)
- 组合式组件(Tabs/TabPanel、Accordion/AccordionItem)
风险与应对:
- 数据流不透明:难以追踪数据来源 → 使用 Symbol 作为注入键 + 封装 Composable
- 响应式丢失:注入的值如果不加处理可能丢失响应式 → Provider 中用
readonly()包裹暴露的 ref - 缺少类型安全:inject 返回值默认是
unknown→ 使用InjectionKey<T>泛型 - Provider 未挂载:在 Provider 外调用 inject 返回 undefined → Composable 中做防御性检查并抛出明确错误
相关笔记
- [[01-vue-components-and-templates]] — SFC 与组件基础
- [[02-reactivity-and-composition-api]] — 响应式系统与 Composable
- [[04-pinia-state-management]] — Pinia 状态管理完整指南
- [[05-nuxt-routing-and-rendering]] — Nuxt 路由与渲染模式
- [[06-nuxt-data-server-cache]] — Nuxt 数据层与缓存策略
- [[../02-react-and-nextjs/00-overview]] — React 生态对比参考
下一步学习
- [ ] 阅读 Pinia 状态管理 — 理解全局状态与路由状态的分工
- [ ] 在真实项目中实践:用 Zod 定义一套完整的表单 Schema,接入 VeeValidate
- [ ] 选择一个现有项目,将其目录结构重构为 Feature-based 组织
- [ ] 实现一个完整的登录鉴权守卫,包含 token 刷新和角色权限校验
学习状态:🟡 开始学习