📅 发布日期: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>

存在的问题:

  1. 逻辑分散 - 同一个功能的代码散落在 data、computed、methods、mounted 等不同位置
  2. 代码复用困难 - 如果要在多个组件中复用"用户关注"功能,只能通过 Mixins(容易命名冲突)
  3. TypeScript 支持不友好 - this 的类型推断很困难
  4. 大型组件难以维护 - 当组件变大时,需要在不同选项之间来回跳转

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>

优势明显:

  1. ✅ 逻辑聚合 - 同一功能的代码集中在一起
  2. ✅ 代码复用 - 可以抽取成 useUser 等 Composable 函数
  3. ✅ TypeScript 完美支持 - 类型推断非常友好
  4. ✅ 更好的代码组织 - 按功能而非选项组织代码

🎯 二、响应式基础: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 对比

特性refreactive
适用类型基本类型 + 对象对象 + 数组
访问方式需要.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 对比:

特性watchwatchEffect
依赖追踪手动指定自动追踪
获取旧值✅ 支持❌ 不支持
立即执行需配置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 APIComposition API
beforeCreate❌ 不需要(setup 本身)
created❌ 不需要(setup 本身)
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeUnmountonBeforeUnmount
unmountedonUnmounted
errorCapturedonErrorCaptured
activatedonActivated
deactivatedonDeactivated

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)               │ 👉 工具函数(通用工具、无副作用)
└─────────────────────────────────┘

各层核心职责:

  1. View 层:仅关注 UI 渲染,不处理复杂业务逻辑。通过 Props 接收数据,通过 Emits 触发事件,与 Composable 层解耦。

    示例:ArticleList.vue(仅展示文章列表,点击事件通过 Emits 通知父组件)。

  2. Composable 层:核心业务逻辑封装层,是 Composition API 的核心价值。将分散的逻辑(如分页、搜索)抽取为独立函数,可跨组件复用。

    示例:usePagination.ts(封装分页状态和方法,任何列表组件都可复用)。

  3. API 层:统一管理所有后端接口,避免接口调用散落在组件中。便于后续切换 Mock / 真实 API、统一错误处理。

    示例:api/article.ts(封装 getArticleList、createArticle 等接口)。

  4. Utils 层:提供通用工具函数,无业务依赖、无副作用(输入相同则输出相同)。

    示例:request.ts(Axios 封装,处理请求拦截、响应错误)。

8.1.3 开发顺序

按从底层到上层的顺序开发,确保每一层都可独立测试,避免依赖混乱:

  1. Types(类型定义) → 定义数据结构(如文章接口、查询参数)
  2. Mock(模拟数据) → 准备测试数据,支持前端独立开发
  3. Utils(工具函数) → 封装通用工具(如 Axios 请求)
  4. API(接口层) → 封装接口调用(与后端约定接口格式)
  5. Composables(业务逻辑) → 封装核心逻辑(如文章 CRUD)
  6. Components(UI 组件) → 开发可复用 UI 单元(如列表、表单)
  7. Views(页面) → 整合所有层,组装完整业务页面
  8. 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 类型设计要点

  1. 用联合类型限制可选值

    避免使用宽泛的 string,明确字段的可选范围,减少非法值:
    typescript

    // ✅ 好:仅支持两种状态
    status: 'draft' | 'published';
    
    // ❌ 差:任何字符串都可传入,易出错
    status: string;
    
  2. 用可选属性标记非必填字段

    用 ? 明确区分必填 / 非必填字段,避免歧义:
    typescript

    export interface ArticleQuery {
      page: number;         // 必填(无 ?)
      pageSize: number;     // 必填
      keyword?: string;     // 可选(有 ?)
    }
    
  3. 用泛型提升复用性

    定义通用类型(如 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);
    
  4. 用 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 开发顺序说明

按从简单到复杂的顺序开发,确保每个函数职责单一、可独立测试:

  1. usePagination(分页)→ 基础通用逻辑,无依赖
  2. useDebounce(防抖)→ 基础工具逻辑,无依赖
  3. useSearch(搜索筛选)→ 依赖 useDebounce
  4. useArticles(文章管理)→ 依赖 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 的组合流程:

  1. 用户输入关键词 → keyword 实时更新
  2. useDebounce 监听 keyword → 延迟 500ms 后更新 debouncedKeyword
  3. 组件监听 debouncedKeyword → 变化时触发搜索请求
  4. 最终效果:输入流畅,仅在停止输入后发起一次请求
