Vue 3 Composition API实战:组件复用与状态管理的最佳实践

晨曦吻
晨曦吻 2026-02-12T05:09:06+08:00
0 0 0

标签:Vue 3, 前端开发, Composition API, 组件复用, 状态管理
简介:全面介绍Vue 3 Composition API的核心特性,通过实际项目案例演示如何利用Composition API实现组件逻辑复用、状态管理和复杂业务逻辑的优雅组织。

引言:从Options API到Composition API的演进

随着前端应用复杂度的不断提升,Vue 2时代的 Options API(即基于 data, methods, computed, watch 等选项的声明式写法)在处理大型组件时逐渐暴露出一些结构性问题:

  • 逻辑分散:同一功能的代码被拆分到不同选项中,难以维护。
  • 复用困难:当多个组件需要共享相同逻辑时,依赖 mixins 的方式会导致命名冲突和作用域污染。
  • 类型推导弱:在TypeScript中,Options API 的类型推断能力有限,不利于构建可维护的大型项目。

为解决这些问题,Vue 3引入了全新的 Composition API。它不再依赖于 this 上下文,而是以函数形式组织逻辑,提供了更灵活、可组合、可重用的编程范式。

为什么选择Composition API?

  1. 逻辑复用更自然:通过自定义 Composable 函数实现跨组件逻辑共享。
  2. 更好的类型支持:在 TypeScript 中能提供完整的类型推导与提示。
  3. 更强的代码组织能力:将相关逻辑按功能模块组织,提升可读性和可维护性。
  4. 避免 this 指向陷阱:不再依赖 this,减少运行时错误。
  5. 与现代JavaScript生态无缝集成:支持 const, let, async/await, import/export 等现代语法。

本文将深入探讨 Composition API 的核心机制,并通过真实项目案例展示其在组件复用与状态管理中的最佳实践。

一、Composition API 核心概念详解

1.1 setup() 函数:入口点

setup() 是 Composition API 的起点,它是组件实例创建后、挂载前执行的函数,接收两个参数:

export default {
  setup(props, context) {
    // props: 父组件传入的属性
    // context: 包含 attrs, slots, emit 等上下文信息
  }
}

⚠️ 注意:在 setup() 中无法访问 this,所有响应式数据必须通过 ref / reactive 创建。

1.2 响应式基础:refreactive

ref<T>:创建一个响应式引用

import { ref } from 'vue'

const count = ref(0)

// 读取值:count.value
console.log(count.value) // 0

// 改变值:count.value = newVal
count.value = 10

ref 可用于任何类型(基本类型、对象、数组等),并自动实现响应式绑定。

reactive<T>:创建一个深层响应式对象

import { reactive } from 'vue'

const state = reactive({
  name: 'Alice',
  age: 25,
  hobbies: ['coding', 'reading']
})

state.name = 'Bob'
state.hobbies.push('traveling')

✅ 推荐:对复杂对象使用 reactive;对简单值或需要解构的场景使用 ref

1.3 计算属性:computed

import { computed } from 'vue'

const fullName = computed(() => {
  return `${person.firstName} ${person.lastName}`
})

computed 会缓存结果,在依赖未变化时不重新计算,性能优化显著。

1.4 监听器:watch

基础监听

import { watch } from 'vue'

