Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

Vue 生态 / Vue Ecosystem

1. Vue 生态知识体系 / Vue Ecosystem Knowledge System

2. Vue 组件、模板与编译原理 / Vue Components, Templates, and Compilation

3. Vue 3 响应式系统与 Composition API / Vue 3 Reactivity System and Composition API

4. Vue Router、表单与组件架构 / Vue Router, Forms, and Component Architecture

5. Pinia 状态管理与持久化 / Pinia State Management and Persistence

6. Nuxt 路由、渲染与项目结构 / Nuxt Routing, Rendering, and Project Structure

7. Nuxt 数据获取、Server API 与缓存 / Nuxt Data Fetching, Server APIs, and Caching

8. Vue 测试、性能与生产工程 / Vue Testing, Performance, and Production Engineering

本页目录

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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

1.2 全局守卫实战:登录鉴权 ​

ts
// 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
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
ts
// 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
2
3
4
5
6
7
8
9
10
11
12
13
14

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

1.4 组件内守卫:未保存表单保护 ​

vue
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

第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 ?? '',                       │   │
│  │ })                                                   │   │
│  │ // 可在映射阶段做类型转换和默认值处理                 │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

2.2 动态路由数据获取模式 ​

ts
// 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 }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

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)          │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

第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 接口和验证函数                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

3.2 完整表单实战 ​

ts
// 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

3.3 自定义组件验证 ​

vue
<!-- 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}`">
          &times;
        </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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

3.4 服务端错误与表单级处理 ​

vue
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

第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> 中访问透传属性               │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

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() 包裹暴露的响应式对象                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
ts
// 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

4.3 组合式组件(Compound Components) ​

组合式组件是一种不依赖 Slot Props 的组件组合模式:通过 provide/inject 在父子组件间共享隐式上下文,使子组件可以任意排列组合。

vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
vue
<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vue
<!-- 使用示例 — 任意排列组合 -->
<Tabs default-tab="info">
  <TabPanel id="info" label="基本信息" />
  <TabPanel id="members" label="成员管理" />
  <TabContent id="info">
    <ProjectInfo />
  </TabContent>
  <TabContent id="members">
    <MemberList />
  </TabContent>
</Tabs>
1
2
3
4
5
6
7
8
9
10
11

第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 组件                     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

5.2 路由懒加载与代码分割 ​

ts
// 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 router
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

5.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 策略                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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')                │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ts
// 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()
      }
    },
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

第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 手动同步两个状态源                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

核心总结 ​

总结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? ​

ts
{ path: '/projects/:projectId', component: ProjectDetail, props: true }
1

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:以下代码有什么问题? ​

ts
const form = useForm({ validationSchema: toTypedSchema(projectSchema) })
await form.handleSubmit(async (values) => {
  await api.create(values) // 只做了前端 Zod 校验
})
1
2
3
4

测试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答案 ​

存在两个隐患:

  1. 竞态条件:用户快速切换路由时,旧的异步请求可能在新请求之后返回,导致数据错乱。需要用 onCleanup 或 AbortController 取消旧请求。
  2. 冗余状态:手动将路由参数同步到 ref 会创建"双源"问题——路由和 ref 都是状态源,当它们不一致时难以调试。

正确做法:路由参数直接使用 props: true 传递给组件作为 Props,或使用 computed 从 useRoute() 派生,避免手动同步。数据获取用 watchEffect + onCleanup 或专门的请求库处理竞态。

测试4答案 ​

问题:只做了前端校验,没有处理服务端可能返回的校验错误。前端 Zod 校验只改善用户体验,不能作为安全边界。

改进:

ts
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 知道提交失败
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13

测试5答案 ​

应该暴露:

  • 页面级组件(Feature 的主入口组件)
  • 公共 Composable(该领域的状态管理入口)
  • 公共类型定义(其他 Feature 可能需要引用的类型)
  • API 函数(如果其他 Feature 需要调用)

不应该暴露:

  • 内部子组件(只能通过 Feature 主组件使用的私有组件)
  • 内部 Composable 实现细节
  • 内部常量、工具函数
  • API 层的传输格式细节

原则:index.ts 是 Feature 的"合同"。暴露出去的 API 应保持向后兼容;内部实现可以自由重构。

测试6答案 ​

使用 provide/inject 的场景:

  • 深层组件树通信(3 层以上),避免 Props drilling
  • 向整个子树注入上下文(主题、表单上下文、权限信息)
  • 组合式组件(Tabs/TabPanel、Accordion/AccordionItem)

风险与应对:

  1. 数据流不透明:难以追踪数据来源 → 使用 Symbol 作为注入键 + 封装 Composable
  2. 响应式丢失:注入的值如果不加处理可能丢失响应式 → Provider 中用 readonly() 包裹暴露的 ref
  3. 缺少类型安全:inject 返回值默认是 unknown → 使用 InjectionKey<T> 泛型
  4. 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 刷新和角色权限校验

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇3. Vue 3 响应式系统与 Composition API / Vue 3 Reactivity System and Composition API
下一篇5. Pinia 状态管理与持久化 / Pinia State Management and Persistence

持续记录,持续成长

Copyright © Tidenflow