使用示例
// 在组件中使用
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
  }
}
核心设计亮点
  1. Mock 数据隔离:用 mockData = [...mockArticles] 复制原始数据,避免修改全局 Mock 数据,确保多次创建 / 删除后可重置。
  2. 错误处理统一:所有异步方法都通过 try/catch 捕获错误,并通过 ElMessage 提示用户,同时抛出错误让调用者处理。
  3. 方法复用:publishArticle 复用 updateArticle,避免重复代码(发布本质是更新文章状态)。
  4. 状态同步:创建 / 更新 / 删除后,同时更新 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 接口层?

  1. 统一管理:所有接口集中在一个目录,便于维护(如修改接口路径、参数格式)。
  2. 类型安全:明确接口的参数和返回值类型,与 TypeScript 无缝集成。
  3. 易于切换:Mock 数据和真实 API 的切换只需修改 API 层,无需改动业务逻辑。
  4. 便于测试:可单独 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/articlesQuery 参数(page/pageSize/keyword 等){ items: Article[], total: number }
获取文章详情GET/articles/:id路径参数 idArticle 完整结构
创建文章POST/articlesBody 参数(title/content/author 等)含 id 的Article 结构
更新文章PUT/articles/:id路径参数 id + Body 参数(需更新字段)更新后的Article 结构
删除文章DELETE/articles/:id路径参数 id无(成功则 code=200)
批量删除文章POST/articles/batch-deleteBody 参数({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 开发顺序

按从子到父的顺序开发,确保组件可独立复用:

  1. ArticleList.vue(文章列表)→ 展示型组件,仅负责渲染数据
  2. ArticleFormDialog.vue(表单弹窗)→ 交互型组件,负责新建 / 编辑文章
  3. 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>
核心设计亮点
  1. 纯展示型组件:不包含业务逻辑,仅通过 Props 接收数据,通过 Emits 触发事件,与父组件解耦。
  2. 批量选择支持:通过 @selection-change 事件获取选中行,提取 ID 后转发给父组件,支持批量删除。
  3. 条件渲染:发布按钮仅在文章状态为 draft 时显示,符合业务逻辑。
  4. 响应式布局: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>

代码详解:

部分说明重点
Propsvisible:控制显示,article:编辑的文章支持双向绑定
Emitsupdate:visible:更新显示状态,submit:提交数据v-model 支持
formData响应式表单数据使用reactive
rulesElement 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 事件                │  │
│ └────────────────────────────────────┘  │
└─────────────────────────────────────────┘

组件对比:

特性ArticleListArticleFormDialog
类型展示型组件交互型组件
Propsarticles: Article[]visible: boolean, article?: Article
Emitsview, edit, delete, publishupdate:visible, submit
状态无内部状态有表单状态
复杂度简单中等
验证无表单验证
双向绑定无v-model:visible

设计原则总结:

  1. ✅ 单一职责
    • ArticleList:只负责展示
    • ArticleFormDialog:只负责表单编辑
  2. ✅ Props Down, Events Up
    • 父组件通过 Props 传递数据
    • 子组件通过 Events 通知父组件
  3. ✅ 组件解耦
    • 组件不依赖外部状态
    • 可以独立测试和复用
  4. ✅ 类型安全
    • 完整的 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)
    └── 主题样式

核心代码解析:

  1. Composables 组合使用
// 三个 Composable 各司其职
const { currentPage, pageSize, total, reset: resetPagination } = usePagination(10)
const { keyword, debouncedKeyword, ... } = useSearch()
const { articles, loading, fetchArticles, ... } = useArticles()

// 好处:
// - 逻辑清晰,职责分离
// - 易于测试和维护
// - 可以独立复用
  1. Watch 监听器实现自动刷新
// 监听搜索关键词(防抖后)
watch(debouncedKeyword, () => {
  resetPagination()  // 重要:搜索时回到第一页
  loadArticles()
})

// 监听筛选条件
watch([selectedStatus, selectedTag], () => {
  resetPagination()
  loadArticles()
})

// 监听分页变化
watch([currentPage, pageSize], () => {
  loadArticles()  // 不重置页码
})

为什么要重置页码?

场景:用户在第3页,然后搜索关键词
- ❌ 不重置:可能第3页没数据,显示空列表
- ✅ 重置:回到第1页,显示搜索结果
  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)
    }
  }
}
  1. 表单对话框的状态管理