watch(
  () => user.age,
  (newAge, oldAge) => {
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

多项监听

watch(
  [() => user.firstName, () => user.lastName],
  ([newFirst, newLast], [oldFirst, oldLast]) => {
    console.log(`姓名更新:${newFirst} ${newLast}`)
  }
)

深层监听

watch(
  () => user.profile,
  (newProfile, oldProfile) => {
    // 只有当 profile 内部属性变化时触发
  },
  { deep: true }
)

💡 提示:watch 支持返回停止监听函数,适合异步操作清理。

1.5 生命周期钩子:onXxx API

Vue 3 提供了与生命周期对应的 onXxx 函数,可在 setup() 中使用:

旧写法 新写法
beforeCreate ❌ 无对应(已合并至 setup
created onMounted
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onUpdated
beforeUnmount onBeforeUnmount
unmounted onUnmounted
import { onMounted, onUnmounted } from 'vue'

export default {
  setup() {
    onMounted(() => {
      console.log('组件已挂载')
    })

    onUnmounted(() => {
      console.log('组件即将销毁')
    })
  }
}

✅ 优点:统一生命周期管理,便于测试与调试。

二、组件复用:Composable 函数的设计与实践

2.1 什么是 Composable 函数?

Composable 函数 是一个返回响应式数据和方法的纯函数,可以被任意组件调用,实现逻辑复用。

典型结构如下:

// composable/useCounter.ts
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

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

  const decrement = () => {
    count.value -= 1
  }

  const reset = () => {
    count.value = initialValue
  }

  return {
    count,
    increment,
    decrement,
    reset
  }
}

2.2 通用复用:表单验证逻辑

假设我们有一个用户注册表单,包含用户名、邮箱、密码校验。我们可以将其抽象为一个 Composable:

// composable/useFormValidation.ts
import { ref, computed } from 'vue'

interface ValidationResult {
  valid: boolean
  errors: string[]
}

export function useFormValidation(initialValues: Record<string, any>) {
  const form = ref({ ...initialValues })
  const errors = ref<Record<string, string>>({})

  const validateField = (field: string, value: string): boolean => {
    switch (field) {
      case 'username':
        return value.length >= 3 && /^[a-zA-Z0-9_]+$/.test(value)
      case 'email':
        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
      case 'password':
        return value.length >= 8
      default:
        return true
    }
  }

  const validateAll = (): ValidationResult => {
    const fieldErrors: Record<string, string> = {}
    let isValid = true

    for (const [key, value] of Object.entries(form.value)) {
      if (!validateField(key, String(value))) {
        fieldErrors[key] = `Invalid ${key}`
        isValid = false
      }
    }

    errors.value = fieldErrors
    return { valid: isValid, errors: Object.values(fieldErrors) }
  }

  const reset = () => {
    form.value = { ...initialValues }
    errors.value = {}
  }

  const setField = (field: string, value: any) => {
    form.value[field] = value
    // 可选:实时校验
    if (errors.value[field]) {
      delete errors.value[field]
    }
  }

  const getFieldError = (field: string) => errors.value[field]

  return {
    form,
    errors,
    validateAll,
    reset,
    setField,
    getFieldError,
    isDirty: computed(() => Object.keys(form.value).some(k => form.value[k] !== initialValues[k]))
  }
}

2.3 组件中使用:注册页面示例

<!-- RegisterForm.vue -->
<script setup>
import { useFormValidation } from '@/composables/useFormValidation'

const initialValues = {
  username: '',
  email: '',
  password: ''
}

const { form, errors, validateAll, reset, setField, getFieldError, isDirty } = useFormValidation(initialValues)

const handleSubmit = () => {
  const result = validateAll()
  if (result.valid) {
    alert('注册成功!')
    reset()
  } else {
    console.log('表单验证失败:', result.errors)
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <label>用户名:</label>
      <input
        v-model="form.username"
        :class="{ error: getFieldError('username') }"
        @blur="validateAll()"
      />
      <span v-if="getFieldError('username')" class="error-text">{{ getFieldError('username') }}</span>
    </div>

    <div>
      <label>邮箱:</label>
      <input
        v-model="form.email"
        :class="{ error: getFieldError('email') }"
        @blur="validateAll()"
      />
      <span v-if="getFieldError('email')" class="error-text">{{ getFieldError('email') }}</span>
    </div>

    <div>
      <label>密码:</label>
      <input
        type="password"
        v-model="form.password"
        :class="{ error: getFieldError('password') }"
        @blur="validateAll()"
      />
      <span v-if="getFieldError('password')" class="error-text">{{ getFieldError('password') }}</span>
    </div>

    <button type="submit" :disabled="!isDirty">提交</button>
    <button type="button" @click="reset">重置</button>
  </form>
</template>

<style scoped>
.error { border-color: red; }
.error-text { color: red; font-size: 0.8em; }
</style>

✅ 优势:

  • 表单逻辑完全独立于组件,可复用于登录页、资料修改页等。
  • 易于单元测试。
  • 支持动态字段配置。

三、状态管理:从局部状态到全局状态

3.1 局部状态管理:使用 useXxx Composable

对于组件内部状态,建议封装成 Composable,例如:

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

export function useLocalStorage<T>(key: string, initialValue: T): Ref<T> {
  const storedValue = localStorage.getItem(key)
  const value = ref<T>(
    storedValue ? JSON.parse(storedValue) : initialValue
  )

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

  return value
}

使用:

<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'

const theme = useLocalStorage('app-theme', 'light')

// 切换主题
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>

🌟 这种模式非常适合保存用户偏好、本地缓存等。

3.2 全局状态管理:Pinia vs Vuex

虽然 Vue 3 推荐使用 Pinia 作为官方推荐的状态管理库,但你可以通过 Composable + provide/inject 实现轻量级全局状态。

方案一:使用 Pinia(推荐)

// stores/userStore.ts
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    userInfo: null as any,
    isLoggedIn: false
  }),

  getters: {
    displayName() {
      return this.userInfo?.name || 'Anonymous'
    }
  },

  actions: {
    login(userData) {
      this.userInfo = userData
      this.isLoggedIn = true
    },

    logout() {
      this.userInfo = null
      this.isLoggedIn = false
    }
  }
})
<script setup>
import { useUserStore } from '@/stores/userStore'

