Vue 3 + Nginx + Docker 全面实战指南

最后更新于:2026年3月14日 晚上

Vue 3 完整学习指南


目录

  1. [环境搭建](# 环境搭建)
  2. [项目结构](# 项目结构)
  3. [Vue 3 核心语法](#Vue 3 核心语法)
  4. [Vue Router 路由](# Vue Router 路由)
  5. [Pinia 状态管理](#Pinia 状态管理)
  6. [Axios 网络请求](# Axios 网络请求)
  7. [打包工具对比](# 打包工具对比)
  8. [Nginx 部署](# Nginx 部署)
  9. [Docker 容器化](#Docker 容器化)
  10. 综合实战案例

1. 环境搭建

1.1 安装 Node.js

1
2
3
4
5
6
7
8
9
10
11
12
# 推荐使用 nvm 管理 Node 版本
# macOS / Linux
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# 安装 Node.js (推荐 18+)
# nvm 版本控制,可以同时安装多个node版本
nvm install 18
nvm use 18

# 验证
node -v # v18.x.x
npm -v # 9.x.x

1.2 创建 Vue 3 项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# ========== 方式一:官方推荐 create-vue (基于Vite) ==========
npm create vue@latest

# 交互式选择:
# ✔ Project name: my-vue-app
# ✔ Add TypeScript? Yes
# ✔ Add JSX Support? No
# ✔ Add Vue Router? Yes
# ✔ Add Pinia? Yes
# ✔ Add Vitest? No
# ✔ Add ESLint? Yes
# ✔ Add Prettier? Yes

cd my-vue-app
npm install
npm run dev

# ========== 方式二:Vite 直接创建 ==========
npm create vite@latest my-vue-app -- --template vue
# 或 TypeScript 版本
npm create vite@latest my-vue-app -- --template vue-ts

1.3 常用 VSCode 插件

1
2
3
4
5
- Vue - Official (原 Volar)      → Vue 3 语法支持
- TypeScript Vue Plugin → TS 支持
- ESLint → 代码规范
- Prettier → 代码格式化
- Auto Rename Tag → 自动重命名标签

2. 项目结构

2.1 标准项目目录

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
my-vue-app/
├── public/ # 静态资源(不会被打包处理)
│ └── favicon.ico
├── src/
│ ├── api/ # API 请求模块
│ │ ├── index.ts # axios 实例与拦截器
│ │ ├── user.ts # 用户相关接口
│ │ └── product.ts # 商品相关接口
│ ├── assets/ # 静态资源(会被打包处理)
│ │ ├── images/
│ │ ├── styles/
│ │ │ ├── reset.css # 重置样式
│ │ │ ├── variables.scss # SCSS 变量
│ │ │ └── global.scss # 全局样式
│ │ └── fonts/
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用基础组件
│ │ │ ├── AppHeader.vue
│ │ │ ├── AppFooter.vue
│ │ │ └── AppSidebar.vue
│ │ └── business/ # 业务组件
│ │ ├── UserCard.vue
│ │ └── ProductList.vue
│ ├── composables/ # 组合式函数(Hooks)
│ │ ├── useAuth.ts
│ │ ├── useLoading.ts
│ │ └── usePagination.ts
│ ├── directives/ # 自定义指令
│ │ └── vPermission.ts
│ ├── layouts/ # 布局组件
│ │ ├── DefaultLayout.vue
│ │ └── BlankLayout.vue
│ ├── plugins/ # 插件
│ │ └── elementPlus.ts
│ ├── router/ # 路由配置
│ │ ├── index.ts
│ │ └── modules/ # 路由模块拆分
│ │ ├── user.ts
│ │ └── product.ts
│ ├── stores/ # Pinia 状态管理
│ │ ├── index.ts
│ │ ├── user.ts
│ │ └── app.ts
│ ├── types/ # TypeScript 类型定义
│ │ ├── api.d.ts
│ │ ├── store.d.ts
│ │ └── global.d.ts
│ ├── utils/ # 工具函数
│ │ ├── auth.ts # Token 管理
│ │ ├── storage.ts # 本地存储封装
│ │ ├── validate.ts # 校验函数
│ │ └── format.ts # 格式化函数
│ ├── views/ # 页面组件
│ │ ├── home/
│ │ │ └── index.vue
│ │ ├── login/
│ │ │ └── index.vue
│ │ ├── user/
│ │ │ ├── index.vue
│ │ │ └── detail.vue
│ │ └── 404.vue
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── .env # 通用环境变量
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .eslintrc.cjs # ESLint 配置
├── .prettierrc.json # Prettier 配置
├── index.html # HTML 入口
├── package.json
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
├── Dockerfile # Docker 配置
├── nginx.conf # Nginx 配置
└── docker-compose.yml # Docker Compose

2.2 入口文件

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
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

// 全局样式
import './assets/styles/reset.css'
import './assets/styles/global.scss'

const app = createApp(App)

// 注册插件
app.use(createPinia()) // 状态管理
app.use(router) // 路由

// 全局属性(慎用,推荐 provide/inject)
app.config.globalProperties.$appName = 'My App'

// 全局错误处理
app.config.errorHandler = (err, instance, info) => {
console.error('全局错误:', err)
console.error('错误信息:', info)
}

app.mount('#app')

2.3 环境变量配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# .env (所有环境共享)
VITE_APP_TITLE=我的应用

# .env.development (开发环境)
VITE_APP_BASE_API=http://localhost:3000/api
VITE_APP_ENV=development

# .env.production (生产环境)
VITE_APP_BASE_API=https://api.example.com
VITE_APP_ENV=production

# .env.staging (预发布环境)
VITE_APP_BASE_API=https://staging-api.example.com
VITE_APP_ENV=staging
1
2
3
4
5
6
// 在代码中使用环境变量
console.log(import.meta.env.VITE_APP_BASE_API)
console.log(import.meta.env.VITE_APP_TITLE)
console.log(import.meta.env.MODE) // 'development' | 'production'
console.log(import.meta.env.DEV) // true/false
console.log(import.meta.env.PROD) // true/false

TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// vite.config.ts 中使用环境变量
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())

return {
server: {
proxy: {
'/api': {
target: env.VITE_APP_BASE_API,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
})

3. Vue 3 核心语法

3.1 组合式 API vs 选项式 API

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
79
80
<!-- ============ 选项式 API(Vue 2 风格,Vue 3 仍支持)============ -->
<script>
export default {
data() {
return {
count: 0,
message: 'Hello'
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
},
watch: {
count(newVal, oldVal) {
console.log(`count: ${oldVal} -> ${newVal}`)
}
},
mounted() {
console.log('组件挂载完成')
}
}
</script>

<!-- ============ 组合式 API + setup 函数 ============ -->
<script>
import { ref, computed, watch, onMounted } from 'vue'

export default {
setup() {
const count = ref(0)
const message = ref('Hello')

const doubleCount = computed(() => count.value * 2)

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

watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} -> ${newVal}`)
})

onMounted(() => {
console.log('组件挂载完成')
})

return { count, message, doubleCount, increment }
}
}
</script>

<!-- ============ 组合式 API + <script setup>(推荐)============ -->
<script setup>
import { ref, computed, watch, onMounted } from 'vue'

// 所有顶层变量和函数自动暴露给模板,无需 return
const count = ref(0)
const message = ref('Hello')

const doubleCount = computed(() => count.value * 2)

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

watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} -> ${newVal}`)
})

onMounted(() => {
console.log('组件挂载完成')
})
</script>

3.2 响应式系统

ref — 基本类型 & 任意类型

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
<script setup>
import { ref, isRef, unref, toRef, toRefs } from 'vue'

// ========== ref:用于任意类型 ==========
const count = ref(0) // Ref<number>
const name = ref('张三') // Ref<string>
const isShow = ref(true) // Ref<boolean>
const user = ref({ // Ref<{name: string, age: number}>
name: '张三',
age: 25
})
const list = ref([1, 2, 3]) // Ref<number[]>

// JS 中通过 .value 访问和修改
count.value++
user.value.name = '李四'
list.value.push(4)

// 模板中自动解包,不需要 .value
// <template> {{ count }} {{ user.name }} </template>

// ========== 工具函数 ==========
console.log(isRef(count)) // true —— 检测是否是 ref
console.log(unref(count)) // 0 —— 如果是 ref 返回 .value,否则返回自身

// ========== shallowRef:浅层响应式 ==========
import { shallowRef, triggerRef } from 'vue'

const shallowState = shallowRef({
nested: { count: 0 }
})

// 这不会触发更新(深层属性不追踪)
shallowState.value.nested.count++

// 需要替换整个 .value 才会触发
shallowState.value = { nested: { count: 1 } }

// 或者手动触发
triggerRef(shallowState)
</script>

reactive — 对象类型

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
<script setup>
import { reactive, toRefs, toRef, readonly } from 'vue'

// ========== reactive:只用于对象 ==========
const state = reactive({
count: 0,
user: {
name: '张三',
age: 25,
address: {
city: '北京'
}
},
list: [1, 2, 3]
})

// 直接访问,不需要 .value
state.count++
state.user.name = '李四'
state.user.address.city = '上海' // 深层响应式
state.list.push(4)

// ⚠️ 注意事项:reactive 的限制
// 1. 不能整体替换(会丢失响应式)
// ❌ state = { count: 1, user: { name: '李四' } }
// ✅ Object.assign(state, { count: 1 })

// 2. 解构会丢失响应式
// ❌ const { count } = state // count 是普通变量
// ✅ const { count } = toRefs(state) // count 是 Ref

// ========== toRefs / toRef ==========
const { count, user } = toRefs(state)
// count 是 Ref<number>,修改 count.value 会同步修改 state.count

const name = toRef(state.user, 'name')
// name 是 Ref<string>,指向 state.user.name

// ========== readonly ==========
const readonlyState = readonly(state)
// readonlyState.count++ // ❌ 警告,无法修改
</script>

ref vs reactive 选择指南

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
<script setup>
// ==================== 推荐用法 ====================

// ✅ 基本类型 → 用 ref
const count = ref(0)
const title = ref('标题')
const visible = ref(false)

// ✅ 表单对象 → 用 reactive(字段固定,不需要整体替换)
const form = reactive({
username: '',
password: '',
remember: false
})

// ✅ 需要整体替换 → 用 ref
const userInfo = ref(null) // 从接口拿到后直接赋值
// userInfo.value = await fetchUser()

const tableData = ref([]) // 列表数据
// tableData.value = await fetchList()

// ✅ 复杂嵌套但字段固定 → reactive
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0
})

// ==================== 总结 ====================
// ref → 更通用,适合所有场景,尤其需要替换整个值时
// reactive → 适合固定结构的对象(表单、配置),使用时不需要 .value
// 团队中统一用 ref 也完全没问题
</script>

3.3 计算属性 computed

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
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')
const price = ref(100)
const quantity = ref(3)

// ========== 只读计算属性 ==========
const fullName = computed(() => `${firstName.value}${lastName.value}`)
const total = computed(() => price.value * quantity.value)

// ========== 可写计算属性 ==========
const fullNameWritable = computed({
get: () => `${firstName.value}${lastName.value}`,
set: (val) => {
firstName.value = val.charAt(0)
lastName.value = val.slice(1)
}
})

fullNameWritable.value = '李四'
// firstName.value → '李'
// lastName.value → '四'

// ========== 计算属性 vs 方法 ==========
// computed:有缓存,依赖不变则不重新计算
// method:每次调用都执行
const expensiveComputed = computed(() => {
console.log('computed 执行了') // 依赖不变时不会再打印
return price.value * quantity.value
})

const expensiveMethod = () => {
console.log('method 执行了') // 每次调用都打印
return price.value * quantity.value
}
</script>

<template>
<!-- computed 多次使用只计算一次 -->
<p>{{ expensiveComputed }}</p>
<p>{{ expensiveComputed }}</p>

<!-- method 多次使用计算多次 -->
<p>{{ expensiveMethod() }}</p>
<p>{{ expensiveMethod() }}</p>
</template>

3.4 侦听器 watch / watchEffect

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<script setup>
import { ref, reactive, watch, watchEffect, watchPostEffect } from 'vue'

const count = ref(0)
const name = ref('张三')
const user = reactive({ name: '张三', age: 25, address: { city: '北京' } })

// ========== watch:侦听 ref ==========
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} -> ${newVal}`)
})

// ========== watch:侦听 reactive 对象的某个属性 ==========
// 用 getter 函数
watch(
() => user.name,
(newVal, oldVal) => {
console.log(`user.name: ${oldVal} -> ${newVal}`)
}
)

// ========== watch:侦听整个 reactive 对象 ==========
// 自动深度侦听,且 oldVal === newVal(同一引用)
watch(
() => user,
(newVal) => {
console.log('user changed:', newVal)
},
{ deep: true }
)

// ========== watch:侦听多个源 ==========
watch(
[count, name, () => user.age],
([newCount, newName, newAge], [oldCount, oldName, oldAge]) => {
console.log('多个值变化了')
}
)

// ========== watch 配置选项 ==========
watch(
count,
(newVal, oldVal) => {
console.log(newVal)
},
{
immediate: true, // 立即执行一次(创建时就执行)
deep: true, // 深度侦听(ref 包装的对象需要)
flush: 'post', // 'pre'(默认) | 'post'(DOM更新后) | 'sync'(同步)
once: true, // Vue 3.4+ 只触发一次
}
)

// ========== watchEffect:自动收集依赖 ==========
// 立即执行,自动追踪回调中用到的响应式变量
const stop = watchEffect(() => {
// 用到了 count 和 name,自动侦听它们
console.log(`count=${count.value}, name=${name.value}`)
})

// 停止侦听
stop()

// ========== watchEffect 清理副作用 ==========
watchEffect((onCleanup) => {
const timer = setInterval(() => {
console.log(count.value)
}, 1000)

// 在下次执行前或组件卸载时调用
onCleanup(() => {
clearInterval(timer)
})
})

// ========== watchPostEffect(DOM 更新后执行)==========
watchPostEffect(() => {
// 这里可以安全访问更新后的 DOM
console.log(document.querySelector('#my-el')?.textContent)
})

// ========== watch vs watchEffect 对比 ==========
// watch:
// - 明确指定侦听源
// - 可以获取旧值
// - 默认懒执行(不加 immediate 不会立即执行)
// - 适合:需要知道什么变了,条件性执行逻辑
//
// watchEffect:
// - 自动收集依赖
// - 不能获取旧值
// - 立即执行
// - 适合:副作用与响应式状态紧密关联
</script>

3.5 生命周期

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
79
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onActivated,
onDeactivated,
onErrorCaptured
} from 'vue'

// ========== 生命周期对照表 ==========
// 选项式 API → 组合式 API
// beforeCreate → 不需要(setup 本身就是)
// created → 不需要(setup 本身就是)
// beforeMount → onBeforeMount
// mounted → onMounted
// beforeUpdate → onBeforeUpdate
// updated → onUpdated
// beforeUnmount → onBeforeUnmount(Vue2 是 beforeDestroy)
// unmounted → onUnmounted(Vue2 是 destroyed)
// activated → onActivated(keep-alive)
// deactivated → onDeactivated(keep-alive)
// errorCaptured → onErrorCaptured

// ========== 使用示例 ==========

// setup 阶段 = beforeCreate + created
console.log('setup: 组件初始化,此时可以执行同步逻辑')

onBeforeMount(() => {
console.log('DOM 挂载前,模板已编译')
})

onMounted(() => {
console.log('DOM 已挂载,可以访问 DOM 元素')
// 常用于:发起请求、初始化第三方库、添加事件监听
})

onBeforeUpdate(() => {
console.log('数据变化,DOM 更新前')
})

onUpdated(() => {
console.log('DOM 已更新')
// ⚠️ 避免在此修改状态,可能导致死循环
})

onBeforeUnmount(() => {
console.log('组件卸载前,实例仍可用')
// 常用于:清理定时器、取消请求、移除事件监听
})

onUnmounted(() => {
console.log('组件已卸载')
})

// keep-alive 专用
onActivated(() => {
console.log('被 keep-alive 缓存的组件激活')
})

onDeactivated(() => {
console.log('被 keep-alive 缓存的组件停用')
})

// 错误捕获
onErrorCaptured((err, instance, info) => {
console.error('捕获子组件错误:', err, info)
return false // 阻止错误继续传播
})

// ========== 同一个钩子可以注册多次 ==========
onMounted(() => console.log('mounted 回调 1'))
onMounted(() => console.log('mounted 回调 2'))
// 按注册顺序依次执行
</script>

3.6 模板语法 & 指令

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
<template>
<!-- ========== 文本插值 ========== -->
<p>{{ message }}</p>
<p>{{ count + 1 }}</p>
<p>{{ ok ? 'YES' : 'NO' }}</p>
<p>{{ message.split('').reverse().join('') }}</p>

<!-- 原始 HTML(⚠️ 注意 XSS) -->
<p v-html="rawHtml"></p>

<!-- ========== 属性绑定 v-bind ========== -->
<img :src="imageUrl" :alt="imageAlt" />
<div :class="className"></div>
<div :style="styleObj"></div>

<!-- 动态属性名 -->
<div :[attrName]="attrValue"></div>

<!-- 绑定多个属性 -->
<div v-bind="objectOfAttrs"></div>
<!-- objectOfAttrs = { id: 'container', class: 'wrapper', 'data-name': 'test' } -->

<!-- ========== Class 绑定 ========== -->
<!-- 对象语法 -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>

<!-- 数组语法 -->
<div :class="[activeClass, errorClass]"></div>

<!-- 混合 -->
<div :class="[{ active: isActive }, errorClass]"></div>

<!-- ========== Style 绑定 ========== -->
<!-- 对象语法(驼峰或短横线) -->
<div :style="{ color: textColor, fontSize: fontSize + 'px' }"></div>
<div :style="{ 'font-size': fontSize + 'px' }"></div>

<!-- 数组语法 -->
<div :style="[baseStyles, overrideStyles]"></div>

<!-- ========== 条件渲染 ========== -->
<!-- v-if / v-else-if / v-else:真正的条件渲染,切换时销毁/创建 -->
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else>Other</div>

<!-- v-show:CSS display 切换,始终渲染,适合频繁切换 -->
<div v-show="isVisible">我只是被隐藏了</div>

<!-- template 上使用 v-if(不会渲染额外 DOM) -->
<template v-if="showGroup">
<h1>标题</h1>
<p>段落</p>
</template>

<!-- ========== 列表渲染 ========== -->
<!-- 数组 -->
<ul>
<li v-for="(item, index) in list" :key="item.id">
{{ index }} - {{ item.name }}
</li>
</ul>

<!-- 对象 -->
<div v-for="(value, key, index) in userObj" :key="key">
{{ index }}. {{ key }}: {{ value }}
</div>

<!-- 数字范围 -->
<span v-for="n in 10" :key="n">{{ n }}</span>

<!-- v-for + v-if(⚠️ 不要在同一元素上同时使用) -->
<!-- ❌ 错误 -->
<!-- <li v-for="item in list" v-if="item.active">{{ item.name }}</li> -->
<!-- ✅ 正确:用 template 包裹 -->
<template v-for="item in list" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
<!-- ✅ 或用 computed 过滤 -->

<!-- ========== 事件处理 ========== -->
<button @click="increment">+1</button>
<button @click="count++">内联+1</button>
<button @click="addItem('hello', $event)">带参数</button>

<!-- 事件修饰符 -->
<form @submit.prevent="onSubmit">阻止默认行为</form>
<a @click.stop="doThis">阻止冒泡</a>
<div @click.self="doThat">只在元素自身触发</div>
<button @click.once="doOnce">只触发一次</button>
<div @scroll.passive="onScroll">提升滚动性能</div>
<div @click.stop.prevent="doAll">可以链式使用</div>

<!-- 按键修饰符 -->
<input @keyup.enter="submit" />
<input @keyup.esc="cancel" />
<input @keyup.tab="onTab" />
<input @keyup.delete="onDelete" />
<input @keyup.ctrl.enter="submitCtrlEnter" />

<!-- ========== 表单双向绑定 v-model ========== -->
<input v-model="text" />
<input v-model.lazy="text" /> <!-- 失焦时更新 -->
<input v-model.number="age" /> <!-- 转为数字 -->
<input v-model.trim="name" /> <!-- 去除首尾空格 -->

<textarea v-model="description"></textarea>

<input type="checkbox" v-model="checked" />
<input type="checkbox" v-model="checkedNames" value="Jack" />
<input type="checkbox" v-model="checkedNames" value="John" />

<input type="radio" v-model="picked" value="one" />
<input type="radio" v-model="picked" value="two" />

<select v-model="selected">
<option disabled value="">请选择</option>
<option>A</option>
<option>B</option>
</select>

<!-- 多选 -->
<select v-model="multiSelected" multiple>
<option>A</option>
<option>B</option>
<option>C</option>
</select>
</template>

<script setup>
import { ref, reactive } from 'vue'

const message = ref('Hello Vue 3')
const count = ref(0)
const isActive = ref(true)
const hasError = ref(false)
const type = ref('A')
const isVisible = ref(true)
const text = ref('')
const checked = ref(false)
const checkedNames = ref([])
const picked = ref('one')
const selected = ref('')
const multiSelected = ref([])

const list = ref([
{ id: 1, name: '苹果', active: true },
{ id: 2, name: '香蕉', active: false },
{ id: 3, name: '橙子', active: true }
])

const objectOfAttrs = {
id: 'container',
class: 'wrapper'
}

const increment = () => count.value++

const addItem = (msg, event) => {
console.log(msg, event)
}
</script>

3.7 组件通信

Props & Emits

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<!-- ========== 父组件 Parent.vue ========== -->
<template>
<ChildComponent
:title="pageTitle"
:count="count"
:user="userInfo"
:list="items"
is-published
@update="handleUpdate"
@delete="handleDelete"
/>
</template>

<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'

const pageTitle = ref('标题')
const count = ref(0)
const userInfo = ref({ name: '张三', age: 25 })
const items = ref([1, 2, 3])

const handleUpdate = (newTitle) => {
pageTitle.value = newTitle
}

const handleDelete = (id) => {
console.log('删除:', id)
}
</script>

<!-- ========== 子组件 ChildComponent.vue ========== -->
<template>
<div>
<h1>{{ title }}</h1>
<p>{{ count }}</p>
<p>{{ user.name }}</p>
<button @click="updateTitle">修改标题</button>
<button @click="onDelete(123)">删除</button>
</div>
</template>

<script setup>
// ========== defineProps ==========
// 方式一:运行时声明
const props = defineProps({
title: {
type: String,
required: true,
default: '默认标题'
},
count: {
type: Number,
default: 0
},
user: {
type: Object,
default: () => ({ name: '', age: 0 })
},
list: {
type: Array,
default: () => []
},
isPublished: Boolean // 简写
})

// 方式二:TypeScript 类型声明(推荐)
// interface Props {
// title: string
// count?: number
// user?: { name: string; age: number }
// list?: number[]
// isPublished?: boolean
// }
// const props = withDefaults(defineProps<Props>(), {
// count: 0,
// user: () => ({ name: '', age: 0 }),
// list: () => [],
// isPublished: false
// })

// 使用 props
console.log(props.title)

// ⚠️ props 是只读的
// props.title = '新标题' // ❌ 报错

// ========== defineEmits ==========
// 方式一:数组声明
const emit = defineEmits(['update', 'delete'])

// 方式二:对象声明(带验证)
// const emit = defineEmits({
// update: (title) => typeof title === 'string',
// delete: (id) => typeof id === 'number'
// })

// 方式三:TypeScript 声明(推荐)
// const emit = defineEmits<{
// update: [title: string]
// delete: [id: number]
// }>()

const updateTitle = () => {
emit('update', '新标题')
}

const onDelete = (id) => {
emit('delete', id)
}
</script>

v-model 组件双向绑定

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
<!-- ========== 父组件 ========== -->
<template>
<!-- 单个 v-model -->
<CustomInput v-model="searchText" />
<!-- 等价于 <CustomInput :modelValue="searchText" @update:modelValue="val => searchText = val" /> -->

<!-- 多个 v-model -->
<UserForm
v-model:name="userName"
v-model:age="userAge"
/>

<!-- 带修饰符 -->
<CustomInput v-model.capitalize="text" />
</template>

<script setup>
import { ref } from 'vue'
const searchText = ref('')
const userName = ref('张三')
const userAge = ref(25)
const text = ref('')
</script>

<!-- ========== 子组件 CustomInput.vue ========== -->
<template>
<input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

<script setup>
defineProps({
modelValue: String
})
defineEmits(['update:modelValue'])
</script>

<!-- ========== 子组件 UserForm.vue (多个 v-model) ========== -->
<template>
<input :value="name" @input="$emit('update:name', $event.target.value)" />
<input :value="age" type="number" @input="$emit('update:age', Number($event.target.value))" />
</template>

<script setup>
defineProps({
name: String,
age: Number
})
defineEmits(['update:name', 'update:age'])
</script>

<!-- ========== Vue 3.4+ defineModel 简化写法 ========== -->
<!-- CustomInput.vue -->
<template>
<input v-model="model" />
</template>

<script setup>
const model = defineModel() // 自动生成 prop + emit
// 多个:
// const name = defineModel('name')
// const age = defineModel('age', { type: Number, default: 0 })
</script>

provide / inject 跨层级通信

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
<!-- ========== 祖先组件 ========== -->
<script setup>
import { provide, ref, readonly } from 'vue'

const theme = ref('dark')
const userInfo = ref({ name: '张三', role: 'admin' })

// 提供响应式数据
provide('theme', theme)

// 提供只读数据(防止子组件直接修改)
provide('userInfo', readonly(userInfo))

// 提供方法(让子组件通过方法修改数据)
provide('updateTheme', (newTheme) => {
theme.value = newTheme
})

// 使用 Symbol 作为 key(避免命名冲突)
// export const THEME_KEY = Symbol('theme')
// provide(THEME_KEY, theme)
</script>

<!-- ========== 后代组件(任意深度)========== -->
<script setup>
import { inject } from 'vue'

// 注入
const theme = inject('theme') // 可能 undefined
const theme2 = inject('theme', 'light') // 带默认值
const userInfo = inject('userInfo')
const updateTheme = inject('updateTheme')

// TypeScript 中
// const theme = inject<Ref<string>>('theme')
// const theme = inject<string>('theme', 'light') // 有默认值时不需要 ?

// 使用
console.log(theme.value) // 'dark'
updateTheme('light') // 通过方法修改祖先的数据
</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
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<!-- ========== defineExpose 暴露子组件方法/属性 ========== -->
<!-- 子组件 ChildComponent.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)
const childMethod = () => {
console.log('子组件方法被调用')
}

// <script setup> 中组件默认是封闭的
// 必须通过 defineExpose 暴露
defineExpose({
count,
childMethod
})
</script>

<!-- 父组件 -->
<template>
<ChildComponent ref="childRef" />
<button @click="callChild">调用子组件</button>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref(null)

onMounted(() => {
console.log(childRef.value.count) // 访问子组件数据
childRef.value.childMethod() // 调用子组件方法
})

const callChild = () => {
childRef.value?.childMethod()
}
</script>

<!-- ========== attrs 透传属性 ========== -->
<!-- 子组件 -->
<script setup>
import { useAttrs } from 'vue'

const attrs = useAttrs()
// attrs 包含父组件传递的所有非 prop、非 emit 属性
console.log(attrs.class) // 外部传的 class
console.log(attrs.style) // 外部传的 style
console.log(attrs['data-id']) // 自定义属性
</script>

<!-- ========== Mitt 事件总线(第三方替代 Vue2 的 EventBus)========== -->
<!-- npm install mitt -->

<!-- utils/eventBus.ts -->
<script>
import mitt from 'mitt'

type Events = {
'user-login': { name: string; token: string }
'cart-update': number
}

export const emitter = mitt<Events>()
</script>

<!-- 组件 A:发送事件 -->
<script setup>
import { emitter } from '@/utils/eventBus'

const login = () => {
emitter.emit('user-login', { name: '张三', token: 'abc123' })
}
</script>

<!-- 组件 B:监听事件 -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { emitter } from '@/utils/eventBus'

const handleLogin = (data) => {
console.log('用户登录:', data.name)
}

onMounted(() => {
emitter.on('user-login', handleLogin)
})

onUnmounted(() => {
emitter.off('user-login', handleLogin) // 记得移除!
})
</script>

3.8 插槽 Slots

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<!-- ========== 基础插槽 ========== -->
<!-- 子组件 Card.vue -->
<template>
<div class="card">
<slot>默认内容(父组件没传内容时显示)</slot>
</div>
</template>

<!-- 父组件 -->
<template>
<Card>
<p>这段内容会替换默认插槽</p>
</Card>
<Card /> <!-- 显示"默认内容" -->
</template>

<!-- ========== 具名插槽 ========== -->
<!-- 子组件 Layout.vue -->
<template>
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> <!-- 默认插槽,等价于 name="default" -->
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>

<!-- 父组件 -->
<template>
<Layout>
<template #header>
<h1>网站标题</h1>
</template>

<!-- 默认插槽内容 -->
<p>主要内容</p>

<template #footer>
<p>版权信息</p>
</template>
</Layout>
</template>

<!-- ========== 作用域插槽(子向父传数据)========== -->
<!-- 子组件 DataList.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item" :index="items.indexOf(item)">
<!-- 默认渲染 -->
{{ item.name }}
</slot>
</li>
</ul>
</template>

<script setup>
defineProps({
items: Array
})
</script>

<!-- 父组件:自定义渲染方式 -->
<template>
<DataList :items="products">
<template #item="{ item, index }">
<span>{{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}</span>
<button @click="buy(item)">购买</button>
</template>
</DataList>

<!-- 解构并重命名 -->
<DataList :items="products">
<template #item="{ item: product }">
<span>{{ product.name }}</span>
</template>
</DataList>
</template>

<!-- ========== 动态插槽名 ========== -->
<template>
<Layout>
<template #[dynamicSlotName]>
<p>动态插槽内容</p>
</template>
</Layout>
</template>

<script setup>
import { ref } from 'vue'
const dynamicSlotName = ref('header')
</script>

3.9 组合式函数 Composables(自定义 Hooks)

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// ========== composables/useCounter.ts ==========
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)

const increment = () => count.value++
const decrement = () => count.value--
const reset = () => (count.value = initialValue)

return { count, doubleCount, increment, decrement, reset }
}

// ========== composables/useMouse.ts ==========
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
const x = ref(0)
const y = ref(0)

const update = (e: MouseEvent) => {
x.value = e.pageX
y.value = e.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))

return { x, y }
}

// ========== composables/useLocalStorage.ts ==========
import { ref, watch } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)

watch(
data,
(newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
},
{ deep: true }
)

return data
}

// ========== composables/useLoading.ts ==========
import { ref } from 'vue'

export function useLoading() {
const loading = ref(false)
const error = ref<string | null>(null)

const run = async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
loading.value = true
error.value = null
try {
const result = await fn()
return result
} catch (e: any) {
error.value = e.message || '请求失败'
return undefined
} finally {
loading.value = false
}
}

return { loading, error, run }
}

// ========== composables/usePagination.ts ==========
import { ref, computed } from 'vue'

export function usePagination(fetchFn: (page: number, size: number) => Promise<any>) {
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const data = ref([])
const loading = ref(false)

const totalPages = computed(() => Math.ceil(total.value / pageSize.value))

const loadData = async () => {
loading.value = true
try {
const res = await fetchFn(currentPage.value, pageSize.value)
data.value = res.list
total.value = res.total
} finally {
loading.value = false
}
}

const goPage = (page: number) => {
currentPage.value = page
loadData()
}

const nextPage = () => {
if (currentPage.value < totalPages.value) {
goPage(currentPage.value + 1)
}
}

const prevPage = () => {
if (currentPage.value > 1) {
goPage(currentPage.value - 1)
}
}

return {
currentPage, pageSize, total, totalPages,
data, loading,
loadData, goPage, nextPage, prevPage
}
}

// ========== composables/useDebounce.ts ==========
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

export function useDebounce<T>(value: Ref<T>, delay = 300): Ref<T> {
const debouncedValue = ref(value.value) as Ref<T>

let timer: ReturnType<typeof setTimeout>

watch(value, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newVal
}, delay)
})

return debouncedValue
}
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
<!-- 在组件中使用 Composables -->
<template>
<div>
<p>计数: {{ count }} (双倍: {{ doubleCount }})</p>
<button @click="increment">+</button>
<button @click="reset">重置</button>

<p>鼠标位置: {{ x }}, {{ y }}</p>

<input v-model="searchInput" placeholder="搜索..." />
<p>防抖后的值: {{ debouncedSearch }}</p>

<div v-if="loading">加载中...</div>
<div v-if="error">{{ error }}</div>
</div>
</template>

<script setup>
import { ref } from 'vue'
import { useCounter } from '@/composables/useCounter'
import { useMouse } from '@/composables/useMouse'
import { useDebounce } from '@/composables/useDebounce'
import { useLoading } from '@/composables/useLoading'

// 解构使用
const { count, doubleCount, increment, reset } = useCounter(10)
const { x, y } = useMouse()

const searchInput = ref('')
const debouncedSearch = useDebounce(searchInput, 500)

const { loading, error, run } = useLoading()

// 使用 loading 包装异步操作
const fetchData = async () => {
const data = await run(() => fetch('/api/data').then(r => r.json()))
console.log(data)
}
</script>

3.10 内置组件

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
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<template>
<!-- ========== Transition 过渡动画 ========== -->
<button @click="show = !show">切换</button>
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>

<!-- ========== TransitionGroup 列表过渡 ========== -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.text }}
</li>
</TransitionGroup>

<!-- ========== KeepAlive 缓存组件 ========== -->
<KeepAlive :include="['HomeView', 'UserView']" :max="10">
<component :is="currentComponent" />
</KeepAlive>

<!-- 配合路由 -->
<RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>

<!-- ========== Teleport 传送门 ========== -->
<Teleport to="body">
<div v-if="showModal" class="modal">
<p>这个 Modal 被渲染到 body 下</p>
</div>
</Teleport>

<Teleport to="#modal-container">
<div>指定容器</div>
</Teleport>

<!-- ========== Suspense 异步组件(实验性)========== -->
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</template>

<script setup>
import { ref, defineAsyncComponent } from 'vue'

const show = ref(true)
const showModal = ref(false)

// 异步组件
const AsyncComponent = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
)

// 带配置的异步组件
const AsyncWithOptions = defineAsyncComponent({
loader: () => import('./components/HeavyComponent.vue'),
loadingComponent: () => import('./components/Loading.vue'),
errorComponent: () => import('./components/Error.vue'),
delay: 200, // 展示 loading 前的延迟
timeout: 3000 // 超时时间
})
</script>

<style>
/* Transition: fade */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

/* TransitionGroup: list */
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-move {
transition: transform 0.5s ease;
}
</style>

3.11 自定义指令

TypeScript

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
// ========== directives/vFocus.ts ==========
import type { Directive } from 'vue'

export const vFocus: Directive = {
mounted(el: HTMLInputElement) {
el.focus()
}
}

// ========== directives/vPermission.ts ==========
import type { Directive } from 'vue'
import { useUserStore } from '@/stores/user'

export const vPermission: Directive = {
mounted(el: HTMLElement, binding) {
const userStore = useUserStore()
const requiredPermission = binding.value

if (!userStore.permissions.includes(requiredPermission)) {
el.parentNode?.removeChild(el)
}
}
}

// ========== directives/vClickOutside.ts ==========
import type { Directive } from 'vue'

export const vClickOutside: Directive = {
mounted(el, binding) {
el._clickOutsideHandler = (event: Event) => {
if (!el.contains(event.target as Node)) {
binding.value(event)
}
}
document.addEventListener('click', el._clickOutsideHandler)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutsideHandler)
}
}

// ========== directives/vLazy.ts 图片懒加载 ==========
import type { Directive } from 'vue'

export const vLazy: Directive = {
mounted(el: HTMLImageElement, binding) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value
observer.unobserve(el)
}
})
observer.observe(el)
}
}

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
29
30
<!-- 使用自定义指令 -->
<template>
<!-- 局部注册 -->
<input v-focus />

<!-- 权限控制 -->
<button v-permission="'admin:delete'">删除</button>

<!-- 点击外部关闭 -->
<div v-click-outside="closeDropdown">
<button>打开下拉</button>
<ul v-show="showDropdown">...</ul>
</div>

<!-- 图片懒加载 -->
<img v-lazy="imageUrl" />
</template>

<script setup>
import { vFocus } from '@/directives/vFocus'
import { vClickOutside } from '@/directives/vClickOutside'
import { vLazy } from '@/directives/vLazy'
</script>

<!-- 全局注册(main.ts) -->
<script>
// main.ts
import { vPermission } from '@/directives/vPermission'
app.directive('permission', vPermission)
</script>

4. Vue Router

4.1 基础配置

TypeScript

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// router/index.ts
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
name: 'Home',
component: () => import('@/views/home/index.vue'), // 懒加载
meta: {
title: '首页',
requiresAuth: false,
icon: 'home'
}
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' }
},
{
// 嵌套路由
path: '/user',
name: 'User',
component: () => import('@/layouts/DefaultLayout.vue'),
redirect: '/user/list',
meta: { title: '用户管理', requiresAuth: true },
children: [
{
path: 'list', // → /user/list
name: 'UserList',
component: () => import('@/views/user/index.vue'),
meta: { title: '用户列表' }
},
{
path: 'detail/:id', // → /user/detail/123 动态路由
name: 'UserDetail',
component: () => import('@/views/user/detail.vue'),
meta: { title: '用户详情' },
props: true // 将 params 作为 props 传给组件
},
{
path: 'edit/:id?', // → /user/edit 或 /user/edit/123 可选参数
name: 'UserEdit',
component: () => import('@/views/user/edit.vue')
}
]
},
{
// 404 页面(捕获所有未匹配路由)
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/404.vue')
}
]

const router = createRouter({
// history 模式(推荐,URL 好看,需要后端配合)
history: createWebHistory(import.meta.env.BASE_URL),
// hash 模式(URL 带 #,无需后端配合)
// history: createWebHashHistory(),
routes,
// 滚动行为
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition // 浏览器前进/后退时恢复位置
}
return { top: 0 } // 跳转新页面回到顶部
}
})

// ========== 全局前置守卫 ==========
router.beforeEach((to, from, next) => {
// 设置页面标题
document.title = (to.meta.title as string) || '默认标题'

// 登录鉴权
const token = localStorage.getItem('token')

if (to.meta.requiresAuth && !token) {
// 需要登录但未登录,跳转登录页
next({
path: '/login',
query: { redirect: to.fullPath } // 记录目标地址
})
} else if (to.path === '/login' && token) {
// 已登录访问登录页,跳转首页
next('/home')
} else {
next()
}
})

// ========== 全局后置守卫 ==========
router.afterEach((to, from) => {
// 关闭 loading、统计 PV 等
console.log(`路由: ${from.path}${to.path}`)
})

export default router

4.2 组件中使用路由

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
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
<template>
<!-- 声明式导航 -->
<RouterLink to="/home">首页</RouterLink>
<RouterLink :to="{ name: 'UserDetail', params: { id: 123 } }">用户详情</RouterLink>
<RouterLink :to="{ path: '/user/list', query: { page: 1 } }">用户列表</RouterLink>
<RouterLink to="/home" active-class="active" exact-active-class="exact-active">首页</RouterLink>

<!-- 路由出口 -->
<RouterView />

<!-- 具名路由出口 + 插槽 -->
<RouterView v-slot="{ Component, route }">
<Transition name="fade" mode="out-in">
<KeepAlive :include="cachedViews">
<component :is="Component" :key="route.path" />
</KeepAlive>
</Transition>
</RouterView>
</template>

<script setup>
import { useRouter, useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

const router = useRouter() // 路由实例(用于导航)
const route = useRoute() // 当前路由信息(用于读取参数)

// ========== 读取路由信息 ==========
console.log(route.path) // '/user/detail/123'
console.log(route.params.id) // '123'
console.log(route.query.page) // '1'
console.log(route.name) // 'UserDetail'
console.log(route.meta.title) // '用户详情'
console.log(route.fullPath) // '/user/detail/123?page=1'
console.log(route.hash) // '#section'
console.log(route.matched) // 匹配的路由记录数组

// ========== 编程式导航 ==========
// push(有历史记录)
router.push('/home')
router.push({ path: '/home' })
router.push({ name: 'UserDetail', params: { id: 123 } })
router.push({ path: '/user/list', query: { page: 1, size: 10 } })

// replace(替换当前记录,无法后退)
router.replace('/home')
router.replace({ name: 'Home' })

// go / back / forward
router.go(-1) // 后退一步
router.go(1) // 前进一步
router.back() // 等价于 go(-1)
router.forward() // 等价于 go(1)

// ========== 组件内路由守卫 ==========
// 路由离开前(如表单未保存提示)
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const answer = window.confirm('有未保存的修改,确定离开吗?')
if (!answer) return false // 取消导航
}
})

// 路由参数变化时(同一组件复用,如 /user/1 → /user/2)
onBeforeRouteUpdate((to, from) => {
console.log('路由参数变化:', to.params.id)
// 重新获取数据
fetchUserDetail(to.params.id)
})
</script>

4.3 动态路由(权限控制)

TypeScript

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
// router/dynamicRoutes.ts
import type { RouteRecordRaw } from 'vue-router'

// 后端返回的菜单数据
interface MenuItem {
path: string
name: string
component: string // 组件路径
meta?: { title: string; icon: string }
children?: MenuItem[]
}

// 组件映射表
const modules = import.meta.glob('@/views/**/*.vue')

// 将后端数据转为路由配置
export function generateRoutes(menus: MenuItem[]): RouteRecordRaw[] {
return menus.map((menu) => {
const route: RouteRecordRaw = {
path: menu.path,
name: menu.name,
component: modules[`/src/views/${menu.component}.vue`],
meta: menu.meta,
children: menu.children ? generateRoutes(menu.children) : undefined
}
return route
})
}

// 在登录后动态添加路由
// const dynamicRoutes = generateRoutes(backendMenus)
// dynamicRoutes.forEach(route => {
// router.addRoute('Layout', route) // 添加为 Layout 的子路由
// })

5. Pinia 状态管理

5.1 创建与配置

TypeScript

1
2
3
4
5
6
7
8
9
10
11
// stores/index.ts
import { createPinia } from 'pinia'

const pinia = createPinia()

// 持久化插件(可选)
// npm install pinia-plugin-persistedstate
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)

export default pinia

5.2 定义 Store

TypeScript

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// ========== 方式一:Options Store(类似 Vuex) ==========
// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
// state:存放数据
state: () => ({
count: 0,
name: '计数器',
items: [] as string[]
}),

// getters:计算属性
getters: {
doubleCount: (state) => state.count * 2,

// 使用 this 访问其他 getters
doubleCountPlusOne(): number {
return this.doubleCount + 1
},

// 接受参数的 getter(返回函数)
getItemById: (state) => {
return (id: string) => state.items.find(item => item === id)
}
},

// actions:方法(同步/异步都在这里)
actions: {
increment() {
this.count++
},
decrement() {
this.count--
},
async fetchItems() {
try {
const res = await fetch('/api/items')
this.items = await res.json()
} catch (error) {
console.error(error)
}
},
// 可以调用其他 store
async complexAction() {
const userStore = useUserStore()
if (userStore.isLoggedIn) {
await this.fetchItems()
}
}
},

// 持久化配置(需要插件)
persist: {
key: 'counter-store',
storage: localStorage,
pick: ['count'] // 只持久化 count
}
})

// ========== 方式二:Setup Store(推荐,更灵活)==========
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, getUserInfoApi, logoutApi } from '@/api/user'

export const useUserStore = defineStore('user', () => {
// ref() → state
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref<{
id: number
name: string
avatar: string
roles: string[]
permissions: string[]
} | null>(null)

// computed() → getters
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => userInfo.value?.name || '游客')
const permissions = computed(() => userInfo.value?.permissions || [])
const hasPermission = computed(() => (perm: string) =>
permissions.value.includes(perm)
)

// function() → actions
async function login(username: string, password: string) {
try {
const res = await loginApi({ username, password })
token.value = res.data.token
localStorage.setItem('token', res.data.token)
await fetchUserInfo()
return true
} catch (error) {
console.error('登录失败:', error)
return false
}
}

async function fetchUserInfo() {
try {
const res = await getUserInfoApi()
userInfo.value = res.data
} catch (error) {
console.error('获取用户信息失败:', error)
logout()
}
}

function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}

function setToken(newToken: string) {
token.value = newToken
localStorage.setItem('token', newToken)
}

// 返回所有需要暴露的内容
return {
token,
userInfo,
isLoggedIn,
userName,
permissions,
hasPermission,
login,
fetchUserInfo,
logout,
setToken
}
}, {
persist: {
key: 'user-store',
storage: localStorage,
pick: ['token']
}
})

// ========== stores/app.ts 应用全局配置 ==========
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAppStore = defineStore('app', () => {
const sidebar = ref({
collapsed: false,
width: 240
})
const theme = ref<'light' | 'dark'>('light')
const language = ref('zh-CN')
const loading = ref(false)

const sidebarWidth = computed(() =>
sidebar.value.collapsed ? 64 : sidebar.value.width
)

function toggleSidebar() {
sidebar.value.collapsed = !sidebar.value.collapsed
}

function setTheme(newTheme: 'light' | 'dark') {
theme.value = newTheme
document.documentElement.setAttribute('data-theme', newTheme)
}

function setLoading(val: boolean) {
loading.value = val
}

return {
sidebar, theme, language, loading,
sidebarWidth,
toggleSidebar, setTheme, setLoading
}
}, {
persist: {
pick: ['theme', 'language', 'sidebar']
}
})

5.3 在组件中使用 Store

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
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
79
80
81
82
83
84
85
86
87
88
89
<template>
<div>
<p>{{ userStore.userName }}</p>
<p>{{ count }} × 2 = {{ doubleCount }}</p>
<button @click="counterStore.increment()">+1</button>
<button @click="handleLogin">登录</button>
<button @click="userStore.logout()">退出</button>
</div>
</template>

<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCounterStore } from '@/stores/counter'

const userStore = useUserStore()
const counterStore = useCounterStore()

// ========== 读取状态 ==========
// 方式一:直接使用(响应式)
console.log(userStore.userName) // getter
console.log(userStore.isLoggedIn) // getter
console.log(counterStore.count) // state

// 方式二:解构(⚠️ 直接解构会丢失响应式)
// ❌ const { count, doubleCount } = counterStore // 不是响应式
// ✅ 使用 storeToRefs
const { count, doubleCount } = storeToRefs(counterStore)
const { userName, isLoggedIn } = storeToRefs(userStore)

// ⚠️ 注意:actions 不要用 storeToRefs,直接解构即可
const { increment, decrement } = counterStore

// ========== 修改状态 ==========
// 方式一:直接修改
counterStore.count++

// 方式二:$patch(批量修改,性能更好)
counterStore.$patch({
count: 10,
name: '新名称'
})

// $patch 函数形式(适合复杂修改,如数组操作)
counterStore.$patch((state) => {
state.count++
state.items.push('new item')
})

// 方式三:调用 action
counterStore.increment()

// ========== 重置状态 ==========
counterStore.$reset() // 只有 Options Store 支持

// Setup Store 手动实现重置
// 在 store 中定义 reset 方法

// ========== 订阅状态变化 ==========
counterStore.$subscribe((mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
// mutation.storeId: 'counter'
console.log('状态变化:', mutation.type, state.count)

// 自动保存到 localStorage
localStorage.setItem('counter', JSON.stringify(state))
})

// 订阅 action 调用
counterStore.$onAction(({ name, args, after, onError }) => {
console.log(`调用了 action: ${name},参数:`, args)

after((result) => {
console.log(`${name} 执行完成,返回:`, result)
})

onError((error) => {
console.error(`${name} 执行出错:`, error)
})
})

// ========== 异步操作 ==========
const handleLogin = async () => {
const success = await userStore.login('admin', '123456')
if (success) {
router.push('/home')
}
}
</script>

5.4 Pinia vs Vuex 对比

text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────────────────┬──────────────────────────────────────┬──────────────────────────────────────┐
│ 特性 │ Vuex 4 │ Pinia │
├─────────────────┼──────────────────────────────────────┼──────────────────────────────────────┤
│ Vue 版本支持 │ Vue 2 & 3 │ Vue 2 & 3(官方推荐) │
│ TypeScript │ 支持但体验差 │ 原生完美支持 │
│ 模块化 │ modules + namespaced(复杂) │ 多个独立 store(简单) │
│ Mutations │ 必须通过 mutations 修改 │ ❌ 没有 mutations,直接改 │
│ Actions │ 异步操作 │ 同步异步都用 actions │
│ Getters │ ✅ │ ✅ │
│ 组合式 API │ 需要额外 composable │ Setup Store 原生支持 │
│ DevTools │ ✅ │ ✅ │
│ 代码体积 │ 较大 │ 更轻量(~1KB) │
│ 嵌套模块 │ ✅ │ 扁平化,store 间可互相引用 │
│ 热更新 │ 需要额外配置 │ ✅ 开箱即用 │
└─────────────────┴──────────────────────────────────────┴──────────────────────────────────────┘

结论:新项目一律用 Pinia,Vuex 仅用于维护旧项目。

6. Axios 网络请求

6.1 封装 Axios

TypeScript

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// api/index.ts
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/user'
import router from '@/router'
import { ElMessage, ElMessageBox } from 'element-plus'

// ========== 类型定义 ==========
interface ApiResponse<T = any> {
code: number
message: string
data: T
}

// ========== 创建实例 ==========
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 15000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})

// ========== 请求拦截器 ==========
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 添加 Token
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}

// 添加时间戳防止缓存(GET 请求)
if (config.method?.toLowerCase() === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}

return config
},
(error) => {
console.error('请求拦截器错误:', error)
return Promise.reject(error)
}
)

// ========== 响应拦截器 ==========
// 是否正在刷新 Token
let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []

function onRefreshed(token: string) {
refreshSubscribers.forEach((cb) => cb(token))
refreshSubscribers = []
}

service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { code, message, data } = response.data

// 根据业务状态码处理
switch (code) {
case 200:
case 0:
return data as any // 直接返回 data,简化使用

case 401:
// Token 过期
handleUnauthorized()
return Promise.reject(new Error(message || '登录已过期'))

case 403:
ElMessage.error('没有权限访问')
return Promise.reject(new Error(message || '没有权限'))

default:
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message || '请求失败'))
}
},
(error) => {
// HTTP 状态码错误处理
if (error.response) {
const { status, data } = error.response

switch (status) {
case 400:
ElMessage.error(data?.message || '请求参数错误')
break
case 401:
handleUnauthorized()
break
case 403:
ElMessage.error('没有权限')
break
case 404:
ElMessage.error('请求资源不存在')
break
case 500:
ElMessage.error('服务器内部错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务不可用')
break
default:
ElMessage.error(`请求失败(${status})`)
}
} else if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请稍后重试')
} else if (error.message === 'Network Error') {
ElMessage.error('网络连接异常')
} else if (error.message.includes('cancel')) {
console.log('请求已取消')
} else {
ElMessage.error('请求失败')
}

return Promise.reject(error)
}
)

// 处理 401 未授权
function handleUnauthorized() {
const userStore = useUserStore()

ElMessageBox.confirm('登录已过期,请重新登录', '提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userStore.logout()
router.push(`/login?redirect=${router.currentRoute.value.fullPath}`)
})
}

// ========== 封装请求方法 ==========
const http = {
get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, { params, ...config })
},

post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},

put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},

delete<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, { params, ...config })
},

// 上传文件
upload<T = any>(url: string, file: File, fieldName = 'file', extra?: Record<string, any>): Promise<T> {
const formData = new FormData()
formData.append(fieldName, file)
if (extra) {
Object.entries(extra).forEach(([key, value]) => {
formData.append(key, value as string)
})
}
return service.post(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},

// 下载文件
download(url: string, params?: any, fileName?: string): Promise<void> {
return service
.get(url, {
params,
responseType: 'blob'
})
.then((res: any) => {
const blob = new Blob([res])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName || 'download'
link.click()
URL.revokeObjectURL(link.href)
})
}
}

export default http
export { service }

6.2 API 模块

TypeScript

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// ========== api/user.ts ==========
import http from './index'

// 类型定义
export interface LoginParams {
username: string
password: string
}

export interface LoginResult {
token: string
refreshToken: string
expiresIn: number
}

export interface UserInfo {
id: number
name: string
avatar: string
email: string
roles: string[]
permissions: string[]
}

export interface UserListParams {
page: number
pageSize: number
keyword?: string
status?: number
}

export interface UserListResult {
list: UserInfo[]
total: number
}

// 接口方法
export const loginApi = (data: LoginParams) => {
return http.post<LoginResult>('/auth/login', data)
}

export const getUserInfoApi = () => {
return http.get<UserInfo>('/user/info')
}

export const getUserListApi = (params: UserListParams) => {
return http.get<UserListResult>('/user/list', params)
}

export const createUserApi = (data: Partial<UserInfo>) => {
return http.post<UserInfo>('/user', data)
}

export const updateUserApi = (id: number, data: Partial<UserInfo>) => {
return http.put<UserInfo>(`/user/${id}`, data)
}

export const deleteUserApi = (id: number) => {
return http.delete(`/user/${id}`)
}

export const uploadAvatarApi = (file: File) => {
return http.upload<{ url: string }>('/user/avatar', file)
}

export const exportUserApi = (params?: any) => {
return http.download('/user/export', params, '用户列表.xlsx')
}

// ========== api/product.ts ==========
import http from './index'

export interface Product {
id: number
name: string
price: number
description: string
category: string
stock: number
image: string
}

export const getProductListApi = (params: {
page: number
pageSize: number
category?: string
}) => {
return http.get<{ list: Product[]; total: number }>('/products', params)
}

export const getProductDetailApi = (id: number) => {
return http.get<Product>(`/products/${id}`)
}

6.3 在组件中使用

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
29
30
31
32
33
34
35
36
37
38
39
40
41
<template>
<div>
<div v-loading="loading">
<div v-for="user in userList" :key="user.id">
{{ user.name }}
</div>
</div>
<button @click="loadMore" :disabled="loading">加载更多</button>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { getUserListApi } from '@/api/user'
import type { UserInfo } from '@/api/user'

const userList = ref<UserInfo[]>([])
const loading = ref(false)
const page = ref(1)

const fetchUsers = async () => {
loading.value = true
try {
const res = await getUserListApi({ page: page.value, pageSize: 10 })
userList.value = res.list
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}

const loadMore = () => {
page.value++
fetchUsers()
}

onMounted(() => {
fetchUsers()
})
</script>

6.4 请求取消(防重复提交)

TypeScript

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
// utils/cancelRequest.ts
import axios from 'axios'
import type { AxiosRequestConfig } from 'axios'

const pendingMap = new Map<string, AbortController>()

function getRequestKey(config: AxiosRequestConfig): string {
return [config.method, config.url, JSON.stringify(config.params), JSON.stringify(config.data)].join('&')
}

export function addPending(config: AxiosRequestConfig) {
removePending(config) // 先取消重复请求
const key = getRequestKey(config)
const controller = new AbortController()
config.signal = controller.signal
pendingMap.set(key, controller)
}

export function removePending(config: AxiosRequestConfig) {
const key = getRequestKey(config)
if (pendingMap.has(key)) {
pendingMap.get(key)!.abort()
pendingMap.delete(key)
}
}

export function clearPending() {
pendingMap.forEach((controller) => controller.abort())
pendingMap.clear()
}

// 在拦截器中使用:
// 请求拦截器:addPending(config)
// 响应拦截器:removePending(config)
// 路由切换时:clearPending()

7. 打包工具对比

7.1 Vite vs Webpack vs 其他

text

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
┌──────────────┬──────────────────────┬──────────────────────┬──────────────────────┬──────────────────────┐
│ 特性 │ Vite │ Webpack │ Rollup │ esbuild │
├──────────────┼──────────────────────┼──────────────────────┼──────────────────────┼──────────────────────┤
│ 定位 │ 开发服务器 + 构建工具 │ 通用打包器 │ ES Module 打包器 │ 极速打包器 │
│ 语言 │ JS (基于Rollup+esbuild)│ JS │ JS │ Go │
│ 开发启动速度 │ ⚡ 极快(<1s) │ 🐌 慢(10-30s+) │ - │ ⚡ 极快 │
│ HMR 速度 │ ⚡ 极快(毫秒级) │ 🐌 慢(秒级) │ - │ ⚡ 极快 │
│ 生产构建 │ Rollup │ 自身 │ 自身 │ 自身 │
│ 构建速度 │ 快 │ 慢 │ 中等 │ 极快 │
│ 配置复杂度 │ 简单 │ 复杂 │ 中等 │ 简单 │
│ 生态系统 │ 快速增长 │ 最丰富 │ 丰富 │ 较少 │
│ Tree Shaking │ ✅ (Rollup) │ ✅ │ ✅ 最佳 │ ✅ │
│ Code Split │ ✅ │ ✅ │ ✅ │ ✅(有限) │
│ CSS 处理 │ ✅ 内置 │ 需要 loader │ 需要插件 │ ✅ 基础支持 │
│ TypeScript │ ✅ 内置(esbuild) │ 需要 ts-loader │ 需要插件 │ ✅ 原生 │
│ 浏览器兼容 │ 现代浏览器(可配置) │ 任意 │ 现代浏览器 │ 现代浏览器 │
│ SSR 支持 │ ✅ │ ✅ │ ✅ │ ❌ │
│ 适用场景 │ 现代前端项目 │ 企业级复杂项目 │ 库/组件打包 │ 底层工具 │
│ Vue 3 推荐 │ ✅ 官方推荐 │ ✅ 可用 │ 直接用不太方便 │ 直接用不太方便 │
└──────────────┴──────────────────────┴──────────────────────┴──────────────────────┴──────────────────────┘

结论:
- 新项目 → Vite(Vue/React 官方推荐)
- 老项目维护 → Webpack(生态最全)
- 打包库/组件 → Rollup
- esbuild → 被 Vite 内部使用,一般不直接用

7.2 Vite 为什么快?

text

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
传统 Webpack 工作流:
entry → 分析依赖 → 编译所有模块 → 打包 bundle → 启动服务器
(启动前必须处理完所有文件,项目越大越慢)

Vite 工作流:
┌─────────────────────────────────────────────────────────┐
1. 启动时: │
│ - 只启动开发服务器(几乎瞬间) │
│ - 不打包任何代码 │
│ │
2. 首次访问页面时: │
│ - 浏览器通过 <script type="module"> 发起请求 │
│ - Vite 拦截请求,按需编译单个文件 │
│ - 利用 esbuild 做 TS/JSX 转换(Go 语言,极快) │
│ │
3. 依赖预构建: │
│ - 第一次启动时用 esbuild 预编译 node_modules │
│ - 将 CommonJS → ESM │
│ - 合并小模块减少请求数 │
│ - 结果缓存到 node_modules/.vite │
│ │
4. HMR(热更新): │
│ - 只重新编译修改的文件 │
│ - 通过 WebSocket 推送到浏览器 │
│ - 不需要重新打包整个 bundle │
└─────────────────────────────────────────────────────────┘

7.3 Vite 配置

TypeScript

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

// 自动导入
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// 压缩
import viteCompression from 'vite-plugin-compression'

// SVG
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())

return {
// ========== 基础配置 ==========
base: '/', // 公共基础路径(CDN: 'https://cdn.example.com/')

// ========== 路径别名 ==========
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@views': resolve(__dirname, 'src/views'),
'@utils': resolve(__dirname, 'src/utils'),
'@api': resolve(__dirname, 'src/api'),
'@assets': resolve(__dirname, 'src/assets')
}
},

// ========== 插件 ==========
plugins: [
vue(),

// 自动导入 Vue/VueRouter/Pinia 的 API
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()],
dts: 'src/types/auto-imports.d.ts'
}),

// 自动注册组件
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/types/components.d.ts'
}),

// Gzip 压缩
viteCompression({
verbose: true,
disable: false,
threshold: 10240, // 大于 10KB 才压缩
algorithm: 'gzip',
ext: '.gz'
}),

// SVG 图标
createSvgIconsPlugin({
iconDirs: [resolve(process.cwd(), 'src/assets/icons')],
symbolId: 'icon-[dir]-[name]'
})
],

// ========== CSS 配置 ==========
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/assets/styles/variables.scss" as *;`
}
},
// CSS Modules
modules: {
localsConvention: 'camelCaseOnly'
}
},

// ========== 开发服务器 ==========
server: {
host: '0.0.0.0', // 允许外部访问
port: 5173,
open: true, // 自动打开浏览器
cors: true,

// 代理配置
proxy: {
'/api': {
target: env.VITE_APP_BASE_API,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// secure: false, // 允许自签名证书
// ws: true, // WebSocket 代理
},
'/upload': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
},

// ========== 构建配置 ==========
build: {
target: 'es2015',
outDir: 'dist',
assetsDir: 'assets',
sourcemap: mode !== 'production', // 生产环境不生成 sourcemap
minify: 'terser',

terserOptions: {
compress: {
drop_console: true, // 移除 console
drop_debugger: true // 移除 debugger
}
},

// 分包策略
rollupOptions: {
output: {
// 入口文件
entryFileNames: 'assets/js/[name]-[hash].js',
// 代码分割的chunk
chunkFileNames: 'assets/js/[name]-[hash].js',
// 静态资源
assetFileNames: (assetInfo) => {
const info = assetInfo.name || ''
if (/\.(png|jpe?g|gif|svg|webp|ico)$/.test(info)) {
return 'assets/images/[name]-[hash][extname]'
}
if (/\.(woff2?|eot|ttf|otf)$/.test(info)) {
return 'assets/fonts/[name]-[hash][extname]'
}
if (/\.css$/.test(info)) {
return 'assets/css/[name]-[hash][extname]'
}
return 'assets/[name]-[hash][extname]'
},
// 手动分包
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'element-plus': ['element-plus'],
'axios': ['axios'],
'lodash': ['lodash-es']
}
}
},

// chunk 大小警告阈值
chunkSizeWarningLimit: 1000
},

// ========== 依赖优化 ==========
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia', 'axios', 'element-plus']
}
}
})