// 新建:清空 currentArticle
const handleCreate = () => {
  currentArticle.value = null  // null 表示新建模式
  dialogVisible.value = true
}

// 编辑:设置 currentArticle
const handleEdit = (article: Article) => {
  currentArticle.value = article  // 传入要编辑的文章
  dialogVisible.value = true
}

// ArticleFormDialog 组件会根据 article 是否为 null 切换模式
  1. 响应式设计
@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
筛选useSearchv-model、watch
分页usePagination + ElPaginationv-model、watch
列表展示ArticleList 组件Props、Emits
新建 / 编辑ArticleFormDialog 组件v-model、表单验证
删除ElMessageBox + useArticles二次确认、异步操作
加载状态loading refv-if 条件渲染

代码质量特点:

  1. ✅ 高内聚低耦合 - Composables 各司其职
  2. ✅ 响应式驱动 - watch 自动监听变化
  3. ✅ 类型安全 - 完整的 TypeScript 支持
  4. ✅ 用户体验 - 加载状态、防抖、二次确认
  5. ✅ 可维护性 - 代码结构清晰,易于理解和修改

至此,我们的文章管理系统已经完全开发完成!🎉

所有代码都已就绪,现在你可以:

  1. 运行项目 pnpm dev
  2. 体验完整的 CRUD 功能
  3. 查看 Mock 数据的效果
  4. 在项目中修改和扩展功能

九、总结与最佳实践

通过这个完整的文章管理系统实战,我们学习了 Vue3 Composition API 的核心能力:

9.1 技术栈总结

技术作用关键词
Vue 3核心框架Composition API、响应式系统
TypeScript类型安全Interface、泛型、工具类型
Element PlusUI 组件库表格、表单、对话框、分页
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 核心概念回顾

  1. Composable 设计模式
// 好的 Composable 特征:
- 单一职责 - 只做一件事
- 可组合性 - 小的组合成大的
- 返回值清晰 - 状态 + 方法
- 类型安全 - 完整的 TS 支持
- 易于测试 - 纯逻辑,无 DOM 依赖
  1. 响应式数据管理
// 推荐使用 ref
const count = ref(0)
const user = ref({ name: 'John' })

// 避免使用 reactive 的陷阱
const state = reactive({ count: 0 })
// ❌ 解构会失去响应性
const { count } = state
  1. Watch 监听器模式
// 监听单个值
watch(keyword, () => { ... })

// 监听多个值
watch([status, tag], () => { ... })

// 立即执行
watch(source, callback, { immediate: true })

// 深度监听
watch(obj, callback, { deep: true })
  1. 组件通信模式

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

性能优化:

  1. ✅ 使用 useDebounce 减少请求
  2. ✅ 使用 computed 缓存计算结果
  3. ✅ 区分 v-if 与 v-show 的正确场景
  4. ✅ 列表使用 key 属性
  5. ✅ 大列表使用虚拟滚动

9.5 进阶学习方向

  1. 状态管理 Pinia
import { defineStore } from 'pinia'

export const useArticleStore = defineStore('article', () => {
  const articles = ref<Article[]>([])
  
  const fetchArticles = async () => {
    // ...
  }
  
  return { articles, fetchArticles }
})
  1. Vue Router 路由
const router = createRouter({
  routes: [
    { path: '/articles', component: ArticleManage },
    { path: '/articles/:id', component: ArticleDetail }
  ]
})
  1. 测试
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()
})
  1. 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 下一步

恭喜你完成了这个完整的实战项目!🎉

后续可以做的事情:

  1. 📝 切换到真实 API(修改 useArticles)
  2. 🎨 美化 UI(添加更多动画和交互)
  3. 📱 完善移动端适配(响应式布局)
  4. 🔐 添加用户认证(登录 / 注册)
  5. 📊 添加数据可视化(图表展示)
  6. 🚀 部署到生产环境

继续学习:

  • 📖 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
}

使用场景:

  1. 搜索框输入 - 用户停止输入后才搜索
  2. 窗口大小调整 - 调整停止后才重新计算布局
  3. 滚动加载 - 滚动停止后才加载更多数据

8.4 API 接口层

为什么要独立 API 层?

将 API 调用封装成独立的函数有以下好处:

  1. 统一管理 - 所有接口在一个地方,方便维护
  2. 类型安全 - 明确的参数和返回值类型
  3. 易于测试 - 可以方便地 mock API 函数
  4. 代码复用 - 多个组件可以共享同一个 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
  }
}

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