const userStore = useUserStore()

const login = () => {
  userStore.login({ name: 'Alice', email: 'alice@example.com' })
}

const logout = () => {
  userStore.logout()
}
</script>

✅ Pinia 优势:

  • 原生支持 TypeScript
  • 支持热更新
  • 模块化设计,易于拆分
  • 无需额外插件即可使用

方案二:基于 provide/inject 手动实现全局状态(适用于小型项目)

// composables/useGlobalState.ts
import { provide, inject, ref } from 'vue'

const GLOBAL_STATE_KEY = 'globalState'

export function useGlobalState() {
  const state = inject(GLOBAL_STATE_KEY)
  if (!state) {
    throw new Error('useGlobalState must be used within a provider')
  }
  return state
}

export function provideGlobalState() {
  const state = {
    user: ref(null),
    theme: ref('light'),
    notifications: ref([] as Array<{ id: number; msg: string }>)
  }

  provide(GLOBAL_STATE_KEY, state)

  return state
}
<!-- App.vue -->
<script setup>
import { provideGlobalState } from '@/composables/useGlobalState'

provideGlobalState()
</script>
<!-- UserProfile.vue -->
<script setup>
import { useGlobalState } from '@/composables/useGlobalState'

const { user, theme, notifications } = useGlobalState()

const updateUser = (data) => {
  user.value = data
}

const addNotification = (msg) => {
  notifications.value.push({ id: Date.now(), msg })
}
</script>

⚠️ 注意:provide/inject 不支持响应式穿透,需确保 ref/reactive 正确包裹。

四、高级实战:复杂业务逻辑的组织与解耦

4.1 案例:商品详情页的“库存与价格联动”逻辑

需求:商品详情页需要根据数量、规格、促销活动动态计算总价,并实时更新库存状态。

抽象 Composable:useProductPriceCalculator

// composable/useProductPriceCalculator.ts
import { ref, computed, watch } from 'vue'

export interface ProductOption {
  id: string
  name: string
  price: number
  stock: number
  selected: boolean
}

export interface Promotion {
  id: string
  name: string
  discountRate: number
  condition: (quantity: number) => boolean
}

interface ProductConfig {
  basePrice: number
  options: ProductOption[]
  promotions: Promotion[]
  maxQuantityPerOrder: number
}

export function useProductPriceCalculator(config: ProductConfig) {
  const quantity = ref(1)
  const selectedOptionId = ref<string | null>(null)

  const selectedOption = computed(() => {
    return config.options.find(opt => opt.id === selectedOptionId.value)
  })

  const total = computed(() => {
    const base = selectedOption.value?.price ?? config.basePrice
    const subtotal = base * quantity.value
    const promotion = config.promotions.find(p => p.condition(quantity.value))

    if (promotion) {
      return subtotal * (1 - promotion.discountRate)
    }

    return subtotal
  })

  const availableStock = computed(() => {
    const option = selectedOption.value
    if (!option) return Infinity
    return Math.min(option.stock, config.maxQuantityPerOrder)
  })

  const canAddToCart = computed(() => {
    return quantity.value <= availableStock.value
  })

  const addToCart = () => {
    if (!canAddToCart.value) {
      console.warn('库存不足')
      return false
    }
    // 模拟添加到购物车
    console.log(`已加入 ${quantity.value} 件,总价:${total.value.toFixed(2)} 元`)
    return true
  }

  const updateQuantity = (val: number) => {
    const clamped = Math.max(1, Math.min(val, availableStock.value))
    quantity.value = clamped
  }

  const selectOption = (id: string) => {
    selectedOptionId.value = id
  }

  // 同步库存变化
  watch(selectedOption, () => {
    if (quantity.value > availableStock.value) {
      quantity.value = availableStock.value
    }
  })

  return {
    quantity,
    selectedOptionId,
    selectedOption,
    total,
    availableStock,
    canAddToCart,
    addToCart,
    updateQuantity,
    selectOption
  }
}

4.2 组件中使用:ProductDetail.vue

<script setup>
import { useProductPriceCalculator } from '@/composables/useProductPriceCalculator'

const productConfig = {
  basePrice: 199,
  maxQuantityPerOrder: 10,
  options: [
    { id: 'size-s', name: 'S', price: 199, stock: 50, selected: false },
    { id: 'size-m', name: 'M', price: 219, stock: 30, selected: true },
    { id: 'size-l', name: 'L', price: 239, stock: 15, selected: false }
  ],
  promotions: [
    {
      id: 'bulk-10',
      name: '满10件享9折',
      discountRate: 0.1,
      condition: q => q >= 10
    },
    {
      id: 'bulk-5',
      name: '满5件享95折',
      discountRate: 0.05,
      condition: q => q >= 5
    }
  ]
}