8. Nginx 部署

8.1 Nginx 配置

nginx

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# nginx.conf

# 全局配置
user nginx;
worker_processes auto; # 自动根据 CPU 核心数设置
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
worker_connections 1024; # 每个 worker 最大连接数
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;

# Gzip 压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;

# ========== Vue 前端站点 ==========
server {
listen 80;
server_name www.example.com example.com;

# HTTPS 重定向(可选)
# return 301 https://$server_name$request_uri;

root /usr/share/nginx/html;
index index.html;

# ========== 关键:Vue Router history 模式 ==========
# 所有路径都返回 index.html,由前端路由处理
location / {
try_files $uri $uri/ /index.html;
}

# ========== API 反向代理 ==========
location /api/ {
proxy_pass http://backend-server:3000/; # 后端服务地址
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}

# ========== 静态资源缓存 ==========
# 带 hash 的资源:强缓存一年
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}

# index.html:不缓存(确保能获取最新版本)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}

# ========== 安全头 ==========
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}

# 自定义 404 页面
error_page 404 /index.html;

# 自定义 50x 页面
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

# ========== HTTPS 配置 ==========
server {
listen 443 ssl http2;
server_name www.example.com;

ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

# 其他配置同上...
root /usr/share/nginx/html;
index index.html;

location / {
try_files $uri $uri/ /index.html;
}

location /api/ {
proxy_pass http://backend-server:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

8.2 常用 Nginx 命令

Bash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 安装
sudo apt install nginx # Ubuntu/Debian
sudo yum install nginx # CentOS

# 操作
sudo nginx # 启动
sudo nginx -s stop # 强制停止
sudo nginx -s quit # 优雅停止
sudo nginx -s reload # 重新加载配置
sudo nginx -t # 测试配置文件语法

# 查看
sudo systemctl status nginx # 查看状态
sudo systemctl enable nginx # 开机自启

# 日志
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log

8.3 手动部署流程

Bash

1
2
3
4
5
6
7
8
9
10
11
# 1. 本地构建
npm run build

# 2. 上传到服务器
scp -r dist/* user@server:/usr/share/nginx/html/

# 或使用 rsync(增量同步)
rsync -avz --delete dist/ user@server:/usr/share/nginx/html/

# 3. 服务器上重载 Nginx
ssh user@server "sudo nginx -s reload"

9. Docker 容器化

9.1 Dockerfile

Dockerfile

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
# ========== 多阶段构建 ==========

# 阶段一:构建
FROM node:18-alpine AS builder

# 设置工作目录
WORKDIR /app

# 先复制 package 文件,利用 Docker 缓存层
COPY package.json package-lock.json ./

# 安装依赖
RUN npm ci --registry=https://registry.npmmirror.com

# 复制源码
COPY . .

# 构建(可传入构建参数)
ARG VITE_APP_BASE_API
ENV VITE_APP_BASE_API=${VITE_APP_BASE_API}

RUN npm run build

# 阶段二:运行(只包含 nginx 和构建产物,镜像极小)
FROM nginx:1.25-alpine

# 复制 nginx 配置
COPY nginx.conf /etc/nginx/nginx.conf

# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html

# 暴露端口
EXPOSE 80

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1

# 启动 nginx(前台运行)
CMD ["nginx", "-g", "daemon off;"]

9.2 .dockerignore

text

1
2
3
4
5
6
7
8
9
10
11
node_modules
dist
.git
.gitignore
.vscode
.env.local
.env.*.local
*.md
Dockerfile
docker-compose.yml
.dockerignore

9.3 docker-compose.yml

YAML

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
79
80
81
82
83
84
85
86
87
88
89
90
91
version: '3.8'

services:
# ========== 前端 ==========
frontend:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_BASE_API: http://backend:3000
container_name: vue-frontend
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
# SSL 证书(如果有)
# - ./ssl:/etc/nginx/ssl:ro
depends_on:
- backend
restart: always
networks:
- app-network

# ========== 后端 API ==========
backend:
image: node:18-alpine
container_name: api-backend
working_dir: /app
volumes:
- ./server:/app
ports:
- "3000:3000"
command: npm start
environment:
- NODE_ENV=production
- DB_HOST=database
- DB_PORT=3306
- DB_NAME=myapp
- DB_USER=root
- DB_PASS=password123
depends_on:
database:
condition: service_healthy
restart: always
networks:
- app-network

# ========== 数据库 ==========
database:
image: mysql:8.0
container_name: mysql-db
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: password123
MYSQL_DATABASE: myapp
MYSQL_CHARSET: utf8mb4
volumes:
- mysql-data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql # 初始化 SQL
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
restart: always
networks:
- app-network

# ========== Redis 缓存(可选)==========
redis:
image: redis:7-alpine
container_name: redis-cache
ports:
- "6379:6379"
volumes:
- redis-data:/data
restart: always
networks:
- app-network

# 数据卷
volumes:
mysql-data:
redis-data:

# 网络
networks:
app-network:
driver: bridge

9.4 Docker 常用命令

Bash

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
# ========== 镜像操作 ==========
docker build -t my-vue-app . # 构建镜像
docker build -t my-vue-app --build-arg VITE_APP_BASE_API=https://api.example.com . # 带参数构建
docker images # 查看镜像列表
docker rmi my-vue-app # 删除镜像
docker image prune # 清理悬挂镜像

# ========== 容器操作 ==========
docker run -d -p 80:80 --name my-app my-vue-app # 后台运行
docker run -d -p 80:80 -v ./nginx.conf:/etc/nginx/nginx.conf my-vue-app # 挂载配置
docker ps # 查看运行中的容器
docker ps -a # 查看所有容器
docker stop my-app # 停止容器
docker start my-app # 启动容器
docker restart my-app # 重启容器
docker rm my-app # 删除容器
docker logs my-app # 查看日志
docker logs -f my-app # 实时查看日志
docker exec -it my-app sh # 进入容器

# ========== Docker Compose ==========
docker-compose up -d # 后台启动所有服务
docker-compose up -d --build # 重新构建并启动
docker-compose down # 停止并删除容器
docker-compose down -v # 停止并删除容器+数据卷
docker-compose ps # 查看服务状态
docker-compose logs -f frontend # 查看某服务日志
docker-compose restart frontend # 重启某服务
docker-compose exec frontend sh # 进入某服务容器

# ========== 完整部署流程 ==========
# 1. 服务器安装 Docker
curl -fsSL https://get.docker.com | sh
sudo systemctl start docker
sudo systemctl enable docker

# 2. 安装 Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

# 3. 上传项目到服务器
scp -r ./project user@server:/home/user/project

# 4. 启动
cd /home/user/project
docker-compose up -d --build

10. 综合实战案例

一个完整的用户管理系统

10.1 项目入口与路由

TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'

import App from './App.vue'
import router from './router'
import './assets/styles/global.scss'

const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

app.use(pinia)
app.use(router)
app.use(ElementPlus, { locale: zhCn })

app.mount('#app')

TypeScript

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
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'

const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', requiresAuth: false }
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'dashboard' }
},
{
path: 'users',
name: 'UserList',
component: () => import('@/views/user/list.vue'),
meta: { title: '用户列表', icon: 'user' }
},
{
path: 'users/:id',
name: 'UserDetail',
component: () => import('@/views/user/detail.vue'),
meta: { title: '用户详情', hidden: true },
props: true
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/index.vue'),
meta: { title: '系统设置', icon: 'setting' }
}
]
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/404.vue')
}
]
})

// 路由守卫
router.beforeEach(async (to, from, next) => {
document.title = `${to.meta.title || ''} - 管理系统`

const userStore = useUserStore()

if (to.meta.requiresAuth !== false) {
if (!userStore.token) {
return next({ path: '/login', query: { redirect: to.fullPath } })
}
// 如果有 token 但没有用户信息,获取用户信息
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
} catch {
userStore.logout()
return next({ path: '/login', query: { redirect: to.fullPath } })
}
}
}

next()
})

export default router

10.2 Store

TypeScript

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
// src/stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, getUserInfoApi, type LoginParams, type UserInfo } from '@/api/user'
import router from '@/router'

export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref<UserInfo | null>(null)

const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => userInfo.value?.name || '游客')
const avatar = computed(() => userInfo.value?.avatar || '/default-avatar.png')

async function login(params: LoginParams) {
const res = await loginApi(params)
token.value = res.token
await fetchUserInfo()
}

async function fetchUserInfo() {
const res = await getUserInfoApi()
userInfo.value = res
}

function logout() {
token.value = ''
userInfo.value = null
router.push('/login')
}

return {
token, userInfo,
isLoggedIn, userName, avatar,
login, fetchUserInfo, logout
}
}, {
persist: {
pick: ['token']
}
})

10.3 布局组件

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
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
<!-- src/layouts/DefaultLayout.vue -->
<template>
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
<div class="logo">
<img src="@/assets/images/logo.png" alt="logo" />
<span v-show="!isCollapse">管理系统</span>
</div>

<el-menu
:default-active="route.path"
:collapse="isCollapse"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<template v-for="item in menuList" :key="item.path">
<el-menu-item :index="item.path">
<el-icon><component :is="item.meta.icon" /></el-icon>
<template #title>{{ item.meta.title }}</template>
</el-menu-item>
</template>
</el-menu>
</el-aside>

<!-- 主体 -->
<el-container>
<!-- 头部 -->
<el-header class="header">
<div class="left">
<el-icon class="toggle-btn" @click="isCollapse = !isCollapse">
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
{{ item.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>

<div class="right">
<el-dropdown>
<span class="user-info">
<el-avatar :src="userStore.avatar" :size="32" />
<span>{{ userStore.userName }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/settings')">
个人设置
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>

<!-- 内容区 -->
<el-main class="main-content">
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</el-main>
</el-container>
</el-container>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessageBox } from 'element-plus'
import { Fold, Expand } from '@element-plus/icons-vue'

const route = useRoute()
const router = useRouter()
const userStore = useUserStore()

const isCollapse = ref(false)

// 菜单列表(过滤隐藏项)
const menuList = computed(() => {
const mainRoute = router.getRoutes().find(r => r.path === '/')
return (mainRoute?.children || []).filter(item => !item.meta?.hidden)
})

// 面包屑
const breadcrumbs = computed(() => {
return route.matched.filter(item => item.meta?.title)
})

const handleLogout = () => {
ElMessageBox.confirm('确定退出登录吗?', '提示', {
type: 'warning'
}).then(() => {
userStore.logout()
})
}
</script>

<style lang="scss" scoped>
.layout-container {
height: 100vh;
}

.sidebar {
background-color: #304156;
transition: width 0.3s;
overflow: hidden;

.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #fff;
font-size: 18px;
font-weight: bold;

img {
width: 32px;
height: 32px;
}
}
}

.header {
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);

.left {
display: flex;
align-items: center;
gap: 16px;
}

.toggle-btn {
font-size: 20px;
cursor: pointer;
}

.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
}

.main-content {
background-color: #f0f2f5;
min-height: 0;
}

.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

10.4 登录页

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
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
<!-- src/views/login/index.vue -->
<template>
<div class="login-container">
<el-card class="login-card">
<h2>管理系统登录</h2>
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>

<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>

<el-form-item>
<el-checkbox v-model="loginForm.remember">记住我</el-checkbox>
</el-form-item>

<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
登 录
</el-button>
</el-form>
</el-card>
</div>
</template>

<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()

const formRef = ref()
const loading = ref(false)

const loginForm = reactive({
username: 'admin',
password: '123456',
remember: false
})

const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 30, message: '长度在 6 到 30 个字符', trigger: 'blur' }
]
}

const handleLogin = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return

loading.value = true
try {
await userStore.login({
username: loginForm.username,
password: loginForm.password
})

ElMessage.success('登录成功')

// 跳转到之前的页面或首页
const redirect = (route.query.redirect as string) || '/dashboard'
router.push(redirect)
} catch (error: any) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>

<style lang="scss" scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.login-card {
width: 420px;
padding: 20px;

h2 {
text-align: center;
margin-bottom: 30px;
color: #333;
}

.login-btn {
width: 100%;
}
}
</style>

10.5 用户列表页(CRUD 完整示例)

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
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
<!-- src/views/user/list.vue -->
<template>
<div class="user-list-page">
<!-- 搜索栏 -->
<el-card class="search-card">
<el-form :model="searchForm" inline>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="姓名/邮箱"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="全部" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>

<!-- 表格 -->
<el-card class="table-card">
<template #header>
<div class="card-header">
<span>用户列表</span>
<el-button type="primary" @click="handleAdd">新增用户</el-button>
</div>
</template>

<el-table
v-loading="loading"
:data="tableData"
border
stripe
@sort-change="handleSortChange"
>
<el-table-column type="index" label="#" width="60" />
<el-table-column prop="name" label="姓名" sortable="custom" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'">
{{ row.role === 'admin' ? '管理员' : '普通用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180" sortable="custom" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)">查看</el-button>
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-popconfirm
title="确定删除该用户吗?"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button type="danger" link>删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>

<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card>

<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增用户' : '编辑用户'"
width="500px"
destroy-on-close
>
<el-form
ref="dialogFormRef"
:model="dialogForm"
:rules="dialogRules"
label-width="80px"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="dialogForm.name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="dialogForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="dialogForm.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="dialogForm.role" placeholder="请选择角色">
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
</el-select>
</el-form-item>
<el-form-item v-if="dialogType === 'add'" label="密码" prop="password">
<el-input v-model="dialogForm.password" type="password" placeholder="请输入密码" />
</el-form-item>
</el-form>

<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getUserListApi,
createUserApi,
updateUserApi,
deleteUserApi
} from '@/api/user'

const router = useRouter()

// ========== 搜索 ==========
const searchForm = reactive({
keyword: '',
status: undefined
})

// ========== 表格数据 ==========
const loading = ref(false)
const tableData = ref([])
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})

const fetchData = async () => {
loading.value = true
try {
const res = await getUserListApi({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword,
status: searchForm.status
})
tableData.value = res.list
pagination.total = res.total
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}

const handleSearch = () => {
pagination.page = 1
fetchData()
}

const handleReset = () => {
searchForm.keyword = ''
searchForm.status = undefined
handleSearch()
}

const handleSortChange = ({ prop, order }) => {
console.log('排序:', prop, order)
fetchData()
}

// ========== 弹窗 ==========
const dialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const dialogFormRef = ref()
const submitLoading = ref(false)

const dialogForm = reactive({
id: undefined,
name: '',
email: '',
phone: '',
role: 'user',
password: ''
})

const dialogRules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' }
]
}

const resetDialogForm = () => {
Object.assign(dialogForm, {
id: undefined,
name: '',
email: '',
phone: '',
role: 'user',
password: ''
})
}

const handleAdd = () => {
dialogType.value = 'add'
resetDialogForm()
dialogVisible.value = true
}

const handleEdit = (row) => {
dialogType.value = 'edit'
Object.assign(dialogForm, {
id: row.id,
name: row.name,
email: row.email,
phone: row.phone,
role: row.role
})
dialogVisible.value = true
}

const handleSubmit = async () => {
const valid = await dialogFormRef.value?.validate().catch(() => false)
if (!valid) return

submitLoading.value = true
try {
if (dialogType.value === 'add') {
await createUserApi(dialogForm)
ElMessage.success('新增成功')
} else {
await updateUserApi(dialogForm.id, dialogForm)
ElMessage.success('编辑成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}

// ========== 操作 ==========
const handleView = (row) => {
router.push(`/users/${row.id}`)
}

const handleDelete = async (row) => {
try {
await deleteUserApi(row.id)
ElMessage.success('删除成功')
// 如果当前页只有一条数据且不是第一页,跳到上一页
if (tableData.value.length === 1 && pagination.page > 1) {
pagination.page--
}
fetchData()
} catch (error) {
console.error(error)
}
}

const handleStatusChange = async (row) => {
try {
await updateUserApi(row.id, { status: row.status })
ElMessage.success(`已${row.status ? '启用' : '禁用'}`)
} catch (error) {
row.status = row.status ? 0 : 1 // 回滚
}
}

// ========== 初始化 ==========
onMounted(() => {
fetchData()
})
</script>

<style lang="scss" scoped>
.user-list-page {
.search-card {
margin-bottom: 16px;
}

.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}

.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
}
</style>

10.6 用户详情页

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
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
<!-- src/views/user/detail.vue -->
<template>
<div class="user-detail" v-loading="loading">
<el-page-header @back="router.back()" content="用户详情" />

<el-card v-if="userDetail" class="detail-card">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ userDetail.id }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ userDetail.name }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ userDetail.email }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ userDetail.phone }}</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag>{{ userDetail.role }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="userDetail.status ? 'success' : 'danger'">
{{ userDetail.status ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ userDetail.createdAt }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ userDetail.updatedAt }}</el-descriptions-item>
</el-descriptions>
</el-card>

<el-empty v-else-if="!loading" description="用户不存在" />
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getUserDetailApi } from '@/api/user'

const props = defineProps<{ id: string }>()
const router = useRouter()

const loading = ref(false)
const userDetail = ref(null)

const fetchDetail = async () => {
loading.value = true
try {
userDetail.value = await getUserDetailApi(Number(props.id))
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}

onMounted(fetchDetail)
</script>

<style lang="scss" scoped>
.user-detail {
.detail-card {
margin-top: 20px;
}
}
</style>

10.7 仪表盘页

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
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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<!-- src/views/dashboard/index.vue -->
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :lg="6" v-for="item in statsCards" :key="item.title">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-info">
<p class="stat-title">{{ item.title }}</p>
<p class="stat-value">{{ item.value }}</p>
<p class="stat-desc">
<span :class="item.trend > 0 ? 'up' : 'down'">
{{ item.trend > 0 ? '↑' : '↓' }} {{ Math.abs(item.trend) }}%
</span>
较上月
</p>
</div>
<div class="stat-icon" :style="{ backgroundColor: item.color }">
<el-icon :size="32"><component :is="item.icon" /></el-icon>
</div>
</div>
</el-card>
</el-col>
</el-row>

<!-- 最近活动 -->
<el-card class="activity-card">
<template #header>
<span>最近活动</span>
</template>
<el-timeline>
<el-timeline-item
v-for="activity in activities"
:key="activity.id"
:timestamp="activity.time"
placement="top"
:type="activity.type"
>
<p>{{ activity.content }}</p>
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { User, ShoppingCart, Money, TrendCharts } from '@element-plus/icons-vue'

const statsCards = ref([
{ title: '用户总数', value: '12,846', trend: 12.5, color: '#409EFF', icon: User },
{ title: '订单数量', value: '3,256', trend: -3.2, color: '#67C23A', icon: ShoppingCart },
{ title: '销售额', value: '¥128,460', trend: 8.7, color: '#E6A23C', icon: Money },
{ title: '访问量', value: '98,745', trend: 15.3, color: '#F56C6C', icon: TrendCharts }
])

const activities = ref([
{ id: 1, content: '用户张三注册了账号', time: '2024-01-15 14:30', type: 'primary' },
{ id: 2, content: '订单 #10086 已完成支付', time: '2024-01-15 13:20', type: 'success' },
{ id: 3, content: '商品"iPhone 15"库存不足', time: '2024-01-15 12:00', type: 'warning' },
{ id: 4, content: '系统更新完成 v2.1.0', time: '2024-01-15 10:00', type: 'info' }
])

onMounted(() => {
// 实际项目中从接口获取数据
console.log('Dashboard mounted')
})
</script>

<style lang="scss" scoped>
.dashboard {
.stat-card {
margin-bottom: 16px;

.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
}

.stat-title {
color: #909399;
font-size: 14px;
margin-bottom: 8px;
}

.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
margin-bottom: 8px;
}

.stat-desc {
font-size: 12px;
color: #909399;

.up { color: #67C23A; }
.down { color: #F56C6C; }
}

.stat-icon {
width: 64px;
height: 64px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
}

.activity-card {
margin-top: 16px;
}
}
</style>

10.8 package.json 脚本

JSON

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
{
"name": "vue3-admin",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:staging": "vite build --mode staging",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx --fix",
"format": "prettier --write src/",
"type-check": "vue-tsc --noEmit",
"docker:build": "docker build -t vue3-admin .",
"docker:run": "docker run -d -p 80:80 --name vue3-admin vue3-admin",
"deploy": "npm run build && scp -r dist/* user@server:/usr/share/nginx/html/"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"pinia-plugin-persistedstate": "^3.2.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0",
"typescript": "^5.3.0",
"sass": "^1.69.0",
"eslint": "^8.56.0",
"prettier": "^3.2.0",
"unplugin-auto-import": "^0.17.0",
"unplugin-vue-components": "^0.26.0",
"vite-plugin-compression": "^0.5.0"
}
}

快速复习速查表

text

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
┌─────────────────────────────────────────────────────────────────────┐
│ Vue 3 核心概念速查 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 响应式: ref() → 基本类型 + 对象 reactive() → 对象 │
│ 计算属性: computed(() => xxx) │
│ 侦听器: watch(source, callback) watchEffect(callback) │
│ 生命周期: onMounted onUpdated onUnmounted onBeforeMount ... │
│ │
│ 组件通信: props ↓ emits ↑ v-model ↕ │
│ provide/inject defineExpose mitt │
│ │
│ 路由: useRouter() → 导航 useRoute() → 读参数 │
│ beforeEach 守卫 动态路由 addRoute │
│ │
│ Pinia: defineStore('id', () => { ref computed function }) │
storeToRefs() 解构保持响应式 │
│ │
│ Axios: 创建实例 → 请求拦截(加Token) → 响应拦截(统一错误) → 封装方法 │
│ │
│ 构建: Vite(推荐) vs Webpack vite.config.ts
│ 部署: npm run build → Nginx(try_files) → Docker(多阶段构建) │
│ │
│ 最佳实践: │
│ ✅ 使用 <script setup> │
│ ✅ 使用 TypeScript │
│ ✅ 组合式函数(Composables)复用逻辑 │
│ ✅ 统一 ref 而非混用 ref/reactive │
│ ✅ API 层独立封装 │
│ ✅ 路由懒加载 () => import(...) │
│ ✅ 分包策略优化首屏加载 │
│ │
└─────────────────────────────────────────────────────────────────────┘

学习建议:先通读一遍建立整体认知,然后按照第 10 章的实战案例从零开始搭建一个完整项目,遇到不理解的语法再回到前面章节查阅。写完之后用 Docker 部署到服务器上,完整走一遍流程就能掌握了。


Vue 3 + Nginx + Docker 全面实战指南
https://xtanguser.github.io/2026/03/11/Vue 3 +nginx+docker/
作者
小唐
发布于
2026年3月11日
许可协议