(第二章) Vue3 组合式API实战:从Options到Composition【保姆级教程】
📅 发布日期:2025年10月23日
🏷️ 标签:Vue3、Composition API、Hooks、响应式
⏱️ 阅读时长:约120分钟
💡 难度:⭐⭐ 进阶级
📖 前言
大家好,我是代码小库。这是 Vue3 实战系列 的第2篇文章。在上一篇中,我们完成了 Vue3 + Vite 项目的搭建。今天,我们将深入学习 Vue3 最核心的特性 —— 组合式 API(Composition API)。
最终项目预览:

本文你将学到:
- ✅ Options API 和 Composition API 的本质区别
- ✅
ref和reactive的正确使用场景 - ✅
computed、watch、watchEffect的高级用法 - ✅ 生命周期钩子在 Composition API 中的使用
- ✅ 10 个实用的自定义 Composable 函数
- ✅ 从 Options API 迁移到 Composition API 的最佳实践
🤔 一、为什么需要 Composition API?
1.1 Options API 的局限性
让我们先看一个用 Options API 写的组件:
<template>
<div class="user-profile">
<h2>{{ user.name }}</h2>
<p>粉丝数:{{ followerCount }}</p>
<button @click="follow">关注</button>
<div class="posts">
<div v-for="post in posts" :key="post.id">
{{ post.title }}
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
user: null,
posts: [],
isFollowing: false,
}
},
computed: {
followerCount() {
return this.user?.followers || 0
}
},
watch: {
'$route.params.userId'(newId) {
this.fetchUserData(newId)
}
},
mounted() {
this.fetchUserData(this.$route.params.userId)
},
methods: {
async fetchUserData(userId) {
// 获取用户数据的逻辑
},
async follow() {
// 关注用户的逻辑
},
async fetchPosts(userId) {
// 获取帖子的逻辑
},
}
}
</script>
存在的问题:
- 逻辑分散 - 同一个功能的代码散落在
data、computed、methods、mounted等不同位置 - 代码复用困难 - 如果要在多个组件中复用"用户关注"功能,只能通过 Mixins(容易命名冲突)
- TypeScript 支持不友好 -
this的类型推断很困难 - 大型组件难以维护 - 当组件变大时,需要在不同选项之间来回跳转
1.2 Composition API 的优势
使用 Composition API 改写:
<template>
<div class="user-profile">
<h2>{{ user?.name }}</h2>
<p>粉丝数:{{ followerCount }}</p>
<button @click="follow">关注</button>
<div class="posts">
<div v-for="post in posts" :key="post.id">
{{ post.title }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useUser } from '@/composables/useUser'
// 使用组合式函数(Composable)
const route = useRoute()
const { user, followerCount, follow } = useUser(route.params.userId)
const posts = ref([])
// 监听路由变化
watch(() => route.params.userId, (newId) => {
fetchUserData(newId)
})
// 获取帖子数据
const fetchPosts = async (userId: string) => {
// 获取帖子的逻辑
}
onMounted(() => {
fetchPosts(route.params.userId)
})
</script>
优势明显:
- ✅ 逻辑聚合 - 同一功能的代码集中在一起
- ✅ 代码复用 - 可以抽取成
useUser等 Composable 函数 - ✅ TypeScript 完美支持 - 类型推断非常友好
- ✅ 更好的代码组织 - 按功能而非选项组织代码
🎯 二、响应式基础:ref vs reactive
2.1 ref - 响应式引用
ref 用于包装 基本类型 或 单个对象:
import { ref } from 'vue'
// 基本类型
const count = ref(0)
const message = ref('Hello')
const isActive = ref(false)
// 访问和修改值(需要 .value)
console.log(count.value) // 0
count.value++ // 修改值
count.value = 10 // 赋新值
// 对象类型
const user = ref({
name: 'John',
age: 25
})
// 修改对象属性
user.value.name = 'Tom'
// 替换整个对象
user.value = { name: 'Jane', age: 30 }
在模板中使用(自动解包):
<template>
<!-- 模板中不需要 .value -->
<div>{{ count }}</div>
<div>{{ message }}</div>
<button @click="count++">增加</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const message = ref('Hello')
</script>
2.2 reactive - 响应式对象
reactive 用于包装 对象类型(包括数组):
import { reactive } from 'vue'
// 创建响应式对象
const state = reactive({
count: 0,
message: 'Hello',
user: {
name: 'John',
age: 25
}
})
// 直接访问和修改(不需要 .value)
console.log(state.count) // 0
state.count++ // 修改值
state.user.name = 'Tom' // 修改嵌套属性
// 数组
const list = reactive([1, 2, 3])
list.push(4) // 响应式操作
list[0] = 100 // 响应式修改
2.3 ref vs reactive 对比
| 特性 | ref | reactive |
|---|---|---|
| 适用类型 | 基本类型 + 对象 | 对象 + 数组 |
| 访问方式 | 需要.value | 直接访问属性 |
| 解包 | 模板中自动解包 | 不需要解包 |
| 重新赋值 | ✅ 支持 | ❌ 会失去响应式 |
| TypeScript 支持 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
❌ reactive 的陷阱:
import { reactive } from 'vue'
let state = reactive({ count: 0 })
// ❌ 错误:重新赋值会失去响应式
state = reactive({ count: 1 }) // 失去响应式!
// ❌ 错误:解构会失去响应式
const { count } = state // count 不再是响应式
// ✅ 正确:使用 toRefs 解构
import { toRefs } from 'vue'
const { count } = toRefs(state) // count 是 ref,保持响应式
2.4 最佳实践建议
// 推荐方案1:全部使用 ref(推荐⭐⭐⭐⭐⭐)
const count = ref(0)
const user = ref({ name: 'John', age: 25 })
const list = ref([1, 2, 3])
// 优点:
// - 一致性强,不需要记住什么时候用哪个
// - 不会遇到 reactive 的解构陷阱
// - TypeScript 类型推断更好
// 推荐方案2:ref 基本类型 + reactive 对象
const count = ref(0)
const state = reactive({
user: { name: 'John', age: 25 },
list: [1, 2, 3]
})
// 优点:
// - 访问对象属性更简洁(不需要 .value)
// - 缺点:容易踩到解构和重新赋值的坑
我的建议: 新手或小项目 → 全用 ref;大项目或团队开发 → 根据团队规范。
📊 三、计算属性和侦听器
3.1 computed - 计算属性
import { ref, computed } from 'vue'
// 只读计算属性
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
console.log(fullName.value) // "John Doe"
// 修改 firstName 会自动更新 fullName
firstName.value = 'Tom'
console.log(fullName.value) // "Tom Doe"
// ❌ 不能直接修改计算属性
fullName.value = 'Jane Doe' // 报错!
可写的计算属性:
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed({
// getter
get() {
return `${firstName.value} ${lastName.value}`
},
// setter
set(newValue) {
const names = newValue.split(' ')
firstName.value = names[0]
lastName.value = names[1]
}
})
// 现在可以修改了
fullName.value = 'Jane Smith'
console.log(firstName.value) // "Jane"
console.log(lastName.value) // "Smith"
实战案例:购物车总价计算
interface CartItem {
id: number
name: string
price: number
quantity: number
}
const cartItems = ref<CartItem[]>([
{ id: 1, name: 'iPhone', price: 5999, quantity: 1 },
{ id: 2, name: 'iPad', price: 3999, quantity: 2 }
])
// 计算总价
const totalPrice = computed(() => {
return cartItems.value.reduce((sum, item) => {
return sum + item.price * item.quantity
}, 0)
})
console.log(totalPrice.value) // 13997
// 计算总数量
const totalQuantity = computed(() => {
return cartItems.value.reduce((sum, item) => sum + item.quantity, 0)
})
3.2 watch - 侦听器
侦听单个数据源:
import { ref, watch } from 'vue'
const count = ref(0)
// 侦听 ref
watch(count, (newValue, oldValue) => {
console.log(`count 从 ${oldValue} 变成了 ${newValue}`)
})
count.value++ // 输出: count 从 0 变成了 1
侦听对象的属性:
const user = reactive({
name: 'John',
age: 25
})
// ❌ 错误:直接侦听 reactive 对象的属性不会触发
watch(user.age, (newAge) => {
console.log(newAge)
}) // 不会触发!
// ✅ 正确:使用 getter 函数
watch(
() => user.age,
(newAge, oldAge) => {
console.log(`年龄从 ${oldAge} 变成了 ${newAge}`)
}
)
user.age = 26 // 触发侦听器
侦听多个数据源:
const firstName = ref('John')
const lastName = ref('Doe')
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`名字从 ${oldFirst} ${oldLast} 变成了 ${newFirst} ${newLast}`)
}
)
深度侦听:
const user = ref({
name: 'John',
profile: {
age: 25,
city: 'Beijing'
}
})
// ❌ 默认不会侦听嵌套属性的变化
watch(user, (newUser) => {
console.log('user 变化了')
})
user.value.profile.age = 26 // 不会触发!
// ✅ 开启深度侦听
watch(
user,
(newUser) => {
console.log('user 或其嵌套属性变化了')
},
{ deep: true } // 深度侦听
)
user.value.profile.age = 26 // 触发!
立即执行:
const userId = ref(1)
watch(
userId,
async (newId) => {
// 获取用户数据
const user = await fetchUser(newId)
},
{ immediate: true } // 组件创建时立即执行一次
)
3.3 watchEffect - 自动追踪依赖
import { ref, watchEffect } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// 自动追踪依赖
watchEffect(() => {
console.log(`全名: ${firstName.value} ${lastName.value}`)
})
// 立即输出: 全名: John Doe
firstName.value = 'Tom'
// 输出: 全名: Tom Doe
lastName.value = 'Smith'
// 输出: 全名: Tom Smith
watch vs watchEffect 对比:
| 特性 | watch | watchEffect |
|---|---|---|
| 依赖追踪 | 手动指定 | 自动追踪 |
| 获取旧值 | ✅ 支持 | ❌ 不支持 |
| 立即执行 | 需配置immediate | 默认立即执行 |
| 使用场景 | 需要对比新旧值 | 副作用函数 |
实战案例:自动保存草稿
const draftTitle = ref('')
const draftContent = ref('')
// 自动保存草稿到 localStorage
watchEffect(() => {
localStorage.setItem('draft', JSON.stringify({
title: draftTitle.value,
content: draftContent.value
}))
})
// 用户输入时自动保存
draftTitle.value = 'My Article'
draftContent.value = 'Content here...'
// 自动保存到 localStorage
🔄 四、生命周期钩子
4.1 生命周期对照表
| Options API | Composition API |
|---|---|
beforeCreate | ❌ 不需要(setup 本身) |
created | ❌ 不需要(setup 本身) |
beforeMount | onBeforeMount |
mounted | onMounted |
beforeUpdate | onBeforeUpdate |
updated | onUpdated |
beforeUnmount | onBeforeUnmount |
unmounted | onUnmounted |
errorCaptured | onErrorCaptured |
activated | onActivated |
deactivated | onDeactivated |
4.2 使用示例
import {
ref,
onMounted,
onUpdated,
onUnmounted
} from 'vue'
export default {
setup() {
const count = ref(0)
// 组件挂载后执行
onMounted(() => {
console.log('组件已挂载')
// 初始化操作,如获取数据、订阅事件等
})
// 组件更新后执行
onUpdated(() => {
console.log('组件已更新')
// DOM 更新后的操作
})
// 组件卸载前执行
onUnmounted(() => {
console.log('组件即将卸载')
// 清理操作,如取消订阅、清除定时器等
})
return { count }
}
}
实战案例:定时器管理
import { ref, onMounted, onUnmounted } from 'vue'
const useTimer = () => {
const seconds = ref(0)
let timer: number | null = null
onMounted(() => {
// 启动定时器
timer = window.setInterval(() => {
seconds.value++
}, 1000)
})
onUnmounted(() => {
// 清除定时器
if (timer) {
clearInterval(timer)
}
})
return { seconds }
}
// 在组件中使用
const { seconds } = useTimer()
🎁 五、10个实用的 Composable 函数
5.1 useToggle - 布尔值切换
// composables/useToggle.ts
import { ref, Ref } from 'vue'
export function useToggle(initialValue = false): [Ref<boolean>, () => void] {
const state = ref(initialValue)
const toggle = () => {
state.value = !state.value
}
return [state, toggle]
}
// 使用
const [isVisible, toggleVisible] = useToggle(false)
5.2 useCounter - 计数器
// composables/useCounter.ts
import { ref, Ref } from 'vue'
interface UseCounterReturn {
count: Ref<number>
increment: () => void
decrement: () => void
reset: () => void
set: (value: number) => void
}
export function useCounter(initialValue = 0): UseCounterReturn {
const count = ref(initialValue)
const increment = () => {
count.value++
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = initialValue
}
const set = (value: number) => {
count.value = value
}
return { count, increment, decrement, reset, set }
}
// 使用
const { count, increment, decrement, reset } = useCounter(0)
5.3 useLocalStorage - 本地存储
// composables/useLocalStorage.ts
import { ref, watch, Ref } from 'vue'
export function useLocalStorage<T>(
key: string,
defaultValue: T
): Ref<T> {
// 从 localStorage 读取初始值
const storedValue = localStorage.getItem(key)
const value = ref<T>(
storedValue ? JSON.parse(storedValue) : defaultValue
) as Ref<T>
// 监听值的变化,自动保存到 localStorage
watch(
value,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
},
{ deep: true }
)
return value
}
// 使用
const token = useLocalStorage('auth-token', '')
const userPreferences = useLocalStorage('user-prefs', { theme: 'light' })
5.4 useFetch - 数据请求
// composables/useFetch.ts
import { ref, Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<Error | null>
execute: () => Promise<void>
}
export function useFetch<T>(
url: string
): UseFetchReturn<T> {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const execute = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(url)
data.value = await response.json()
} catch (err) {
error.value = err as Error
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
// 使用
const { data, loading, error, execute } = useFetch('/api/users')
execute() // 发起请求
5.5 useDebounce - 防抖
// composables/useDebounce.ts
import { ref, watch, Ref } from 'vue'
export function useDebounce<T>(
value: Ref<T>,
delay = 500
): Ref<T> {
const debouncedValue = ref(value.value) as Ref<T>
let timer: number | null = null
watch(value, (newValue) => {
if (timer) {
clearTimeout(timer)
}
timer = window.setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
// 使用
const searchInput = ref('')
const debouncedSearch = useDebounce(searchInput, 300)
watch(debouncedSearch, (value) => {
// 执行搜索请求
console.log('搜索:', value)
})
5.6 useMousePosition - 鼠标位置
// composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
const updatePosition = (event: MouseEvent) => {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => {
window.addEventListener('mousemove', updatePosition)
})
onUnmounted(() => {
window.removeEventListener('mousemove', updatePosition)
})
return { x, y }
}
// 使用
const { x, y } = useMousePosition()
5.7 useOnline - 网络状态
// composables/useOnline.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useOnline() {
const isOnline = ref(navigator.onLine)
const updateOnlineStatus = () => {
isOnline.value = navigator.onLine
}
onMounted(() => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
return { isOnline }
}
// 使用
const { isOnline } = useOnline()
5.8 useWindowSize - 窗口尺寸
// composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
const updateSize = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
window.addEventListener('resize', updateSize)
})
onUnmounted(() => {
window.removeEventListener('resize', updateSize)
})
return { width, height }
}
// 使用
const { width, height } = useWindowSize()
5.9 useClipboard - 剪贴板
// composables/useClipboard.ts
import { ref } from 'vue'
export function useClipboard() {
const copied = ref(false)
const error = ref<Error | null>(null)
const copy = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
copied.value = true
error.value = null
// 2秒后重置状态
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
error.value = err as Error
copied.value = false
}
}
return { copied, error, copy }
}
// 使用
const { copied, copy } = useClipboard()
const handleCopy = () => {
copy('复制的文本')
}
5.10 useAsync - 异步操作
// composables/useAsync.ts
import { ref, Ref } from 'vue'
interface UseAsyncReturn<T> {
data: Ref<T | null>
loading: Ref<boolean>
error: Ref<Error | null>
execute: (...args: any[]) => Promise<T>
}
export function useAsync<T>(
asyncFunction: (...args: any[]) => Promise<T>
): UseAsyncReturn<T> {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const execute = async (...args: any[]): Promise<T> => {
loading.value = true
error.value = null
try {
const result = await asyncFunction(...args)
data.value = result
return result
} catch (err) {
error.value = err as Error
throw err
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
// 使用
const fetchUserById = (id: number) => {
return fetch(`/api/users/${id}`).then(res => res.json())
}
const { data: user, loading, error, execute } = useAsync(fetchUserById)
// 调用
execute(1) // 获取 ID 为 1 的用户
🔄 六、从 Options API 迁移到 Composition API
6.1 迁移步骤
步骤1:安装 <script setup> 语法
<!-- 之前 -->
<script>
export default {
// ...
}
</script>
<!-- 之后 -->
<script setup lang="ts">
// 直接写代码,不需要 export default
</script>
步骤2:迁移 data
<!-- 之前 -->
<script>
export default {
data() {
return {
count: 0,
message: 'Hello'
}
}
}
</script>
<!-- 之后 -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const message = ref('Hello')
</script>
步骤3:迁移 computed
<!-- 之前 -->
<script>
export default {
data() {
return {
firstName: 'John',
lastName: 'Doe'
}
},
computed: {
fullName() {
return `${this.firstName} ${this.lastName}`
}
}
}
</script>
<!-- 之后 -->
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
</script>
步骤4:迁移 methods
<!-- 之前 -->
<script>
export default {
methods: {
handleClick() {
console.log('clicked')
}
}
}
</script>
<!-- 之后 -->
<script setup>
const handleClick = () => {
console.log('clicked')
}
</script>
步骤5:迁移生命周期钩子
<!-- 之前 -->
<script>
export default {
mounted() {
console.log('mounted')
},
unmounted() {
console.log('unmounted')
}
}
</script>
<!-- 之后 -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
console.log('mounted')
})
onUnmounted(() => {
console.log('unmounted')
})
</script>
6.2 完整迁移示例
迁移前(Options API):
<template>
<div class="todo-app">
<input v-model="newTodo" @keyup.enter="addTodo" />
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.completed" />
{{ todo.text }}
<button @click="removeTodo(todo.id)">删除</button>
</li>
</ul>
<div>
<button @click="filter = 'all'">全部</button>
<button @click="filter = 'active'">未完成</button>
<button @click="filter = 'completed'">已完成</button>
</div>
<p>剩余: {{ remaining }}</p>
</div>
</template>
<script>
export default {
data() {
return {
newTodo: '',
todos: [],
filter: 'all',
nextId: 1
}
},
computed: {
filteredTodos() {
if (this.filter === 'active') {
return this.todos.filter(t => !t.completed)
} else if (this.filter === 'completed') {
return this.todos.filter(t => t.completed)
}
return this.todos
},
remaining() {
return this.todos.filter(t => !t.completed).length
}
},
methods: {
addTodo() {
if (this.newTodo.trim()) {
this.todos.push({
id: this.nextId++,
text: this.newTodo,
completed: false
})
this.newTodo = ''
}
},
removeTodo(id) {
this.todos = this.todos.filter(t => t.id !== id)
}
}
}
</script>
迁移后(Composition API):
<template>
<div class="todo-app">
<input v-model="newTodo" @keyup.enter="addTodo" />
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.completed" />
{{ todo.text }}
<button @click="removeTodo(todo.id)">删除</button>
</li>
</ul>
<div>
<button @click="filter = 'all'">全部</button>
<button @click="filter = 'active'">未完成</button>
<button @click="filter = 'completed'">已完成</button>
</div>
<p>剩余: {{ remaining }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// 状态
const newTodo = ref('')
const todos = ref<Array<{ id: number; text: string; completed: boolean }>>([])
const filter = ref<'all' | 'active' | 'completed'>('all')
const nextId = ref(1)
// 计算属性
const filteredTodos = computed(() => {
if (filter.value === 'active') {
return todos.value.filter(t => !t.completed)
} else if (filter.value === 'completed') {
return todos.value.filter(t => t.completed)
}
return todos.value
})
const remaining = computed(() => {
return todos.value.filter(t => !t.completed).length
})
// 方法
const addTodo = () => {
if (newTodo.value.trim()) {
todos.value.push({
id: nextId.value++,
text: newTodo.value,
completed: false
})
newTodo.value = ''
}
}
const removeTodo = (id: number) => {
todos.value = todos.value.filter(t => t.id !== id)
}
</script>
进一步优化:抽取 Composable
// composables/useTodos.ts
import { ref, computed, Ref } from 'vue'
interface Todo {
id: number
text: string
completed: boolean
}
export function useTodos() {
const todos = ref<Todo[]>([])
const nextId = ref(1)
const addTodo = (text: string) => {
if (text.trim()) {
todos.value.push({
id: nextId.value++,
text: text,
completed: false
})
}
}
const removeTodo = (id: number) => {
todos.value = todos.value.filter(t => t.id !== id)
}
const toggleTodo = (id: number) => {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
const remaining = computed(() => {
return todos.value.filter(t => !t.completed).length
})
return {
todos,
addTodo,
removeTodo,
toggleTodo,
remaining
}
}
// 在组件中使用
<script setup lang="ts">
import { useTodos } from '@/composables/useTodos'
const { todos, addTodo, removeTodo, remaining } = useTodos()
</script>
📝 七、最佳实践总结
7.1 代码组织最佳实践
✅ 推荐:按功能组织代码
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
// ========== 1. 导入依赖 ==========
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
// ========== 2. 外部数据(Store、Router等) ==========
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// ========== 3. 组件状态 ==========
const isLoading = ref(false)
const errorMessage = ref('')
const formData = ref({
username: '',
email: ''
})
// ========== 4. 计算属性 ==========
const isFormValid = computed(() => {
return formData.value.username && formData.value.email
})
// ========== 5. 方法 ==========
const handleSubmit = async () => {
// 提交逻辑
}
const resetForm = () => {
formData.value = { username: '', email: '' }
}
// ========== 6. 侦听器 ==========
watch(() => route.params.id, (newId) => {
// 路由变化处理
})
// ========== 7. 生命周期 ==========
onMounted(() => {
// 初始化逻辑
})
</script>
为什么这样组织?
- 清晰的代码结构,一眼就能找到想要的内容
- 符合从外到内、从上到下的阅读习惯
- 方便后期维护和重构
❌ 不推荐:代码混乱排列
<script setup lang="ts">
// ❌ 糟糕的代码组织
const handleClick = () => { } // 方法
const count = ref(0) // 状态
import { ref } from 'vue' // 导入放在中间?
const double = computed(...) // 计算属性
onMounted(() => { }) // 生命周期
const name = ref('') // 又是状态
// 难以阅读和维护!
</script>
7.2 Composable 函数设计原则
✅ 推荐:单一职责原则
// ✅ 好的设计:每个 Composable 只做一件事
// composables/useUser.ts
export function useUser(userId: Ref<string>) {
const user = ref(null)
const loading = ref(false)
const fetchUser = async () => {
loading.value = true
try {
user.value = await api.getUser(userId.value)
} finally {
loading.value = false
}
}
return { user, loading, fetchUser }
}
// composables/useUserPermissions.ts
export function useUserPermissions(user: Ref<User>) {
const hasPermission = (permission: string) => {
return user.value?.permissions.includes(permission)
}
return { hasPermission }
}
// 组件中使用
const userId = ref('123')
const { user, loading, fetchUser } = useUser(userId)
const { hasPermission } = useUserPermissions(user)
❌ 不推荐:一个 Composable 做太多事情
// ❌ 糟糕的设计:什么都塞进一个 Composable
export function useUser() {
// 用户数据
const user = ref(null)
// 权限判断
const hasPermission = () => { }
// 购物车
const cart = ref([])
// 订单
const orders = ref([])
// 收货地址
const addresses = ref([])
// ... 太多了!
return { /* 一大堆 */ }
}
7.3 命名规范
// ✅ 好的命名
// Composable 以 use 开头
export function useMousePosition() { }
export function useFetch() { }
// 事件处理函数以 handle 或 on 开头
const handleClick = () => { }
const onSubmit = () => { }
// 布尔值以 is/has/should 开头
const isLoading = ref(false)
const hasPermission = ref(true)
const shouldShowModal = ref(false)
// 异步函数明确标注
const fetchUserData = async () => { }
const loadMorePosts = async () => { }
// ❌ 糟糕的命名
const get = () => { } // 太模糊
const flag = ref(false) // 不知道是什么
const data = ref(null) // 太泛化
const fn = () => { } // 毫无意义
7.4 性能优化最佳实践
✅ 1. 避免不必要的响应式
// ❌ 不好:常量也用 ref
const API_URL = ref('https://api.example.com') // 这是常量,不需要响应式
// ✅ 好:只对需要响应的数据使用 ref
const API_URL = 'https://api.example.com' // 普通常量
const userList = ref([]) // 需要响应式
✅ 2. 合理使用 computed 缓存
// ❌ 不好:每次都重新计算
const getExpensiveValue = () => {
return items.value.filter(/* 复杂计算 */)
}
// ✅ 好:使用 computed 缓存结果
const expensiveValue = computed(() => {
return items.value.filter(/* 复杂计算 */)
})
✅ 3. 大列表渲染优化
<template>
<!-- ❌ 不好:一次渲染所有数据 -->
<div v-for="item in allItems" :key="item.id">
{{ item.name }}
</div>
<!-- ✅ 好:虚拟滚动或分页 -->
<div v-for="item in visibleItems" :key="item.id">
{{ item.name }}
</div>
</template>
<script setup>
// 只渲染可见的项
const visibleItems = computed(() => {
const start = currentPage.value * pageSize
const end = start + pageSize
return allItems.value.slice(start, end)
})
</script>
✅ 4. watch 的性能优化
// ❌ 不好:深度监听大对象
watch(
bigObject,
() => { /* ... */ },
{ deep: true } // 性能杀手!
)
// ✅ 好:只监听需要的属性
watch(
() => bigObject.value.specificProperty,
() => { /* ... */ }
)
// ✅ 或者:使用 watchEffect,只追踪用到的属性
watchEffect(() => {
console.log(bigObject.value.specificProperty)
// 只有 specificProperty 变化才会触发
})
7.5 TypeScript 类型定义最佳实践
// ✅ 定义清晰的类型
interface User {
id: number
name: string
email: string
role: 'admin' | 'user' | 'guest' // 使用联合类型
createdAt: Date
}
interface ApiResponse<T> {
code: number
message: string
data: T
}
// 使用泛型
const fetchUser = async (id: number): Promise<ApiResponse<User>> => {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
// Composable 的返回类型
interface UseUserReturn {
user: Ref<User | null>
loading: Ref<boolean>
error: Ref<Error | null>
fetchUser: (id: number) => Promise<void>
updateUser: (data: Partial<User>) => Promise<void>
}
export function useUser(): UseUserReturn {
// ...
}
7.6 错误处理最佳实践
// ✅ 统一的错误处理
const useSafeAsync = <T>(asyncFn: () => Promise<T>) => {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const execute = async () => {
loading.value = true
error.value = null
try {
data.value = await asyncFn()
} catch (err) {
error.value = err as Error
// 统一错误处理
if (err.response?.status === 401) {
router.push('/login')
} else if (err.response?.status === 403) {
ElMessage.error('无权限访问')
} else {
ElMessage.error(err.message || '操作失败')
}
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
// 使用
const { data: users, loading, execute } = useSafeAsync(() =>
fetch('/api/users').then(r => r.json())
)
7.7 常见陷阱和解决方案
陷阱 1:reactive 解构失去响应式
// ❌ 错误
const state = reactive({ count: 0 })
const { count } = state // count 失去响应式!
// ✅ 解决方案1:使用 toRefs
import { toRefs } from 'vue'
const { count } = toRefs(state) // count 保持响应式
// ✅ 解决方案2:直接使用 ref
const count = ref(0)
陷阱 2:在 setup 外部定义响应式数据
// ❌ 错误:在组件外定义状态会被共享
const count = ref(0)
export default {
setup() {
return { count }
}
}
// 多个组件实例会共享同一个 count!
// ✅ 正确:在 setup 或 <script setup> 内定义
export default {
setup() {
const count = ref(0) // 每个实例独立
return { count }
}
}
陷阱 3:忘记清理副作用
// ❌ 错误:没有清理定时器
onMounted(() => {
setInterval(() => {
console.log('tick')
}, 1000)
})
// 组件卸载后定时器还在运行!
// ✅ 正确:清理副作用
onMounted(() => {
const timer = setInterval(() => {
console.log('tick')
}, 1000)
onUnmounted(() => {
clearInterval(timer)
})
})
// ✅ 更好:抽取成 Composable
const useInterval = (fn: () => void, delay: number) => {
let timer: number | null = null
onMounted(() => {
timer = window.setInterval(fn, delay)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
}
💡 八、完整实战:搭建一个文章管理系统
让我们从零开始,用 Composition API 构建一个生产级别的文章管理系统!
本章你将学会:
- 如何规划一个完整的 Vue3 项目
- Composable 函数的实战开发
- TypeScript 类型系统的应用
- Element Plus 组件的深度使用
- Mock 数据的最佳实践
- 从 Mock 到真实 API 的平滑切换
本章亮点
- 📝 15 篇精心准备的 Mock 文章数据
- 🔄 完整的增删改查功能
- 🔍 实时搜索(带防抖优化)
- 📄 灵活的分页系统
- 🎨 美观的 UI 界面
- 💯 开箱即用,克隆即跑
8.0 开发前的准备
8.0.1 项目基础
本实战基于 Vue3 + Vite 项目架构,若尚未搭建项目,请先执行以下步骤:
bash
# 1. 创建 Vue3 + TypeScript 项目
pnpm create vite my-vue3-project --template vue-ts
# 2. 进入项目目录
cd my-vue3-project
# 3. 安装基础依赖
pnpm install
# 4. 安装 Element Plus 及自动导入工具
pnpm add element-plus axios @element-plus/icons-vue
pnpm add -D unplugin-vue-components unplugin-auto-import
💡 小贴士:为什么图标需要单独安装?
Element Plus 为减小包体积,将图标单独发布为 @element-plus/icons-vue 包。若不安装,按钮、输入框等组件中的图标将无法正常显示。
8.0.2 配置 Vite
修改 vite.config.ts,配置自动导入、路径别名等核心功能:
// vite.config.ts
import { defineConfig } 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'
export default defineConfig({
plugins: [
vue(),
// 自动导入 Vue API(如 ref、computed)和 Element Plus 组件
AutoImport({
imports: ['vue', 'vue-router', 'pinia'], // 自动导入的 API 集合
resolvers: [ElementPlusResolver()], // 自动导入 Element Plus 相关 API
dts: 'src/auto-imports.d.ts' // 生成类型声明文件
}),
// 自动注册 Element Plus 组件(无需手动 import)
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts' // 生成组件类型声明文件
})
],
// 路径别名配置(@ 指向 src 目录)
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
// 开发服务器配置
server: {
port: 3000, // 端口号
open: true // 启动后自动打开浏览器
}
})
配置说明:
AutoImport:避免手动导入ref、watch等 Vue API,减少冗余代码Components:自动注册 Element Plus 组件(如<el-table>、<el-form>)alias:简化文件导入路径(例:@/composables/useUser代替../composables/useUser)
8.0.3 配置 TypeScript
更新 tsconfig.app.json,添加路径别名的类型支持,避免 TypeScript 报错:
json
{
"compilerOptions": {
"baseUrl": ".", // 基础路径
"paths": {
"@/*": ["src/*"] // @ 别名映射到 src 目录
}
},
"include": ["src/**/*.vue", "src/**/*.ts"]
}
8.0.4 导入 Element Plus 样式
重要! Element Plus 组件需手动导入全局样式,否则会出现样式错乱(如弹窗错位、按钮无样式)。
在 src/main.ts 中添加样式导入:
// src/main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
// 导入 Element Plus 全局样式(必须)
import 'element-plus/dist/index.css'
// 挂载 App 实例
createApp(App).mount('#app')
8.0.5 清理全局样式
Vite 默认的 src/style.css 包含可能影响布局的样式(如 body { display: flex }),替换为以下全局样式重置代码:
css
/* src/style.css */
/* 1. 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box; /* 统一盒模型 */
}
html, body {
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased; /* 字体抗锯齿 */
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa; /* 浅色背景,提升可读性 */
color: #303133; /* 深色文字,符合视觉规范 */
}
/* 2. 滚动条美化(仅 WebKit 浏览器) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8; /* hover 时加深颜色 */
}
8.0.6 验证配置
执行以下命令启动项目,检查配置是否生效:
bash
pnpm dev
若浏览器自动打开 http://localhost:3000 且无报错,说明项目基础配置成功!
8.1 项目架构设计
在编写代码前,合理的架构设计能让后续开发更高效、维护更简单。好的架构需满足:逻辑清晰、模块解耦、代码可复用。
8.1.1 最终目录结构
plaintext
my-vue3-project/
├── src/
│ ├── api/ # API 接口层(统一管理 HTTP 请求)
│ │ ├── article.ts # 文章相关接口(增删改查)
│ │ └── user.ts # 用户相关接口(登录、权限等)
│ ├── components/ # 公共组件(可复用 UI 单元)
│ │ ├── ArticleList.vue # 文章列表表格组件
│ │ └── ArticleFormDialog.vue # 文章新建/编辑弹窗组件
│ ├── composables/ # Composable 函数(核心业务逻辑复用)
│ │ ├── useArticles.ts # 文章数据管理(CRUD 逻辑)
│ │ ├── usePagination.ts # 分页逻辑(通用分页状态+方法)
│ │ ├── useSearch.ts # 搜索筛选逻辑(关键词+状态+标签)
│ │ └── useDebounce.ts # 防抖工具(搜索输入优化)
│ ├── mock/ # Mock 数据层(模拟后端接口)
│ │ └── articles.ts # 文章 Mock 数据+工具函数
│ ├── types/ # TypeScript 类型定义(全局类型)
│ │ └── article.ts # 文章相关类型(接口、查询参数等)
│ ├── utils/ # 工具函数层(无副作用通用工具)
│ │ ├── request.ts # Axios 封装(请求拦截、错误处理)
│ │ └── format.ts # 格式工具(日期格式化、数据转换)
│ ├── views/ # 页面组件(整合业务逻辑的完整页面)
│ │ ├── ArticleManage.vue # 文章管理主页(核心页面)
│ │ └── login/
│ │ └── index.vue # 登录页面(扩展功能)
│ ├── App.vue # 根组件(页面入口)
│ ├── main.ts # 入口文件(初始化 App、导入依赖)
│ └── style.css # 全局样式(样式重置、全局主题)
├── vite.config.ts # Vite 构建配置
├── tsconfig.json # TypeScript 全局配置
└── package.json # 项目依赖配置
8.1.2 分层架构说明
采用自底向上的分层架构,每一层职责单一,依赖关系清晰:
plaintext
┌─────────────────────────────────┐
│ View 层(views/components) │ 👉 用户界面(仅负责渲染和交互)
├─────────────────────────────────┤
│ Composable 层(composables) │ 👉 业务逻辑(状态管理、逻辑复用)
├─────────────────────────────────┤
│ API 层(api) │ 👉 接口调用(统一封装 HTTP 请求)
├─────────────────────────────────┤
│ Utils 层(utils) │ 👉 工具函数(通用工具、无副作用)
└─────────────────────────────────┘
各层核心职责:
-
View 层:仅关注 UI 渲染,不处理复杂业务逻辑。通过
Props接收数据,通过Emits触发事件,与 Composable 层解耦。示例:
ArticleList.vue(仅展示文章列表,点击事件通过Emits通知父组件)。 -
Composable 层:核心业务逻辑封装层,是 Composition API 的核心价值。将分散的逻辑(如分页、搜索)抽取为独立函数,可跨组件复用。
示例:
usePagination.ts(封装分页状态和方法,任何列表组件都可复用)。 -
API 层:统一管理所有后端接口,避免接口调用散落在组件中。便于后续切换 Mock / 真实 API、统一错误处理。
示例:
api/article.ts(封装getArticleList、createArticle等接口)。 -
Utils 层:提供通用工具函数,无业务依赖、无副作用(输入相同则输出相同)。
示例:
request.ts(Axios 封装,处理请求拦截、响应错误)。
8.1.3 开发顺序
按从底层到上层的顺序开发,确保每一层都可独立测试,避免依赖混乱:
- Types(类型定义) → 定义数据结构(如文章接口、查询参数)
- Mock(模拟数据) → 准备测试数据,支持前端独立开发
- Utils(工具函数) → 封装通用工具(如 Axios 请求)
- API(接口层) → 封装接口调用(与后端约定接口格式)
- Composables(业务逻辑) → 封装核心逻辑(如文章 CRUD)
- Components(UI 组件) → 开发可复用 UI 单元(如列表、表单)
- Views(页面) → 整合所有层,组装完整业务页面
- Integration(集成测试) → 联调测试,验证功能完整性
8.2 第一步:定义类型系统
TypeScript 的核心价值是类型安全,提前定义清晰的类型,可减少 80% 的运行时错误,并获得 IDE 智能提示。
8.2.1 为什么要先定义类型?
无类型 vs 有类型的对比:
// ❌ 无类型:代码易出错,无智能提示
function createArticle(data) {
// data 有哪些字段?title 是否必填?status 可选值有哪些?
// 完全靠开发者记忆,极易出错
}
// ✅ 有类型:一目了然,编译时校验
import type { Article } from '@/types/article'
function createArticle(data: Omit<Article, 'id' | 'createdAt' | 'updatedAt'>) {
// IDE 自动提示 data 的字段(title、content、author 等)
// 若缺少必填字段,TypeScript 编译时直接报错
// 重构时更安全,修改类型后所有引用处都会提示
}
类型定义的核心价值:
- 🔍 IDE 智能提示:减少查文档的时间
- ⚠️ 编译时校验:提前发现错误,避免运行时崩溃
- 📚 代码即文档:类型定义就是最准确的接口文档
- 🔄 安全重构:修改字段时,所有引用处实时反馈
8.2.2 创建类型定义文件
创建 src/types/article.ts,定义文章相关的所有类型:
// src/types/article.ts
/**
* 文章核心数据结构
*/
export interface Article {
id: number; // 文章唯一 ID(后端生成)
title: string; // 文章标题(必填)
content: string; // 文章内容(必填)
author: string; // 作者名称(必填)
status: 'draft' | 'published'; // 文章状态(仅支持“草稿”或“已发布”)
tags: string[]; // 文章标签(数组,如 ['Vue3', 'TypeScript'])
createdAt: string; // 创建时间(后端生成,格式:YYYY-MM-DD HH:mm:ss)
updatedAt: string; // 更新时间(后端生成,格式同上)
}
/**
* 文章列表查询参数
*/
export interface ArticleQuery {
page: number; // 当前页码(必填,默认 1)
pageSize: number; // 每页条数(必填,默认 10)
keyword?: string; // 搜索关键词(可选,搜索标题/内容)
status?: Article['status']; // 状态筛选(可选,仅筛选指定状态的文章)
tag?: string; // 标签筛选(可选,仅筛选包含指定标签的文章)
}
/**
* 分页数据通用结构
*/
export interface PaginationData {
total: number; // 总条数(满足条件的所有数据量)
page: number; // 当前页码
pageSize: number; // 每页条数
}
/**
* API 响应通用格式(所有后端接口返回都遵循此格式)
*/
export interface ApiResponse<T = any> {
code: number; // 状态码(200 表示成功,其他表示错误)
message: string; // 提示信息(成功/错误描述)
data: T; // 响应数据(泛型,根据接口动态变化)
}
/**
* 文章列表接口响应数据
*/
export interface ArticleListData {
items: Article[]; // 文章列表数组
total: number; // 符合条件的文章总条数
}
8.2.3 类型设计要点
-
用联合类型限制可选值
避免使用宽泛的
string,明确字段的可选范围,减少非法值:
typescript// ✅ 好:仅支持两种状态 status: 'draft' | 'published'; // ❌ 差:任何字符串都可传入,易出错 status: string; -
用可选属性标记非必填字段
用
?明确区分必填 / 非必填字段,避免歧义:
typescriptexport interface ArticleQuery { page: number; // 必填(无 ?) pageSize: number; // 必填 keyword?: string; // 可选(有 ?) } -
用泛型提升复用性
定义通用类型(如
ApiResponse),通过泛型适配不同接口的响应数据:
typescript// 通用 API 响应格式 export interface ApiResponse<T = any> { code: number; message: string; data: T; // 泛型 T 动态适配不同数据结构 } // 使用时指定具体类型 const listRes: ApiResponse<ArticleListData> = await api.getArticleList(); const detailRes: ApiResponse<Article> = await api.getArticleDetail(1); -
用 TypeScript 工具类型简化定义
利用
Omit、Partial等工具类型,减少重复代码:
typescript// Omit:排除 Article 中的部分字段(创建文章时无需传入 id/createdAt) type CreateArticleData = Omit<Article, 'id' | 'createdAt' | 'updatedAt'>; // Partial:将所有字段改为可选(更新文章时可只传需要修改的字段) type UpdateArticleData = Partial<Article>; // Pick:只选择 Article 中的部分字段(文章预览时只需 id/title/author) type ArticlePreview = Pick<Article, 'id' | 'title' | 'author'>;
8.2.4 类型实战应用示例
类型定义需在全栈流程中复用,以下是关键场景的应用:
1. API 函数中使用(参数 + 返回值类型)
// src/api/article.ts
import request from '@/utils/request'
import type { Article, ArticleQuery, ApiResponse, ArticleListData } from '@/types/article'
// 获取文章列表:参数类型为 ArticleQuery,返回值类型为 ApiResponse<ArticleListData>
export function getArticleList(params: ArticleQuery) {
return request<ApiResponse<ArticleListData>>({
url: '/articles',
method: 'get',
params
})
}
2. Composable 中使用(状态 + 函数参数类型)
// src/composables/useArticles.ts
import { ref } from 'vue'
import type { Article, ArticleQuery } from '@/types/article'
export function useArticles() {
// 明确 articles 是 Article 类型的数组
const articles = ref<Article[]>([])
// 明确 fetchArticles 的参数类型
const fetchArticles = async (query: ArticleQuery) => {
const res = await getArticleList(query)
articles.value = res.data.items // IDE 自动提示 res.data.items 的结构
}
return { articles, fetchArticles }
}
3. 组件中使用(Props+Emits 类型)
vue
<!-- src/components/ArticleFormDialog.vue -->
<script setup lang="ts">
import type { Article } from '@/types/article'
// Props 类型安全:明确接收的 article 是 Article 类型
defineProps<{
article?: Article | null;
}>()
// Emits 类型安全:明确 submit 事件传递的数据类型
defineEmits<{
submit: [data: Omit<Article, 'id' | 'createdAt' | 'updatedAt'>];
}>()
</script>
8.3 第二步:准备 Mock 数据
在后端接口未就绪时,使用 Mock 数据可实现前后端并行开发,无需等待后端即可验证前端功能。
8.3.1 为什么需要 Mock 数据?
| 对比维度 | Mock 数据 | 真实 API |
|---|---|---|
| 开发速度 | ✔️ 快(前端独立控制) | ❌ 慢(依赖后端开发进度) |
| 数据可控性 | ✔️ 完全可控(可模拟任何场景) | ❌ 依赖后端返回,难以模拟异常场景 |
| 离线开发 | ✔️ 支持(无需后端服务器) | ❌ 不支持(需联网访问后端) |
| 演示稳定性 | ✔️ 稳定(数据本地生成) | ❌ 不稳定(后端可能挂掉或返回错误) |
| 单元测试友好度 | ✔️ 友好(可固定输入输出) | ❌ 不友好(依赖网络和后端状态) |
8.3.2 创建 Mock 数据文件
创建 src/mock/articles.ts,生成 15 篇模拟文章数据,并提供筛选、分页工具函数:
// src/mock/articles.ts
import type { Article } from '@/types/article'
/**
* 生成 15 篇模拟文章数据
* 包含不同状态、标签、作者,模拟真实业务场景
*/
export const mockArticles: Article[] = [
{
id: 1,
title: 'Vue3 Composition API 完全指南',
content: '深入浅出讲解 Vue3 组合式 API 的核心概念,包括 ref、reactive、computed、watch 等核心 API 的使用方法和最佳实践。通过实战案例演示如何用 Composable 函数封装可复用逻辑,解决 Options API 中逻辑分散的问题。',
author: '张三',
status: 'published',
tags: ['Vue3', 'JavaScript', '前端'],
createdAt: '2025-01-20 10:30:00',
updatedAt: '2025-01-22 15:20:00'
},
{
id: 2,
title: 'TypeScript 高级类型技巧',
content: '探索 TypeScript 中的高级类型特性,包括泛型、联合类型、交叉类型、条件类型等。通过实际场景讲解如何用工具类型简化类型定义,提升代码可维护性。',
author: '李四',
status: 'published',
tags: ['TypeScript', '前端', '类型安全'],
createdAt: '2025-01-19 14:20:00',
updatedAt: '2025-01-21 09:15:00'
},
{
id: 3,
title: 'Pinia 状态管理实战',
content: 'Pinia 是 Vue3 推荐的状态管理库,本文将介绍 Pinia 的核心概念、使用方法和最佳实践。包括如何定义 Store、处理异步操作、模块化管理状态,以及与 Vue DevTools 的集成。',
author: '王五',
status: 'draft', // 草稿状态
tags: ['Vue3', 'Pinia', '状态管理'],
createdAt: '2025-01-18 16:45:00',
updatedAt: '2025-01-23 11:30:00'
},
// 以下省略 12 篇文章(实际项目中需补充至 15 篇,覆盖更多状态和标签)
{
id: 4,
title: 'Element Plus 组件二次封装技巧',
content: '讲解如何基于 Element Plus 封装业务组件,包括表格、表单、弹窗等常用组件。重点介绍 props 设计、事件透传、插槽复用等技巧,提升组件复用性。',
author: '赵六',
status: 'published',
tags: ['Element Plus', 'Vue3', '组件封装'],
createdAt: '2025-01-17 09:10:00',
updatedAt: '2025-01-20 14:40:00'
},
// ... 其余 11 篇文章
]
8.3.3 创建 Mock 工具函数
在同一文件中添加 筛选 和 分页 工具函数,模拟后端接口的核心逻辑:
// src/mock/articles.ts(续)
import type { Article, ArticleQuery } from '@/types/article'
/**
* 根据查询条件筛选文章
* @param articles - 原始文章数组
* @param keyword - 搜索关键词(可选)
* @param status - 状态筛选(可选)
* @param tag - 标签筛选(可选)
* @returns 筛选后的文章数组
*/
export function filterArticles(
articles: Article[],
keyword?: string,
status?: Article['status'],
tag?: string
): Article[] {
let filtered = [...articles] // 复制数组,避免修改原始数据
// 1. 关键词筛选(搜索标题和内容,不区分大小写)
if (keyword) {
const lowerKeyword = keyword.toLowerCase()
filtered = filtered.filter(article =>
article.title.toLowerCase().includes(lowerKeyword) ||
article.content.toLowerCase().includes(lowerKeyword)
)
}
// 2. 状态筛选(精确匹配)
if (status) {
filtered = filtered.filter(article => article.status === status)
}
// 3. 标签筛选(包含指定标签即可)
if (tag) {
filtered = filtered.filter(article => article.tags.includes(tag))
}
return filtered
}
/**
* 对文章数组进行分页处理
* @param articles - 筛选后的文章数组
* @param page - 当前页码
* @param pageSize - 每页条数
* @returns 分页后的结果(包含当前页数据和总条数)
*/
export function paginateArticles(
articles: Article[],
page: number,
pageSize: number
): { items: Article[], total: number } {
const startIndex = (page - 1) * pageSize // 计算起始索引
const endIndex = startIndex + pageSize // 计算结束索引
return {
items: articles.slice(startIndex, endIndex), // 当前页数据
total: articles.length // 总条数
}
}
8.3.4 Mock 数据在 Composable 中使用
在 useArticles.ts 中调用 Mock 工具函数,模拟接口请求逻辑:
// src/composables/useArticles.ts
import { ref } from 'vue'
import type { Article, ArticleQuery } from '@/types/article'
import { mockArticles, filterArticles, paginateArticles } from '@/mock/articles'
import { ElMessage } from 'element-plus'
export function useArticles() {
// 状态定义
const articles = ref<Article[]>([]) // 文章列表
const loading = ref(false) // 加载状态
const error = ref<Error | null>(null) // 错误信息
// Mock 数据存储(复制原始数据,避免修改 mockArticles)
let mockData = [...mockArticles]
/**
* 获取文章列表(Mock 版本)
* @param query - 查询参数(分页+筛选)
*/
const fetchArticles = async (query: ArticleQuery) => {
loading.value = true
error.value = null
try {
// 模拟网络延迟(让加载状态更真实)
await new Promise(resolve => setTimeout(resolve, 500))
// 1. 筛选数据
const filteredArticles = filterArticles(
mockData,
query.keyword,
query.status,
query.tag
)
// 2. 分页处理
const paginatedResult = paginateArticles(
filteredArticles,
query.page,
query.pageSize
)
// 3. 更新状态
articles.value = paginatedResult.items
return paginatedResult // 返回 { items, total }
} catch (err) {
error.value = err as Error
ElMessage.error('获取文章列表失败')
throw err // 抛出错误,让调用者处理
} finally {
loading.value = false // 无论成功失败,都关闭加载状态
}
}
// 后续将补充 createArticle、updateArticle 等 CRUD 方法...
return {
articles,
loading,
error,
fetchArticles
}
}
8.3.5 Mock 切换到真实 API 的平滑过渡
后续后端接口就绪后,只需修改 fetchArticles 中的逻辑,替换为真实 API 调用,无需改动其他代码:
// src/composables/useArticles.ts(切换真实 API 后)
import * as articleApi from '@/api/article' // 导入真实 API 函数
const fetchArticles = async (query: ArticleQuery) => {
loading.value = true
error.value = null
try {
// 替换为真实 API 调用
const res = await articleApi.getArticleList(query)
articles.value = res.data.items
return res.data // 真实 API 返回的 { items, total }
} catch (err) {
error.value = err as Error
ElMessage.error('获取文章列表失败')
throw err
} finally {
loading.value = false
}
}
💡 小贴士:切换时需确保 Mock 工具函数的返回格式(如 { items, total })与真实 API 一致,避免额外修改。
8.4 第三步:开发 Composable 函数
Composable 是 Vue3 组合式 API 的核心,用于抽取和复用业务逻辑。本节开发 4 个核心 Composable,覆盖分页、防抖、搜索、文章 CRUD 场景。
8.4.1 开发顺序说明
按从简单到复杂的顺序开发,确保每个函数职责单一、可独立测试:
usePagination(分页)→ 基础通用逻辑,无依赖useDebounce(防抖)→ 基础工具逻辑,无依赖useSearch(搜索筛选)→ 依赖useDebounceuseArticles(文章管理)→ 依赖 Mock 数据和前 3 个函数
8.4.2 usePagination - 分页逻辑复用
功能定位:封装分页的状态(当前页、每页条数、总条数)和操作(页码切换、页码重置),任何列表组件都可复用。
实现代码
// src/composables/usePagination.ts
import { ref, computed } from 'vue'
/**
* 分页 Composable
* @param initialPageSize - 初始每页条数(默认 10)
* @returns 分页状态和操作方法
*/
export function usePagination(initialPageSize = 10) {
// ===== 1. 响应式状态 =====
const currentPage = ref(1) // 当前页码(默认第 1 页)
const pageSize = ref(initialPageSize) // 每页条数(默认 10 条)
const total = ref(0) // 总条数(需外部设置)
// ===== 2. 计算属性(自动推导,无需手动更新) =====
// 总页数 = 总条数 / 每页条数,向上取整
const totalPages = computed(() => {
return Math.ceil(total.value / pageSize.value) || 1 // 避免 0 页
})
// 是否有上一页(当前页 > 1)
const hasPrevPage = computed(() => {
return currentPage.value > 1
})
// 是否有下一页(当前页 < 总页数)
const hasNextPage = computed(() => {
return currentPage.value < totalPages.value
})
// ===== 3. 操作方法 =====
/**
* 跳转到指定页码
* @param page - 目标页码
*/
const goToPage = (page: number) => {
// 边界校验:避免页码超出范围
if (page < 1 || page > totalPages.value) return
currentPage.value = page
}
/**
* 上一页
*/
const prevPage = () => {
if (hasPrevPage.value) {
currentPage.value--
}
}
/**
* 下一页
*/
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++
}
}
/**
* 重置分页(回到第 1 页)
* 用于搜索/筛选条件变化时
*/
const resetPagination = () => {
currentPage.value = 1
}
// ===== 4. 返回对外暴露的状态和方法 =====
return {
// 状态
currentPage,
pageSize,
total,
// 计算属性
totalPages,
hasPrevPage,
hasNextPage,
// 方法
goToPage,
prevPage,
nextPage,
resetPagination
}
}
核心亮点
- 边界校验:
goToPage中避免页码超出[1, totalPages]范围 - 计算属性自动更新:
totalPages、hasPrevPage随total/currentPage自动变化 - 通用性强:不依赖任何业务逻辑,表格、列表等场景都可复用
使用示例
// 在组件中使用
const {
currentPage,
pageSize,
total,
goToPage,
resetPagination
} = usePagination(10) // 初始每页 10 条
// 设置总条数(从接口返回中获取)
total.value = 100
// 跳转到第 3 页
goToPage(3)
// 搜索时重置到第 1 页
resetPagination()
8.4.3 useDebounce - 防抖优化
功能定位:防止用户频繁操作(如搜索输入、窗口 resize)导致过多请求,提升性能和用户体验。
实现代码
// src/composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'
/**
* 防抖 Composable
* @param value - 需要防抖的响应式值(如搜索关键词)
* @param delay - 延迟时间(毫秒,默认 300ms)
* @returns 防抖后的响应式值
*/
export function useDebounce<T>(value: Ref<T>, delay = 300) {
// 防抖后的结果值
const debouncedValue = ref(value.value) as Ref<T>
// 定时器引用(用于清除上一次的定时器)
let timer: ReturnType<typeof setTimeout> | null = null
// 监听原始值的变化
watch(value, (newValue) => {
// 清除上一次的定时器(如果存在)
if (timer) {
clearTimeout(timer)
}
// 设置新的定时器:延迟 delay 后更新 debouncedValue
timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
工作原理
以搜索输入为例,防抖流程如下:
plaintext
用户输入:V → Vu → Vue → Vue3
时间线: 0ms 100ms 200ms 300ms
防抖处理:
- 输入 V:启动定时器(300ms 后更新)
- 输入 Vu:清除上一个定时器,重新启动(300ms 后更新)
- 输入 Vue:清除上一个定时器,重新启动(300ms 后更新)
- 输入 Vue3:清除上一个定时器,重新启动(300ms 后更新)
- 300ms 无输入:更新 debouncedValue 为 Vue3,触发搜索请求
性能对比
| 场景 | 无防抖 | 有防抖(500ms) |
|---|---|---|
| 输入 “Vue3” | 4 次请求 | 1 次请求 |
| 输入 “TypeScript” | 10 次请求 | 1 次请求 |
| 服务器压力 | ❌ 大 | ✔️ 小 |
| 用户体验(页面卡顿) | ❌ 可能卡顿 | ✔️ 流畅 |
推荐延迟时间
- 搜索框输入:300-500ms(平衡响应速度和性能)
- 窗口 resize:100-200ms(快速响应布局变化)
- 输入验证(如手机号格式):500-800ms(避免频繁提示)
使用示例
// 在组件中使用(搜索框防抖)
const searchKeyword = ref('')
// 防抖后的关键词(延迟 500ms 更新)
const debouncedKeyword = useDebounce(searchKeyword, 500)
// 监听防抖后的关键词,触发搜索
watch(debouncedKeyword, (newKeyword) => {
console.log('搜索关键词:', newKeyword)
fetchArticles({ keyword: newKeyword }) // 仅在停止输入 500ms 后执行
})
8.4.4 useSearch - 搜索与筛选逻辑
功能定位:管理搜索和筛选的状态(关键词、标签、状态),组合 useDebounce 实现防抖搜索,统一筛选逻辑。
实现代码
// src/composables/useSearch.ts
import { ref, computed } from 'vue'
import type { Article } from '@/types/article'
import { useDebounce } from './useDebounce'
/**
* 搜索与筛选 Composable
* @returns 筛选状态、防抖后的关键词、操作方法
*/
export function useSearch() {
// ===== 1. 筛选状态(响应式) =====
const keyword = ref('') // 搜索关键词(实时输入)
const selectedTag = ref<string | undefined>() // 选中的标签(筛选)
const selectedStatus = ref<Article['status'] | undefined>() // 选中的状态(筛选)
// ===== 2. 防抖处理(关键词输入优化) =====
// 关键词停止输入 500ms 后才更新,避免频繁搜索
const debouncedKeyword = useDebounce(keyword, 500)
// ===== 3. 计算属性 =====
// 是否有激活的筛选条件(用于控制“重置筛选”按钮的显示)
const hasActiveFilters = computed(() => {
return !!keyword.value || !!selectedTag.value || !!selectedStatus.value
})
// ===== 4. 操作方法 =====
/**
* 重置所有筛选条件
*/
const resetFilters = () => {
keyword.value = ''
selectedTag.value = undefined
selectedStatus.value = undefined
}
// ===== 5. 返回对外暴露的状态和方法 =====
return {
// 原始筛选状态(用于绑定到 UI 组件)
keyword,
selectedTag,
selectedStatus,
// 防抖后的关键词(用于触发搜索)
debouncedKeyword,
// 计算属性
hasActiveFilters,
// 方法
resetFilters
}
}
核心逻辑
useSearch 与 useDebounce 的组合流程:
- 用户输入关键词 →
keyword实时更新 useDebounce监听keyword→ 延迟 500ms 后更新debouncedKeyword- 组件监听
debouncedKeyword→ 变化时触发搜索请求 - 最终效果:输入流畅,仅在停止输入后发起一次请求
使用示例
// 在组件中使用
const {
keyword,
selectedStatus,
debouncedKeyword,
hasActiveFilters,
resetFilters
} = useSearch()
// 监听防抖后的关键词,触发搜索
watch(debouncedKeyword, () => {
fetchArticles()
})
// 监听状态筛选变化,触发搜索
watch(selectedStatus, () => {
fetchArticles()
})
// 点击“重置筛选”按钮
const handleReset = () => {
resetFilters()
fetchArticles()
}
8.4.5 useArticles - 文章数据管理(核心)
功能定位:封装文章的完整 CRUD 逻辑(获取列表、创建、更新、删除、批量删除、发布),是业务逻辑的核心。
实现代码
// src/composables/useArticles.ts
import { ref } from 'vue'
import type { Article, ArticleQuery } from '@/types/article'
import { mockArticles, filterArticles, paginateArticles } from '@/mock/articles'
import { ElMessage } from 'element-plus'
/**
* 文章数据管理 Composable
* @returns 文章状态和 CRUD 方法
*/
export function useArticles() {
// ===== 1. 响应式状态 =====
const articles = ref<Article[]>([]) // 文章列表数据
const loading = ref(false) // 加载状态(控制加载动画)
const error = ref<Error | null>(null) // 错误信息(用于错误提示)
// ===== 2. Mock 数据存储 =====
// 复制原始 Mock 数据,避免直接修改 mockArticles(确保数据可重置)
let mockData = [...mockArticles]
// ===== 3. CRUD 方法 =====
/**
* 1. 获取文章列表(Mock 版本)
* @param query - 查询参数(分页+筛选)
* @returns 分页后的结果(items: 文章列表, total: 总条数)
*/
const fetchArticles = async (query: ArticleQuery) => {
loading.value = true
error.value = null
try {
// 模拟网络延迟(500ms)
await new Promise(resolve => setTimeout(resolve, 500))
// 步骤 1:根据查询条件筛选数据
const filtered = filterArticles(
mockData,
query.keyword,
query.status,
query.tag
)
// 步骤 2:分页处理
const paginated = paginateArticles(filtered, query.page, query.pageSize)
// 步骤 3:更新文章列表状态
articles.value = paginated.items
return paginated // 返回分页结果(供外部获取 total)
} catch (err) {
error.value = err as Error
ElMessage.error('获取文章列表失败')
throw err // 抛出错误,让调用者处理
} finally {
loading.value = false // 无论成功失败,关闭加载状态
}
}
/**
* 2. 创建文章(Mock 版本)
* @param article - 文章数据(不含 id/createdAt/updatedAt)
* @returns 新创建的文章(含 id/createdAt/updatedAt)
*/
const createArticle = async (article: Omit<Article, 'id' | 'createdAt' | 'updatedAt'>) => {
loading.value = true
try {
// 模拟网络延迟(300ms)
await new Promise(resolve => setTimeout(resolve, 300))
// 生成新文章(补充 id 和时间戳)
const newArticle: Article = {
...article,
id: Date.now(), // 用时间戳作为临时 ID(模拟后端生成)
createdAt: new Date().toISOString().replace('T', ' ').slice(0, 19), // 格式:YYYY-MM-DD HH:mm:ss
updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19)
}
// 更新 Mock 数据和当前列表
mockData.unshift(newArticle) // 新增数据放最前面
articles.value.unshift(newArticle)
ElMessage.success('文章创建成功')
return newArticle
} catch (err) {
error.value = err as Error
ElMessage.error('文章创建失败')
throw err
} finally {
loading.value = false
}
}
/**
* 3. 更新文章(Mock 版本)
* @param id - 文章 ID
* @param updates - 需要更新的字段(部分更新)
* @returns 更新后的文章
*/
const updateArticle = async (id: number, updates: Partial<Article>) => {
loading.value = true
try {
// 模拟网络延迟(300ms)
await new Promise(resolve => setTimeout(resolve, 300))
// 查找文章在 Mock 数据中的索引
const mockIndex = mockData.findIndex(item => item.id === id)
if (mockIndex === -1) {
throw new Error(`未找到 ID 为 ${id} 的文章`)
}
// 更新 Mock 数据(保留原有字段,覆盖需要更新的字段)
mockData[mockIndex] = {
...mockData[mockIndex],
...updates,
updatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19) // 更新时间戳
}
// 更新当前列表中的对应文章
const listIndex = articles.value.findIndex(item => item.id === id)
if (listIndex !== -1) {
articles.value[listIndex] = mockData[mockIndex]
}
ElMessage.success('文章更新成功')
return mockData[mockIndex]
} catch (err) {
error.value = err as Error
ElMessage.error('文章更新失败')
throw err
} finally {
loading.value = false
}
}
/**
* 4. 删除文章(Mock 版本)
* @param id - 文章 ID
*/
const deleteArticle = async (id: number) => {
loading.value = true
try {
// 模拟网络延迟(300ms)
await new Promise(resolve => setTimeout(resolve, 300))
// 从 Mock 数据中删除
const initialLength = mockData.length
mockData = mockData.filter(item => item.id !== id)
// 从当前列表中删除
articles.value = articles.value.filter(item => item.id !== id)
// 验证是否删除成功
if (mockData.length === initialLength) {
throw new Error(`未找到 ID 为 ${id} 的文章`)
}
ElMessage.success('文章删除成功')
} catch (err) {
error.value = err as Error
ElMessage.error('文章删除失败')
throw err
} finally {
loading.value = false
}
}
/**
* 5. 批量删除文章(Mock 版本)
* @param ids - 文章 ID 数组
*/
const batchDelete = async (ids: number[]) => {
loading.value = true
try {
// 模拟网络延迟(300ms)
await new Promise(resolve => setTimeout(resolve, 300))
// 从 Mock 数据中批量删除
const initialLength = mockData.length
mockData = mockData.filter(item => !ids.includes(item.id))
// 从当前列表中批量删除
articles.value = articles.value.filter(item => !ids.includes(item.id))
// 验证删除数量
const deletedCount = initialLength - mockData.length
if (deletedCount === 0) {
throw new Error('未找到需要删除的文章')
}
ElMessage.success(`成功删除 ${deletedCount} 篇文章`)
} catch (err) {
error.value = err as Error
ElMessage.error('批量删除失败')
throw err
} finally {
loading.value = false
}
}
/**
* 6. 发布文章(Mock 版本)
* @param id - 文章 ID
* @returns 更新后的文章(状态变为 published)
*/
const publishArticle = async (id: number) => {
// 复用 updateArticle 方法,仅更新 status 字段
return updateArticle(id, { status: 'published' })
}
// ===== 4. 返回对外暴露的状态和方法 =====
return {
// 状态
articles,
loading,
error,
// CRUD 方法
fetchArticles,
createArticle,
updateArticle,
deleteArticle,
batchDelete,
publishArticle
}
}
核心设计亮点
- Mock 数据隔离:用
mockData = [...mockArticles]复制原始数据,避免修改全局 Mock 数据,确保多次创建 / 删除后可重置。 - 错误处理统一:所有异步方法都通过
try/catch捕获错误,并通过ElMessage提示用户,同时抛出错误让调用者处理。 - 方法复用:
publishArticle复用updateArticle,避免重复代码(发布本质是更新文章状态)。 - 状态同步:创建 / 更新 / 删除后,同时更新
mockData和articles,确保 UI 实时刷新。
8.4.6 Composable 函数关系总结
4 个 Composable 函数的依赖关系和职责划分如下:
| Composable 函数 | 核心职责 | 依赖关系 |
|---|---|---|
usePagination | 分页状态 + 操作 | 无(独立) |
useDebounce | 防抖处理 | 无(独立) |
useSearch | 搜索筛选状态 + 防抖 | 依赖useDebounce |
useArticles | 文章 CRUD 逻辑 | 依赖 Mock 数据 |
在主页面中组合使用时,流程如下:
plaintext
ArticleManage.vue(主页面)
├── usePagination → 提供分页状态(currentPage、pageSize)
├── useSearch → 提供筛选状态(debouncedKeyword、selectedStatus)
└── useArticles → 提供文章 CRUD 方法(fetchArticles、createArticle)
└── Mock 数据 → 提供模拟数据支持
8.5 第四步:创建 API 接口层(可选)
虽然当前使用 Mock 数据,但提前创建 API 接口层可让后续切换真实 API 更平滑。API 层的核心是统一管理接口调用,避免接口散落在组件中。
8.5.1 为什么需要 API 接口层?
- 统一管理:所有接口集中在一个目录,便于维护(如修改接口路径、参数格式)。
- 类型安全:明确接口的参数和返回值类型,与 TypeScript 无缝集成。
- 易于切换:Mock 数据和真实 API 的切换只需修改 API 层,无需改动业务逻辑。
- 便于测试:可单独 Mock API 函数,简化单元测试。
8.5.2 封装 Axios 工具(基础)
首先创建 src/utils/request.ts,封装 Axios 实例,处理请求拦截、响应拦截、错误统一处理:
// src/utils/request.ts
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import type { ApiResponse } from '@/types/article'
// 创建 Axios 实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量获取基础 URL
timeout: 10000, // 超时时间(10秒)
headers: {
'Content-Type': 'application/json' // 默认请求头
}
})
// 1. 请求拦截器:添加 Token 等公共参数
request.interceptors.request.use(
(config) => {
// 示例:添加用户 Token(实际项目中从本地存储获取)
const token = localStorage.getItem('auth-token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
// 请求错误处理
ElMessage.error('请求发送失败,请检查网络')
return Promise.reject(error)
}
)
// 2. 响应拦截器:统一处理响应格式和错误
request.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
// 业务错误:状态码不为 200
if (res.code !== 200) {
ElMessage.error(res.message || '操作失败,请重试')
return Promise.reject(new Error(res.message || 'Unknown error'))
}
// 成功:返回响应数据中的 data 字段
return res.data
},
(error) => {
// 网络错误或服务器错误
let message = '网络错误,请检查网络连接'
if (error.response) {
// 根据状态码提示不同信息
switch (error.response.status) {
case 401:
message = '登录已过期,请重新登录'
// 示例:跳转到登录页(需结合 Vue Router)
// router.push('/login')
break
case 403:
message = '无权限访问此资源'
break
case 404:
message = '请求的资源不存在'
break
case 500:
message = '服务器内部错误,请稍后重试'
break
default:
message = `请求错误(${error.response.status})`
}
}
ElMessage.error(message)
return Promise.reject(error)
}
)
// 封装请求函数,支持泛型
export default function requestWithType<T>(config: AxiosRequestConfig): Promise<T> {
return request(config)
}
8.5.3 创建文章 API 接口文件
创建 src/api/article.ts,封装所有文章相关的接口调用,与后端接口格式对齐:
// src/api/article.ts
import request from '@/utils/request'
import type { Article, ArticleQuery, ArticleListData } from '@/types/article'
/**
* 1. 获取文章列表
* @param params - 查询参数(分页+筛选)
* @returns 分页后的文章列表数据
*/
export function getArticleList(params: ArticleQuery) {
return request<ArticleListData>({
url: '/articles',
method: 'get',
params
})
}
/**
* 2. 获取文章详情
* @param id - 文章 ID
* @returns 单篇文章详情
*/
export function getArticleDetail(id: number) {
return request<Article>({
url: `/articles/${id}`,
method: 'get'
})
}
/**
* 3. 创建文章
* @param data - 文章数据(不含 id/createdAt/updatedAt)
* @returns 新创建的文章(含 id/createdAt/updatedAt)
*/
export function createArticle(data: Omit<Article, 'id' | 'createdAt' | 'updatedAt'>) {
return request<Article>({
url: '/articles',
method: 'post',
data
})
}
/**
* 4. 更新文章
* @param id - 文章 ID
* @param data - 需要更新的字段(部分更新)
* @returns 更新后的文章
*/
export function updateArticle(id: number, data: Partial<Article>) {
return request<Article>({
url: `/articles/${id}`,
method: 'put',
data
})
}
/**
* 5. 删除文章
* @param id - 文章 ID
* @returns 无返回值(成功则状态码为 200)
*/
export function deleteArticle(id: number) {
return request<void>({
url: `/articles/${id}`,
method: 'delete'
})
}
/**
* 6. 批量删除文章
* @param data - { ids: 文章 ID 数组 }
* @returns 无返回值
*/
export function batchDeleteArticles(data: { ids: number[] }) {
return request<void>({
url: '/articles/batch-delete',
method: 'post',
data
})
}
/**
* 7. 发布文章
* @param id - 文章 ID
* @returns 更新后的文章(状态变为 published)
*/
export function publishArticle(id: number) {
return request<Article>({
url: `/articles/${id}/publish`,
method: 'post'
})
}
8.5.4 API 接口规范说明
统一接口规范,确保前后端协作顺畅:
| 接口功能 | HTTP 方法 | 接口路径 | 请求参数 | 返回数据格式 |
|---|---|---|---|---|
| 获取文章列表 | GET | /articles | Query 参数(page/pageSize/keyword 等) | { items: Article[], total: number } |
| 获取文章详情 | GET | /articles/:id | 路径参数 id | Article 完整结构 |
| 创建文章 | POST | /articles | Body 参数(title/content/author 等) | 含 id 的Article 结构 |
| 更新文章 | PUT | /articles/:id | 路径参数 id + Body 参数(需更新字段) | 更新后的Article 结构 |
| 删除文章 | DELETE | /articles/:id | 路径参数 id | 无(成功则 code=200) |
| 批量删除文章 | POST | /articles/batch-delete | Body 参数({ids: [1,2,3] }) | 无 |
| 发布文章 | POST | /articles/:id/publish | 路径参数 id | 更新后的Article 结构 |
8.5.5 从 Mock 切换到真实 API
后续后端接口就绪后,只需修改 useArticles.ts 中的方法,替换为 API 函数调用:
// src/composables/useArticles.ts(切换真实 API 后)
import * as articleApi from '@/api/article' // 导入真实 API 函数
// 1. 获取文章列表(真实 API 版本)
const fetchArticles = async (query: ArticleQuery) => {
loading.value = true
error.value = null
try {
// 替换为真实 API 调用
const data = await articleApi.getArticleList(query)
articles.value = data.items
return data // 真实 API 返回 { items, total }
} catch (err) {
error.value = err as Error
ElMessage.error('获取文章列表失败')
throw err
} finally {
loading.value = false
}
}
// 2. 创建文章(真实 API 版本)
const createArticle = async (article: Omit<Article, 'id' | 'createdAt' | 'updatedAt'>) => {
loading.value = true
try {
// 替换为真实 API 调用
const newArticle = await articleApi.createArticle(article)
articles.value.unshift(newArticle) // 更新 UI
ElMessage.success('文章创建成功')
return newArticle
} catch (err) {
error.value = err as Error
ElMessage.error('文章创建失败')
throw err
} finally {
loading.value = false
}
}
// 其他方法(updateArticle/deleteArticle 等)同理,替换为对应的 API 函数调用
8.6 第五步:开发组件
组件是 UI 的基本单元,分为展示型组件(如文章列表)和交互型组件(如表单弹窗)。本节开发 2 个核心组件,覆盖文章管理的核心 UI 场景。
8.6.1 开发顺序
按从子到父的顺序开发,确保组件可独立复用:
ArticleList.vue(文章列表)→ 展示型组件,仅负责渲染数据ArticleFormDialog.vue(表单弹窗)→ 交互型组件,负责新建 / 编辑文章ArticleManage.vue(主页面)→ 整合组件和 Composable 逻辑
8.6.2 组件 1:ArticleList - 文章列表(展示型)
功能定位:接收文章列表数据,渲染为表格,通过 Emits 触发查看、编辑、删除等事件,不处理业务逻辑。
实现代码
<!-- src/components/ArticleList.vue -->
<template>
<el-table
:data="articles"
border
style="width: 100%"
:loading="loading"
row-key="id"
@selection-change="handleSelectionChange"
>
<!-- 多选框列(用于批量删除) -->
<el-table-column type="selection" width="55" />
<!-- ID 列 -->
<el-table-column prop="id" label="ID" width="80" align="center" />
<!-- 标题列 -->
<el-table-column prop="title" label="文章标题" min-width="200" align="left">
<template #default="{ row }">
<el-link type="primary" @click="$emit('view', row)">{{ row.title }}</el-link>
</template>
</el-table-column>
<!-- 作者列 -->
<el-table-column prop="author" label="作者" width="120" align="center" />
<!-- 状态列 -->
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'published' ? 'success' : 'info'">
{{ row.status === 'published' ? '已发布' : '草稿' }}
</el-tag>
</template>
</el-table-column>
<!-- 标签列 -->
<el-table-column label="标签" width="220" align="center">
<template #default="{ row }">
<el-tag
v-for="(tag, index) in row.tags"
:key="index"
size="small"
style="margin-right: 4px"
>
{{ tag }}
</el-tag>
</template>
</el-table-column>
<!-- 创建时间列 -->
<el-table-column prop="createdAt" label="创建时间" width="180" align="center" />
<!-- 操作列 -->
<el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }">
<!-- 查看按钮 -->
<el-button size="small" @click="$emit('view', row)">查看</el-button>
<!-- 编辑按钮 -->
<el-button size="small" type="primary" @click="$emit('edit', row)">编辑</el-button>
<!-- 发布按钮(仅草稿显示) -->
<el-button
v-if="row.status === 'draft'"
size="small"
type="success"
@click="$emit('publish', row.id)"
>
发布
</el-button>
<!-- 删除按钮 -->
<el-button size="small" type="danger" @click="$emit('delete', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script setup lang="ts">
import type { Article } from '@/types/article'
// ===== 1. 定义 Props(接收父组件数据) =====
defineProps<{
articles: Article[]; // 文章列表数据
loading?: boolean; // 加载状态(可选,默认 false)
}>()
// ===== 2. 定义 Emits(触发父组件事件) =====
defineEmits<{
(e: 'view', article: Article): void; // 查看文章
(e: 'edit', article: Article): void; // 编辑文章
(e: 'publish', id: number): void; // 发布文章
(e: 'delete', id: number): void; // 删除文章
(e: 'selection-change', ids: number[]): void; // 批量选择(多选框变化)
}>()
// ===== 3. 批量选择事件(转发给父组件) =====
const handleSelectionChange = (selectedRows: Article[]) => {
// 提取选中行的 ID 数组
const selectedIds = selectedRows.map(row => row.id)
// 触发事件,将 ID 数组传递给父组件
$emit('selection-change', selectedIds)
}
</script>
核心设计亮点
- 纯展示型组件:不包含业务逻辑,仅通过
Props接收数据,通过Emits触发事件,与父组件解耦。 - 批量选择支持:通过
@selection-change事件获取选中行,提取 ID 后转发给父组件,支持批量删除。 - 条件渲染:发布按钮仅在文章状态为
draft时显示,符合业务逻辑。 - 响应式布局:
min-width和fixed="right"确保表格在小屏幕下可横向滚动,操作列固定。
8.6.3 组件 2:ArticleFormDialog - 表单弹窗
功能说明: 用于创建和编辑文章的对话框表单,支持表单验证。
创建 src/components/ArticleFormDialog.vue:
<!-- src/components/ArticleFormDialog.vue -->
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑文章' : '新建文章'"
width="600px"
@close="handleClose"
>
<!-- 表单 -->
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
>
<!-- 标题 -->
<el-form-item label="标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入文章标题"
clearable
/>
</el-form-item>
<!-- 内容 -->
<el-form-item label="内容" prop="content">
<el-input
v-model="formData.content"
type="textarea"
:rows="6"
placeholder="请输入文章内容"
/>
</el-form-item>
<!-- 作者 -->
<el-form-item label="作者" prop="author">
<el-input
v-model="formData.author"
placeholder="请输入作者名称"
clearable
/>
</el-form-item>
<!-- 状态 -->
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio value="draft">草稿</el-radio>
<el-radio value="published">已发布</el-radio>
</el-radio-group>
</el-form-item>
<!-- 标签 -->
<el-form-item label="标签" prop="tags">
<el-select
v-model="formData.tags"
multiple
placeholder="请选择标签"
style="width: 100%"
>
<el-option label="Vue3" value="Vue3" />
<el-option label="TypeScript" value="TypeScript" />
<el-option label="JavaScript" value="JavaScript" />
<el-option label="前端" value="前端" />
<el-option label="后端" value="后端" />
<el-option label="Pinia" value="Pinia" />
<el-option label="状态管理" value="状态管理" />
</el-select>
</el-form-item>
</el-form>
<!-- 底部按钮 -->
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { Article } from '@/types/article'
// Props
const props = defineProps<{
visible: boolean
article?: Article | null
}>()
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
submit: [data: Partial<Article>]
}>()
// ===== 状态定义 =====
const formRef = ref<FormInstance>()
const dialogVisible = ref(props.visible)
// 表单数据
const formData = reactive({
title: '',
content: '',
author: '',
status: 'draft' as Article['status'],
tags: [] as string[]
})
// 是否为编辑模式
const isEdit = ref(false)
// ===== 表单验证规则 =====
const rules: FormRules = {
title: [
{ required: true, message: '请输入文章标题', trigger: 'blur' },
{ min: 2, max: 100, message: '标题长度为 2 至 100 个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入文章内容', trigger: 'blur' },
{ min: 10, message: '内容至少 10 个字符', trigger: 'blur' }
],
author: [
{ required: true, message: '请输入作者名称', trigger: 'blur' }
],
status: [
{ required: true, message: '请选择文章状态', trigger: 'change' }
],
tags: [
{ type: 'array', required: true, message: '请至少选择一个标签', trigger: 'change' }
]
}
// ===== 重置表单(必须在 watch 之前定义,避免 TDZ 错误)====
const resetForm = () => {
formData.title = ''
formData.content = ''
formData.author = ''
formData.status = 'draft'
formData.tags = []
formRef.value?.clearValidate()
}
// ===== 监听 Props 变化 =====
// 监听 visible 变化
watch(() => props.visible, (val) => {
dialogVisible.value = val
})
// 监听 dialogVisible 变化,同步到父组件
watch(dialogVisible, (val) => {
emit('update:visible', val)
})
// 监听 article 变化,填充表单
watch(() => props.article, (article) => {
if (article) {
// 编辑模式:填充数据
isEdit.value = true
formData.title = article.title
formData.content = article.content
formData.author = article.author
formData.status = article.status
formData.tags = [...article.tags]
} else {
// 新建模式:重置表单
isEdit.value = false
resetForm()
}
}, { immediate: true })
// ===== 操作方法 =====
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
// 验证表单
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
// 构建提交数据
const submitData: Partial<Article> = {
title: formData.title,
content: formData.content,
author: formData.author,
status: formData.status,
tags: formData.tags
}
// 如果是编辑模式,添加 ID
if (isEdit.value && props.article) {
submitData.id = props.article.id
}
// 触发提交事件
emit('submit', submitData)
// 关闭对话框
handleClose()
}
})
}
</script>
代码详解:
| 部分 | 说明 | 重点 |
|---|---|---|
| Props | visible:控制显示,article:编辑的文章 | 支持双向绑定 |
| Emits | update:visible:更新显示状态,submit:提交数据 | v-model 支持 |
| formData | 响应式表单数据 | 使用reactive |
| rules | Element Plus 表单验证规则 | 必填、长度验证 |
| resetForm | 重置表单 | ⚠️ 必须在 watch 前定义 |
| watch | 监听 Props 变化,自动填充 / 重置表单 | 编辑 / 新建切换 |
| handleSubmit | 表单提交,验证后触发事件 | 异步验证 |
关键技巧:避免 TDZ(暂时性死区)错误
// ❌ 错误:在 watch 中调用未定义的函数
watch(() => props.article, (article) => {
if (!article) {
resetForm() // 💥 Cannot access 'resetForm' before initialization
}
})
const resetForm = () => { ... }
// ✅ 正确:先定义函数,再在 watch 中使用
const resetForm = () => { ... }
watch(() => props.article, (article) => {
if (!article) {
resetForm() // ✅ 正常工作
}
})
Props 与 Emits 类型:
// Props(输入)
{
visible: boolean // 是否显示对话框
article?: Article | null // 要编辑的文章(null 表示新建)
}
// Emits(输出)
{
'update:visible': (value: boolean) => void // 更新显示状态
submit: (data: Partial<Article>) => void // 提交表单数据
}
使用示例:
<template>
<!-- 父组件中使用 -->
<ArticleFormDialog
v-model:visible="dialogVisible"
:article="currentArticle"
@submit="handleFormSubmit"
/>
</template>
<script setup lang="ts">
const dialogVisible = ref(false)
const currentArticle = ref<Article | null>(null)
// 打开新建对话框
const openCreateDialog = () => {
currentArticle.value = null
dialogVisible.value = true
}
// 打开编辑对话框
const openEditDialog = (article: Article) => {
currentArticle.value = article
dialogVisible.value = true
}
// 处理表单提交
const handleFormSubmit = (data: Partial<Article>) => {
if (currentArticle.value) {
// 编辑
updateArticle(currentArticle.value.id, data)
} else {
// 新建
createArticle(data)
}
}
</script>
8.6.4 组件开发总结
我们开发了 2 个核心组件,它们的职责和交互如下:
组件架构图:
┌─────────────────────────────────────────┐
│ ArticleManage.vue(主页面) │
│ │
│ ┌────────────────────────────────────┐ │
│ │ 搜索筛选区域(原生 Element Plus) │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ ArticleList.vue(列表组件) │ │
│ │ - 接收 articles 数据 │ │
│ │ - 触发 view/edit/delete 事件 │ │
│ └────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────┐ │
│ │ArticleFormDialog.vue(表单弹窗) │ │
│ │ - 接收 visible 和 article │ │
│ │ - 触发 submit 事件 │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────┘
组件对比:
| 特性 | ArticleList | ArticleFormDialog |
|---|---|---|
| 类型 | 展示型组件 | 交互型组件 |
| Props | articles: Article[] | visible: boolean, article?: Article |
| Emits | view, edit, delete, publish | update:visible, submit |
| 状态 | 无内部状态 | 有表单状态 |
| 复杂度 | 简单 | 中等 |
| 验证 | 无 | 表单验证 |
| 双向绑定 | 无 | v-model:visible |
设计原则总结:
- ✅ 单一职责
ArticleList:只负责展示ArticleFormDialog:只负责表单编辑
- ✅ Props Down, Events Up
- 父组件通过 Props 传递数据
- 子组件通过 Events 通知父组件
- ✅ 组件解耦
- 组件不依赖外部状态
- 可以独立测试和复用
- ✅ 类型安全
- 完整的 TypeScript 类型定义
- 编译时类型检查
8.7 第六步:集成主页 ArticleManage.vue
现在把所有功能集成到主页面!这是最激动人心的一步,我们将看到完整的功能运作。
8.7.1 主页面功能清单
✅ 搜索筛选(关键词、状态、标签)
✅ 文章列表展示
✅ 分页功能
✅ 新建文章
✅ 编辑文章
✅ 删除文章
✅ 发布文章
✅ 批量删除
✅ 加载状态
✅ 防抖搜索
8.7.2 完整代码
创建 src/views/ArticleManage.vue:
<!-- src/views/ArticleManage.vue -->
<template>
<div class="article-manage">
<!-- ========== 顶部工具栏 ========== -->
<div class="toolbar">
<!-- 新建按钮 -->
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建文章
</el-button>
<!-- 搜索筛选区域 -->
<div class="search-bar">
<!-- 关键词搜索 -->
<el-input
v-model="keyword"
placeholder="搜索标题或内容.."
clearable
style="width: 250px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<!-- 状态筛选 -->
<el-select
v-model="selectedStatus"
placeholder="状态筛选"
clearable
style="width: 130px"
>
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
</el-select>
<!-- 标签筛选 -->
<el-select
v-model="selectedTag"
placeholder="标签筛选"
clearable
style="width: 150px"
>
<el-option
v-for="tag in availableTags"
:key="tag"
:label="tag"
:value="tag"
/>
</el-select>
<!-- 重置筛选按钮 -->
<el-button
v-if="hasActiveFilters"
@click="resetFilters"
>
重置筛选
</el-button>
</div>
</div>
<!-- ========== 加载状态 ========== -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>加载中..</p>
</div>
<!-- ========== 文章列表 ========== -->
<div v-else>
<!-- 空状态 -->
<el-empty
v-if="articles.length === 0"
description="暂无文章数据"
/>
<!-- 列表组件 -->
<ArticleList
v-else
:articles="articles"
@view="handleView"
@edit="handleEdit"
@publish="handlePublish"
@delete="handleDelete"
/>
<!-- ========== 分页 ========== -->
<div v-if="total > 0" class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- ========== 表单对话框 ========== -->
<ArticleFormDialog
v-model:visible="dialogVisible"
:article="currentArticle"
@submit="handleFormSubmit"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Loading } from '@element-plus/icons-vue'
import ArticleList from '@/components/ArticleList.vue'
import ArticleFormDialog from '@/components/ArticleFormDialog.vue'
import type { Article } from '@/types/article'
// 引入 Composables
import { usePagination } from '@/composables/usePagination'
import { useSearch } from '@/composables/useSearch'
import { useArticles } from '@/composables/useArticles'
// ===== 使用 Composables =====
const { currentPage, pageSize, total, reset: resetPagination } = usePagination(10)
const {
keyword,
selectedTag,
selectedStatus,
debouncedKeyword,
hasActiveFilters,
resetFilters: resetSearchFilters
} = useSearch()
const {
articles,
loading,
fetchArticles,
createArticle,
updateArticle,
deleteArticle,
publishArticle
} = useArticles()
// ===== 状态定义 =====
const dialogVisible = ref(false) // 对话框显示状态
const currentArticle = ref<Article | null>(null) // 当前编辑的文章
// 可用标签列表(从文章中提取)
const availableTags = ref([
'Vue3', 'TypeScript', 'JavaScript', '前端', '后端', 'Pinia', '状态管理'
])
// ===== 数据加载 =====
// 加载文章列表
const loadArticles = async () => {
try {
const data = await fetchArticles({
page: currentPage.value,
pageSize: pageSize.value,
keyword: debouncedKeyword.value,
status: selectedStatus.value,
tag: selectedTag.value
})
total.value = data.total
} catch (error) {
console.error('加载文章失败:', error)
}
}
// ===== 监听事件 =====
// 监听防抖后的关键词
watch(debouncedKeyword, () => {
resetPagination() // 重置到第一页
loadArticles()
})
// 监听筛选条件
watch([selectedStatus, selectedTag], () => {
resetPagination() // 重置到第一页
loadArticles()
})
// 监听分页变化
watch([currentPage, pageSize], () => {
loadArticles()
})
// ===== 操作方法 =====
// 新建文章
const handleCreate = () => {
currentArticle.value = null
dialogVisible.value = true
}
// 查看文章
const handleView = (article: Article) => {
ElMessageBox.alert(
`<p><strong>标题:</strong>${article.title}</p>
<p><strong>作者:</strong>${article.author}</p>
<p><strong>内容:</strong>${article.content}</p>
<p><strong>标签:</strong>${article.tags.join(', ')}</p>`,
'文章详情',
{
dangerouslyUseHTMLString: true,
confirmButtonText: '关闭'
}
)
}
// 编辑文章
const handleEdit = (article: Article) => {
currentArticle.value = article
dialogVisible.value = true
}
// 发布文章
const handlePublish = async (id: number) => {
try {
await ElMessageBox.confirm('确定要发布这篇文章吗?', '提示', {
type: 'warning'
})
await publishArticle(id)
await loadArticles() // 刷新列表
} catch (error) {
if (error !== 'cancel') {
console.error('发布失败:', error)
}
}
}
// 删除文章
const handleDelete = async (id: number) => {
try {
await ElMessageBox.confirm('确定要删除这篇文章吗?此操作不可恢复!', '警告', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消'
})
await deleteArticle(id)
await loadArticles() // 刷新列表
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 处理表单提交
const handleFormSubmit = async (data: Partial<Article>) => {
try {
if (currentArticle.value) {
// 编辑
await updateArticle(currentArticle.value.id, data)
} else {
// 新建
await createArticle(data as Omit<Article, 'id' | 'createdAt' | 'updatedAt'>)
}
await loadArticles() // 刷新列表
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页大小变化
const handleSizeChange = () => {
resetPagination()
loadArticles()
}
// 页码变化
const handlePageChange = () => {
loadArticles()
}
// 重置筛选
const resetFilters = () => {
resetSearchFilters()
resetPagination()
loadArticles()
}
// ===== 生命周期 =====
onMounted(() => {
loadArticles() // 初始加载数据
})
</script>
<style scoped>
.article-manage {
padding: 20px;
background: #fff;
border-radius: 8px;
min-height: calc(100vh - 40px);
}
/* 工具栏 */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.search-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 0;
color: #909399;
}
.loading-container .el-icon {
margin-bottom: 12px;
}
/* 分页 */
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
/* 响应式 */
@media (max-width: 768px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-bar {
flex-direction: column;
}
.search-bar .el-input,
.search-bar .el-select {
width: 100% !important;
}
}
</style>
8.7.3 代码详解
主页面结构分析:
ArticleManage.vue 结构
├── Template 模板部分
│ ├── 工具栏(搜索筛选 + 新建按钮)
│ ├── 加载状态(loading)
│ ├── 文章列表(ArticleList 组件)
│ ├── 分页器(ElPagination)
│ └── 表单对话框(ArticleFormDialog)
├── Script 逻辑部分
│ ├── 引入 Composables(usePagination、useSearch、useArticles)
│ ├── 状态定义(dialogVisible、currentArticle)
│ ├── 数据加载(loadArticles)
│ ├── 监听器(watch 搜索/筛选/分页)
│ ├── 操作方法(CRUD 操作)
│ └── 生命周期(onMounted)
└── Style 样式部分
├── 布局样式(flex 布局)
├── 响应式设计(@media)
└── 主题样式
核心代码解析:
- Composables 组合使用
// 三个 Composable 各司其职
const { currentPage, pageSize, total, reset: resetPagination } = usePagination(10)
const { keyword, debouncedKeyword, ... } = useSearch()
const { articles, loading, fetchArticles, ... } = useArticles()
// 好处:
// - 逻辑清晰,职责分离
// - 易于测试和维护
// - 可以独立复用
- Watch 监听器实现自动刷新
// 监听搜索关键词(防抖后)
watch(debouncedKeyword, () => {
resetPagination() // 重要:搜索时回到第一页
loadArticles()
})
// 监听筛选条件
watch([selectedStatus, selectedTag], () => {
resetPagination()
loadArticles()
})
// 监听分页变化
watch([currentPage, pageSize], () => {
loadArticles() // 不重置页码
})
为什么要重置页码?
场景:用户在第3页,然后搜索关键词
- ❌ 不重置:可能第3页没数据,显示空列表
- ✅ 重置:回到第1页,显示搜索结果
- CRUD 操作的标准流程
// 以删除为例
const handleDelete = async (id: number) => {
try {
// 1. 二次确认(防止误操作)
await ElMessageBox.confirm('确定要删除吗?', '警告', {
type: 'warning'
})
// 2. 调用删除方法
await deleteArticle(id)
// 3. 刷新列表
await loadArticles()
} catch (error) {
// 4. 错误处理:'cancel' 表示用户取消
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
- 表单对话框的状态管理
// 新建:清空 currentArticle
const handleCreate = () => {
currentArticle.value = null // null 表示新建模式
dialogVisible.value = true
}
// 编辑:设置 currentArticle
const handleEdit = (article: Article) => {
currentArticle.value = article // 传入要编辑的文章
dialogVisible.value = true
}
// ArticleFormDialog 组件会根据 article 是否为 null 切换模式
- 响应式设计
@media (max-width: 768px) {
.toolbar {
flex-direction: column; /* 移动端改为纵向布局 */
}
.search-bar .el-input,
.search-bar .el-select {
width: 100% !important; /* 移动端全屏 */
}
}
8.7.4 数据流分析
完整的数据流向:
1. 用户操作
→ 2. 触发事件(搜索/筛选/分页/CRUD)
→ 3. Watch 监听器触发
→ 4. 调用 loadArticles()
→ 5. fetchArticles() 向 Mock/API 获取数据
→ 6. 更新 articles.value
→ 7. Vue 响应式系统更新 DOM
→ 8. 用户看到更新后的界面
搜索防抖流程:
用户输入 "Vue" → keyword.value = 'V' → 延迟300ms
→ keyword.value = 'Vu' → 清除之前的定时器,延迟300ms
→ keyword.value = 'Vue' → 清除之前的定时器,延迟300ms
→ 300ms后
→ debouncedKeyword.value = 'Vue'
→ 触发 watch
→ loadArticles()
8.7.5 主页面集成总结
完成的功能:
| 功能 | 实现方式 | 用到的技术 |
|---|---|---|
| 搜索 | useSearch + useDebounce | 防抖、watch |
| 筛选 | useSearch | v-model、watch |
| 分页 | usePagination + ElPagination | v-model、watch |
| 列表展示 | ArticleList 组件 | Props、Emits |
| 新建 / 编辑 | ArticleFormDialog 组件 | v-model、表单验证 |
| 删除 | ElMessageBox + useArticles | 二次确认、异步操作 |
| 加载状态 | loading ref | v-if 条件渲染 |
代码质量特点:
- ✅ 高内聚低耦合 - Composables 各司其职
- ✅ 响应式驱动 - watch 自动监听变化
- ✅ 类型安全 - 完整的 TypeScript 支持
- ✅ 用户体验 - 加载状态、防抖、二次确认
- ✅ 可维护性 - 代码结构清晰,易于理解和修改
至此,我们的文章管理系统已经完全开发完成!🎉
所有代码都已就绪,现在你可以:
- 运行项目
pnpm dev - 体验完整的 CRUD 功能
- 查看 Mock 数据的效果
- 在项目中修改和扩展功能
九、总结与最佳实践
通过这个完整的文章管理系统实战,我们学习了 Vue3 Composition API 的核心能力:
9.1 技术栈总结
| 技术 | 作用 | 关键词 |
|---|---|---|
| Vue 3 | 核心框架 | Composition API、响应式系统 |
| TypeScript | 类型安全 | Interface、泛型、工具类型 |
| Element Plus | UI 组件库 | 表格、表单、对话框、分页 |
| Vite | 构建工具 | 快速启动、HMR、路径别名 |
| Composables | 逻辑复用 | usePagination、useSearch、useDebounce |
| Mock Data | 开发模式 | 前后端分离开发 |
9.2 开发流程总结
plaintext
完整开发流程:
1. 准备工作(配置 Vite、TypeScript、Element Plus)
→ 2. 定义类型(Article、Query、Response 接口)
→ 3. 准备 Mock 数据(mockArticles + 工具函数)
→ 4. 开发 Composables(usePagination、useDebounce、useSearch、useArticles)
→ 5. 开发 API 层(可选,为真实 API 做准备)
→ 6. 开发组件(ArticleList、ArticleFormDialog)
→ 7. 集成主页面(ArticleManage.vue)
→ 8. 测试与优化
9.3 核心概念回顾
- Composable 设计模式
// 好的 Composable 特征:
- 单一职责 - 只做一件事
- 可组合性 - 小的组合成大的
- 返回值清晰 - 状态 + 方法
- 类型安全 - 完整的 TS 支持
- 易于测试 - 纯逻辑,无 DOM 依赖
- 响应式数据管理
// 推荐使用 ref
const count = ref(0)
const user = ref({ name: 'John' })
// 避免使用 reactive 的陷阱
const state = reactive({ count: 0 })
// ❌ 解构会失去响应性
const { count } = state
- Watch 监听器模式
// 监听单个值
watch(keyword, () => { ... })
// 监听多个值
watch([status, tag], () => { ... })
// 立即执行
watch(source, callback, { immediate: true })
// 深度监听
watch(obj, callback, { deep: true })
- 组件通信模式
plaintext
父组件 ArticleManage
├→ Props → ArticleList(数据传递)
├← Emits ← ArticleList(事件通知)
├→ v-model:visible → ArticleFormDialog(双向绑定)
└← @submit ← ArticleFormDialog(表单提交)
9.4 最佳实践
项目结构:
src/
├── types/ # 类型定义(优先)
├── mock/ # Mock 数据
├── composables/ # 逻辑复用
├── api/ # API 接口
├── components/ # 组件
├── views/ # 页面
└── utils/ # 工具函数
命名规范:
// Composables:use 开头
usePagination、useSearch、useDebounce
// 组件:大驼峰
ArticleList、ArticleFormDialog
// 事件:动词开头
handleCreate、handleEdit、handleDelete
// Props:名词
visible、article、articles
// Emits:动词
submit、update:visible、delete
性能优化:
- ✅ 使用
useDebounce减少请求 - ✅ 使用
computed缓存计算结果 - ✅ 区分
v-if与v-show的正确场景 - ✅ 列表使用
key属性 - ✅ 大列表使用虚拟滚动
9.5 进阶学习方向
- 状态管理 Pinia
import { defineStore } from 'pinia'
export const useArticleStore = defineStore('article', () => {
const articles = ref<Article[]>([])
const fetchArticles = async () => {
// ...
}
return { articles, fetchArticles }
})
- Vue Router 路由
const router = createRouter({
routes: [
{ path: '/articles', component: ArticleManage },
{ path: '/articles/:id', component: ArticleDetail }
]
})
- 测试
import { mount } from '@vue/test-utils'
import ArticleList from '@/components/ArticleList.vue'
test('renders articles', () => {
const wrapper = mount(ArticleList, {
props: { articles: mockArticles }
})
expect(wrapper.find('.article-item')).toExist()
})
- SSR(服务端渲染)
使用 Nuxt 4 或 Vite SSR 实现更好的 SEO 和首屏性能。
9.6 常见问题快速查询
| 问题 | 解决方案 | 章节 |
|---|---|---|
| Cannot access before initialization | 函数定义在 watch 之前 | 8.6.3 |
| Element Plus 弹窗样式错乱 | 调整 CSS 优先级 | 已解决 |
| Mock 数据不刷新 | 使用数组副本[...mockArticles] | 8.4.5 |
| watch 不触发 | 检查是否需要deep: true | 第七章 |
| ref 解构失去响应性 | 使用toRefs | 第六章 |
9.7 下一步
恭喜你完成了这个完整的实战项目!🎉
后续可以做的事情:
- 📝 切换到真实 API(修改 useArticles)
- 🎨 美化 UI(添加更多动画和交互)
- 📱 完善移动端适配(响应式布局)
- 🔐 添加用户认证(登录 / 注册)
- 📊 添加数据可视化(图表展示)
- 🚀 部署到生产环境
继续学习:
- 📖 Vue3 响应式原理深度解析
- 📖 Vue3 组件设计模式
- 📖 Vue3 + TypeScript 最佳实践
- 📖 Vue3 性能优化指南
4. useDebounce - 防抖工具
防抖是性能优化的重要手段,特别是在搜索场景中。
为什么需要防抖?
假设用户输入 “Vue3 教程”,如果没有防抖,会触发 5 次 API 请求:
- 输入 “V” → 请求 1 次
- 输入 “Vu” → 请求 1 次
- 输入 “Vue” → 请求 1 次
- 输入 “Vue3” → 请求 1 次
- 输入 “Vue3 教” → 请求 1 次
- 输入 “Vue3 教程” → 请求 1 次
使用防抖后,只在用户停止输入 500ms 后才发送请求,大大减少了服务器压力。
// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'
/**
* 防抖 Hook - 延迟更新值,适用于搜索等场景
*
* @example
* ```ts
* const keyword = ref('')
* const debouncedKeyword = useDebounce(keyword, 500)
*
* // 监听防抖后的值
* watch(debouncedKeyword, (newKeyword) => {
* // 这里只会在用户停止输入 500ms 后执行
* fetchData(newKeyword)
* })
* ```
*
* @param value - 需要防抖的响应式值
* @param delay - 延迟时间(毫秒),默认 300ms
* @returns 防抖后的响应式值
*/
export function useDebounce<T>(value: Ref<T>, delay = 300) {
// 创建防抖后的值
const debouncedValue = ref(value.value) as Ref<T>
// 定时器引用
let timer: ReturnType<typeof setTimeout> | null = null
// 监听原始值的变化
watch(value, (newValue) => {
// 清除之前的定时器
if (timer) {
clearTimeout(timer)
}
// 设置新的定时器
timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
使用场景:
- 搜索框输入 - 用户停止输入后才搜索
- 窗口大小调整 - 调整停止后才重新计算布局
- 滚动加载 - 滚动停止后才加载更多数据
8.4 API 接口层
为什么要独立 API 层?
将 API 调用封装成独立的函数有以下好处:
- 统一管理 - 所有接口在一个地方,方便维护
- 类型安全 - 明确的参数和返回值类型
- 易于测试 - 可以方便地 mock API 函数
- 代码复用 - 多个组件可以共享同一个 API 函数
创建统一的 API 接口文件:
// api/article.ts
import request from '@/utils/request'
import type { Article, ArticleQuery } from '@/types/article'
/**
* 获取文章列表
* @param query - 查询参数(分页、搜索、筛选)
* @returns Promise<{ items: Article[], total: number }>
*
* @example
* ```ts
* const result = await getArticleList({
* page: 1,
* pageSize: 10,
* keyword: 'Vue3',
* status: 'published'
* })
* console.log(result.items) // 文章列表
* console.log(result.total) // 总条数
* ```
*/
export function getArticleList(query: ArticleQuery) {
return request({
url: '/articles',
method: 'get',
params: query,
})
}
/**
* 获取文章详情
* @param id - 文章 ID
* @returns Promise<Article>
*/
export function getArticleDetail(id: number) {
return request({
url: `/articles/${id}`,
method: 'get',
})
}
/**
* 创建文章
* @param data - 文章数据(不包含 id、createdAt、updatedAt)
* @returns Promise<Article> - 返回创建成功的文章(包含 id 等)
*
* @example
* ```ts
* const newArticle = await createArticle({
* title: '我的第一篇文章',
* content: '这是内容...',
* author: '张三',
* status: 'draft',
* tags: ['Vue3', 'TypeScript']
* })
* ```
*/
export function createArticle(data: Omit<Article, 'id' | 'createdAt' | 'updatedAt'>) {
return request({
url: '/articles',
method: 'post',
data,
})
}
/**
* 更新文章
* @param id - 文章 ID
* @param data - 要更新的字段(支持部分更新)
* @returns Promise<Article> - 返回更新后的文章
*
* @example
* ```ts
* // 只更新标题和状态
* await updateArticle(123, {
* title: '新标题',
* status: 'published'
* })
* ```
*/
export function updateArticle(id: number, data: Partial<Article>) {
return request({
url: `/articles/${id}`,
method: 'put',
data,
})
}
/**
* 删除文章
* @param id - 文章 ID
* @returns Promise<void>
*/
export function deleteArticle(id: number) {
return request({
url: `/articles/${id}`,
method: 'delete',
})
}
/**
* 批量删除文章
* @param ids - 文章 ID 数组
* @returns Promise<void>
*
* @example
* ```ts
* await batchDeleteArticles([1, 2, 3, 4, 5])
* ```
*/
export function batchDeleteArticles(ids: number[]) {
return request({
url: '/articles/batch-delete',
method: 'post',
data: { ids },
})
}
/**
* 发布文章(将草稿变为已发布状态)
* @param id - 文章 ID
* @returns Promise<Article>
*/
export function publishArticle(id: number) {
return request({
url: `/articles/${id}/publish`,
method: 'post',
})
}
API 接口规范说明:
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 获取列表 | GET | /articles | 支持分页和筛选 |
| 获取详情 | GET | /articles/:id | 返回单篇文章 |
| 创建文章 | POST | /articles | 新建文章 |
| 更新文章 | PUT | /articles/:id | 支持部分更新 |
| 删除文章 | DELETE | /articles/:id | 删除单篇文章 |
| 批量删除 | POST | /articles/batch-delete | 批量删除多篇文章 |
| 发布文章 | POST | /articles/:id/publish | 草稿→已发布 |
后端接口返回格式:
json
// 成功响应
{
"code": 200,
"message": "success",
"data": {
"items": [...], // 列表数据
"total": 100 // 总条数
}
}
// 错误响应
{
"code": 400,
"message": "参数错误",
"data": null
}
注意: 在 useArticles.ts 中,需要将原来的 fetch 调用改为使用这些 API 函数。
// composables/useArticles.ts
import * as articleApi from '@/api/article'
// 获取文章列表
const fetchArticles = async (query: ArticleQuery) => {
loading.value = true
error.value = null
try {
const data = await articleApi.getArticleList(query)
articles.value = data.items
return data // { items, total }
} catch (err) {
error.value = err as Error
throw err
} finally {
loading.value = false
}
}
更多推荐

所有评论(0)