const {
  quantity,
  selectedOptionId,
  selectedOption,
  total,
  availableStock,
  canAddToCart,
  addToCart,
  updateQuantity,
  selectOption
} = useProductPriceCalculator(productConfig)
</script>

<template>
  <div class="product-detail">
    <h2>商品名称</h2>

    <!-- 规格选择 -->
    <div class="options">
      <label>请选择尺寸:</label>
      <div>
        <label v-for="opt in productConfig.options" :key="opt.id">
          <input
            type="radio"
            :value="opt.id"
            v-model="selectedOptionId"
            @change="selectOption(opt.id)"
          />
          {{ opt.name }} (¥{{ opt.price }}) - 库存:{{ opt.stock }}
        </label>
      </div>
    </div>

    <!-- 数量控制 -->
    <div class="quantity-control">
      <label>数量:</label>
      <button @click="updateQuantity(quantity - 1)" :disabled="quantity <= 1">-</button>
      <span>{{ quantity }}</span>
      <button @click="updateQuantity(quantity + 1)" :disabled="quantity >= availableStock">+</button>
      <span class="stock-info">
        可售:{{ availableStock }} 件
      </span>
    </div>

    <!-- 总价显示 -->
    <div class="total">
      <strong>总价:¥{{ total.toFixed(2) }}</strong>
    </div>

    <!-- 购买按钮 -->
    <button
      @click="addToCart"
      :disabled="!canAddToCart"
      :class="{ disabled: !canAddToCart }"
    >
      {{ canAddToCart ? '加入购物车' : '库存不足' }}
    </button>
  </div>
</template>

<style scoped>
.product-detail { padding: 20px; }
.options label { margin-right: 10px; }
.quantity-control button { width: 30px; height: 30px; margin: 0 5px; }
.stock-info { color: #666; font-size: 0.9em; }
.disabled { opacity: 0.5; cursor: not-allowed; }
</style>

✅ 本案例体现 Composition API 的核心优势:

  • 业务逻辑集中在一个文件中,清晰可读。
  • 状态与视图分离,便于单元测试。
  • 可轻松复用于其他商品页或弹窗组件。

五、最佳实践总结

主题 最佳实践
命名规范 useXxx 前缀表示 Composable;xxxRef 表示响应式引用
类型安全 使用 TypeScript 定义接口与泛型
副作用管理 watchonMounted 等中及时清理资源
依赖注入 尽量避免过度使用 provide/inject,优先考虑 Pinia
组合顺序 ref/reactive 放在顶部,逻辑按功能分组
测试友好 所有逻辑应可独立测试,不依赖 DOM

5.1 单元测试示例(Jest + Vitest)

// tests/useProductPriceCalculator.test.ts
import { describe, it, expect } from 'vitest'
import { useProductPriceCalculator } from '@/composables/useProductPriceCalculator'

const config = {
  basePrice: 100,
  maxQuantityPerOrder: 5,
  options: [
    { id: 's', name: 'S', price: 100, stock: 10, selected: true },
    { id: 'm', name: 'M', price: 120, stock: 5, selected: false }
  ],
  promotions: [
    { id: '5', name: '满5件9折', discountRate: 0.1, condition: q => q >= 5 }
  ]
}

describe('useProductPriceCalculator', () => {
  it('should calculate correct total with promotion', () => {
    const { quantity, total, addToCart } = useProductPriceCalculator(config)
    quantity.value = 5
    expect(total.value).toBe(500 * 0.9) // 450
  })

  it('should prevent adding more than stock', () => {
    const { quantity, availableStock } = useProductPriceCalculator(config)
    quantity.value = 10
    expect(quantity.value).toBe(availableStock.value) // 5
  })
})

结语:拥抱 Composition API,构建可维护的现代前端架构

Vue 3 的 Composition API 不仅仅是一次语法升级,更是前端工程思想的一次跃迁。它让我们从“组件为中心”的思维转向“逻辑为中心”的设计模式。

通过合理使用 Composable 函数,我们可以:

  • 将重复逻辑提取为可复用模块;
  • 使复杂业务逻辑清晰可读;
  • 极大提升团队协作效率;
  • 为未来迁移至微前端、服务端渲染打下坚实基础。

🔥 记住:一个好的 Composable,应该像一个“乐高积木”——独立、可组合、可测试。

从今天开始,让每个 useXXX 都成为你代码库中最干净、最优雅的部分。

参考文档

✅ 本文完整代码可在 GitHub 仓库中获取:github.com/example/vue3-composition-practice

相关推荐
广告位招租

相似文章

    评论 (0)

    0/2000