Vue 3 组合式 API 最佳实践 – 优易云科技技术博客

Vue 3 组合式 API 最佳实践:从入门到大型项目实战

分类:前端与移动端 | 标签:Vue 3, Composition API, TypeScript, Pinia, 前端

发布时间:2026-04-18 | 作者:优易云科技前端团队

引言

我们团队从 2022 年初开始在新项目中全面采用 Vue 3 + Composition API + TypeScript 的技术栈。两年多来,我们交付了 15+ 个中大型前端项目,涵盖工业管理后台、数据可视化大屏、移动端 H5 和企业级 SaaS 平台。在这个过程中,我们踩过不少坑,也总结出了一套行之有效的最佳实践。本文将从实际项目经验出发,系统性地分享我们对 Composition API 的理解和应用。

Composition API vs Options API:何时选择哪种

这是一个被反复讨论的话题。我们的立场很明确:新项目一律使用 Composition API,但并不意味着 Options API 就一无是处。

Options API 的优势场景

  • 简单组件(如表单输入、纯展示组件):Options API 的 data/methods/computed 结构直观,上手成本低
  • 团队中 Vue 2 老成员过渡期:可以作为临时方案
  • 第三方组件库的示例代码:大多数库仍然提供 Options API 示例

Composition API 的核心优势

在大型项目中,Composition API 的优势会在代码量达到一定规模后变得非常明显:

// Options API 的问题:逻辑碎片化
// 当一个组件有搜索、分页、导出、权限控制等功能时,
// 相关逻辑分散在 data、methods、computed、watch、mounted 中,
// 需要来回跳转才能理解完整逻辑流。

// Composition API 的优势:逻辑内聚
// 相同关注点的代码放在一起,可以提取为独立的 composable

// --- Options API 示例 ---
export default {
  data() {
    return {
      searchQuery: '',
      searchResults: [],
      searchLoading: false,
      // 其他 20+ 个响应式变量...
      tableData: [],
      pagination: { page: 1, pageSize: 20, total: 0 },
      selectedRows: [],
    }
  },
  watch: {
    searchQuery: {
      handler: 'debouncedSearch',
      immediate: false
    }
  },
  methods: {
    async debouncedSearch() { /* ... */ },
    async fetchSearchResults() { /* ... */ },
    handleSearch() { /* ... */ },
    clearSearch() { /* ... */ },
    // 其他 30+ 个方法...
  },
  computed: {
    filteredResults() { /* ... */ },
    hasResults() { /* ... */ },
  }
}

// --- Composition API 示例(逻辑清晰聚合)---
import { useSearch } from '@/composables/useSearch'
import { useTable } from '@/composables/useTable'
import { usePermission } from '@/composables/usePermission'

export default defineComponent({
  setup() {
    // 搜索相关逻辑全部在此
    const { searchQuery, results, loading, search, clear } = useSearch('/api/devices')

    // 表格相关逻辑全部在此
    const { data, pagination, selection, fetchPage, handleSort } = useTable(fetchDevices)

    // 权限相关逻辑全部在此
    const { canExport, canEdit, canDelete } = usePermission('device-management')

    // 组件级逻辑(将上述 composable 串联起来)
    const performSearch = () => {
      pagination.value.page = 1
      search(searchQuery.value)
    }

    return {
      searchQuery, results, loading, performSearch, clear,
      data, pagination, selection, fetchPage, handleSort,
      canExport, canEdit, canDelete
    }
  }
})

setup 语法糖深度使用

Vue 3.2 引入的 <script setup> 语法糖是目前我们推荐的默认写法。它在编译时做了大量优化,不仅减少了样板代码,还有更好的类型推导和运行时性能。

<!-- 使用 <script setup> 编写设备监控卡片组件 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import type { DeviceInfo, SensorData } from '@/types/device'
import { useWebSocket } from '@/composables/useWebSocket'
import { useDeviceStore } from '@/stores/device'
import { formatTime, formatValue } from '@/utils/format'

// Props(编译器宏,无需导入)
const props = defineProps<{
  deviceId: string
  refreshInterval?: number  // 默认 5 秒
  showHistory?: boolean     // 默认 false
}>()

// Emits
const emit = defineEmits<{
  (e: 'alarm', data: SensorData): void
  (e: 'click', device: DeviceInfo): void
}>()

// 响应式状态
const sensorData = ref<SensorData | null>(null)
const historyData = ref<SensorData[]>([])
const isOnline = ref(false)
const lastUpdate = ref<Date | null>(null)

// 计算属性
const statusText = computed(() => isOnline.value ? '在线' : '离线')
const isAlarm = computed(() => {
  if (!sensorData.value) return false
  return sensorData.value.value > sensorData.value.alarmThreshold
})

// WebSocket 实时连接
const { connect, disconnect, onMessage } = useWebSocket(
  `wss://ws.youyiyun.com/device/${props.deviceId}`,
  {
    autoConnect: true,
    reconnect: true,
    reconnectInterval: 3000,
    maxReconnectAttempts: 10
  }
)

onMessage((data: SensorData) => {
  sensorData.value = data
  lastUpdate.value = new Date()
  isOnline.value = true

  if (isAlarm.value) {
    emit('alarm', data)
  }

  if (props.showHistory) {
    historyData.value.push(data)
    if (historyData.value.length > 100) {
      historyData.value.shift()
    }
  }
})

// Store 交互
const deviceStore = useDeviceStore()
const deviceInfo = computed(() => deviceStore.getDeviceById(props.deviceId))

// 生命周期
onMounted(() => {
  deviceStore.fetchDeviceDetail(props.deviceId)
})

onUnmounted(() => {
  disconnect()
})

// 暴露给模板的方法
const handleRefresh = async () => {
  const data = await deviceStore.fetchLatestSensor(props.deviceId)
  if (data) {
    sensorData.value = data
    lastUpdate.value = new Date()
  }
}
</script>

泛型组件

TypeScript 泛型组件是 Vue 3.3+ 的高级特性,我们在通用组件库中大量使用。它允许组件接收泛型参数,使得类型推断更加精确。

<!-- 通用列表组件,支持泛型 -->
<script setup lang="ts" generic="T extends { id: string | number }">
import type { Ref } from 'vue'

const props = defineProps<{
  data: Ref<T[]>
  selected?: Ref<T | null>
  keyField?: keyof T
  render: (item: T) => string
}>()

const emit = defineEmits<{
  (e: 'select', item: T): void
  (e: 'delete', item: T): void
}>()
</script>

自定义 Composable 设计模式

Composable 是 Composition API 的灵魂。一个好的 composable 应该是:单一职责、可复用、有清晰的输入输出接口、自带资源清理机制。我们总结了几种常用的 composable 设计模式。

模式一:Pinia Store Composable(Store-Enhanced Composable)

在很多项目中,开发者会直接在组件中调用 Store 的 action 和 getter。但我们更推荐封装一层 composable,将 Store 操作与组件本地状态、UI 逻辑整合在一起。

// composables/useDeviceManagement.ts
// 封装设备管理相关逻辑:Store + 本地状态 + UI 交互
import { ref, computed, watch } from 'vue'
import { useDeviceStore } from '@/stores/device'
import { useNotification } from '@/composables/useNotification'
import type { DeviceCreateInfo, DeviceFilter, Device } from '@/types/device'

export function useDeviceManagement() {
  const store = useDeviceStore()
  const { notifySuccess, notifyError } = useNotification()

  // 本地 UI 状态(不属于 Store 的关注点)
  const searchKeyword = ref('')
  const selectedDeviceIds = ref<string[]>([])
  const isCreateDialogOpen = ref(false)
  const isBatchDeleteDialogOpen = ref(false)
  const isSubmitting = ref(false)

  // 派生计算属性
  const filteredDevices = computed(() => {
    let result = store.devices
    if (searchKeyword.value) {
      const keyword = searchKeyword.value.toLowerCase()
      result = result.filter(d =>
        d.name.toLowerCase().includes(keyword) ||
        d.sn.toLowerCase().includes(keyword)
      )
    }
    return result
  })

  const hasSelection = computed(() => selectedDeviceIds.value.length > 0)
  const selectedCount = computed(() => selectedDeviceIds.value.length)

  // 业务操作方法
  async function createDevice(data: DeviceCreateInfo) {
    isSubmitting.value = true
    try {
      await store.createDevice(data)
      isCreateDialogOpen.value = false
      notifySuccess('设备创建成功')
    } catch (error) {
      notifyError(`设备创建失败: ${(error as Error).message}`)
      throw error
    } finally {
      isSubmitting.value = false
    }
  }

  async function batchDelete() {
    isSubmitting.value = true
    try {
      await store.batchDeleteDevices(selectedDeviceIds.value)
      selectedDeviceIds.value = []
      isBatchDeleteDialogOpen.value = false
      notifySuccess(`成功删除 ${selectedCount.value} 台设备`)
    } catch (error) {
      notifyError(`批量删除失败: ${(error as Error).message}`)
    } finally {
      isSubmitting.value = false
    }
  }

  // 自动加载
  watch(
    () => store.currentFilter,
    (filter) => store.fetchDevices(filter),
    { immediate: true, deep: true }
  )

  return {
    // Store 数据(透传)
    devices: computed(() => store.devices),
    loading: computed(() => store.loading),
    total: computed(() => store.total),
    pagination: computed(() => store.pagination),

    // 本地 UI 状态
    searchKeyword,
    selectedDeviceIds,
    isCreateDialogOpen,
    isBatchDeleteDialogOpen,
    isSubmitting,

    // 派生状态
    filteredDevices,
    hasSelection,
    selectedCount,

    // 操作方法
    createDevice,
    batchDelete,
    updateFilter: store.updateFilter,
    fetchDevices: store.fetchDevices,
  }
}

模式二:useAsyncData 异步数据管理

在大型项目中,几乎每个组件都需要处理异步数据加载。我们封装了一个通用的 useAsyncData composable,统一管理 loading、error、refresh 和缓存逻辑。

// composables/useAsyncData.ts
import { ref, computed, type Ref } from 'vue'
import type { ComputedRef } from 'vue'

interface UseAsyncDataOptions<T> {
  /** 请求函数 */
  fetcher: (...args: any[]) => Promise<T>
  /** 是否立即执行 */
  immediate?: boolean
  /** 默认值 */
  defaultValue?: T
  /** 错误回调 */
  onError?: (error: Error) => void
  /** 成功回调 */
  onSuccess?: (data: T) => void
  /** 依赖响应式数据,变化时自动重新请求 */
  watch?: Ref[]
  /** 防抖延迟(毫秒),对 watch 依赖变化生效 */
  debounce?: number
}

interface AsyncDataResult<T> {
  data: Ref<T | undefined>
  loading: Ref<boolean>
  error: Ref<Error | null>
  isReady: ComputedRef<boolean>
  execute: (...args: any[]) => Promise<T | undefined>
  refresh: () => Promise<T | undefined>
  reset: () => void
}

export function useAsyncData<T>(
  options: UseAsyncDataOptions<T>
): AsyncDataResult<T> {
  const {
    fetcher,
    immediate = true,
    defaultValue,
    onError,
    onSuccess,
    watch: watchSources,
    debounce: debounceMs = 0
  } = options

  const data = ref<T | undefined>(defaultValue) as Ref<T | undefined>
  const loading = ref(false)
  const error = ref<Error | null>(null)
  const isReady = computed(() => !loading.value && error.value === null)

  let debounceTimer: ReturnType<typeof setTimeout> | null = null

  async function execute(...args: any[]): Promise<T | undefined> {
    loading.value = true
    error.value = null

    try {
      const result = await fetcher(...args)
      data.value = result
      onSuccess?.(result)
      return result
    } catch (e) {
      const err = e instanceof Error ? e : new Error(String(e))
      error.value = err
      onError?.(err)
      return undefined
    } finally {
      loading.value = false
    }
  }

  function refresh() {
    return execute()
  }

  function reset() {
    data.value = defaultValue
    error.value = null
    loading.value = false
  }

  // 自动监听依赖变化
  if (watchSources && watchSources.length > 0) {
    import('vue').then(({ watch }) => {
      watch(
        watchSources,
        () => {
          if (debounceMs > 0) {
            if (debounceTimer) clearTimeout(debounceTimer)
            debounceTimer = setTimeout(execute, debounceMs)
          } else {
            execute()
          }
        },
        { deep: true }
      )
    })
  }

  if (immediate) {
    execute()
  }

  return { data, loading, error, isReady, execute, refresh, reset }
}

// --- 使用示例 ---
// 在组件中使用,自动管理 loading/error 状态
const { data: devices, loading, error, refresh } = useAsyncData({
  fetcher: () => deviceApi.getList({ page: 1, size: 20 }),
  immediate: true,
  onError: (err) => console.error('加载设备列表失败', err)
})

模式三:useEventListener 事件管理

// composables/useEventListener.ts
import { onMounted, onUnmounted, isRef, type Ref, unref } from 'vue'

type Target = EventTarget | Ref<EventTarget | null>
type EventOptions = boolean | AddEventListenerOptions

export function useEventListener<K extends keyof WindowEventMap>(
  target: Target,
  event: K,
  handler: (event: WindowEventMap[K]) => void,
  options?: EventOptions
): void

export function useEventListener<K extends keyof HTMLElementEventMap>(
  target: Target,
  event: K,
  handler: (event: HTMLElementEventMap[K]) => void,
  options?: EventOptions
): void

export function useEventListener(
  target: Target,
  event: string,
  handler: EventListenerOrEventListenerObject,
  options?: EventOptions
): void {
  if (!target) return

  const cleanup = () => {
    const el = unref(target)
    if (el) {
      el.removeEventListener(event, handler, options)
    }
  }

  const register = () => {
    const el = unref(target)
    if (el) {
      el.addEventListener(event, handler, options)
    }
  }

  onMounted(register)
  onUnmounted(cleanup)
}

// --- 使用示例:键盘快捷键 ---
// 自动在组件挂载时注册,卸载时清理
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
  if ((e.ctrlKey || e.metaKey) && e.key === 's') {
    e.preventDefault()
    handleSave()
  }
})

// 响应式 target
const tableRef = ref<HTMLElement | null>(null)
useEventListener(tableRef, 'scroll', handleTableScroll, { passive: true })

Vue 3 Composable设计模式架构图

性能优化实战技巧

在一个包含 500+ 组件的工业管理后台项目中,我们通过以下优化手段将首屏加载时间从 4.2 秒降低到 1.8 秒,长列表滚动帧率从 15fps 提升到稳定 60fps。

shallowRef 与 shallowReactive

对于大型数据对象(如表格数据、图表配置),使用 shallowRef 可以避免 Vue 对对象内部进行深度响应式代理,显著降低初始化开销和内存占用。

// 性能对比:处理 10000 行表格数据
import { ref, shallowRef, triggerRef } from 'vue'

// ❌ 深度响应式:初始化耗时 ~800ms,内存占用大
const tableData = ref<Device[]>([])

// ✅ 浅层响应式:初始化耗时 ~50ms,内存占用小
const tableData = shallowRef<Device[]>([])

// 修改数据后需要手动触发更新
async function loadData() {
  const response = await api.fetchDeviceList()
  tableData.value = response.data  // 替换整个引用,自动触发更新
}

// 如果需要修改数组内部元素且触发更新
function updateDeviceInList(id: string, patch: Partial<Device>) {
  const index = tableData.value.findIndex(d => d.id === id)
  if (index !== -1) {
    tableData.value[index] = { ...tableData.value[index], ...patch }
    triggerRef(tableData)  // 手动触发依赖更新
  }
}

markRaw 与 toRaw

对于第三方库实例(如 ECharts、Mapbox GL、PDF.js),绝不能被 Vue 的响应式系统代理,否则会导致严重的性能问题和内存泄漏。

import { markRaw } from 'vue'
import * as echarts from 'echarts'

// ❌ 错误:ECharts 实例被响应式代理,操作会触发大量不必要的更新
const chartInstance = ref(null)

// ✅ 正确:使用 markRaw 标记为非响应式对象
const chartInstance = ref<echarts.ECharts | null>(null)

onMounted(() => {
  const el = document.getElementById('chart-container')!
  // markRaw 返回的是原始对象,Vue 不会为其创建 Proxy
  chartInstance.value = markRaw(echarts.init(el, 'dark'))

  // 配置数据可以是响应式的(因为需要动态更新图表)
  const chartOptions = reactive({
    xAxis: { type: 'category', data: [] },
    yAxis: { type: 'value' },
    series: [{ type: 'line', data: [] }]
  })

  chartInstance.value.setOption(chartOptions)
})

v-memo 指令

v-memo 是 Vue 3.2 引入的性能优化指令,可以缓存整个子树的 VNode。在频繁更新的列表中,对不变的部分使用 v-memo 可以跳过 diff 比较。

<!-- 设备列表:只有设备状态变化时才重新渲染该项 -->
<div v-for="device in devices" :key="device.id" v-memo="[device.status, device.lastAlarm]">
  <DeviceCard :device="device" @click="handleClick(device)" />
</div>

<!-- 复杂的条件渲染缓存 -->
<div v-memo="[shouldRender]">
  <!-- 这个复杂的计算组件只有在 shouldRender 变化时才会重新计算 -->
  <ComplexChart :data="chartData" :options="chartOptions" />
</div>

Pinia + TypeScript 状态管理

Pinia 已经成为 Vue 3 生态中事实上的状态管理标准。结合 TypeScript,我们可以获得完整的类型安全和极佳的开发体验。

// stores/device.ts — 设备管理 Store
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Device, DeviceFilter, DeviceStats } from '@/types/device'
import { deviceApi } from '@/api/device'

export const useDeviceStore = defineStore('device', () => {
  // --- State ---
  const devices = ref<Device[]>([])
  const currentDevice = ref<Device | null>(null)
  const filter = ref<DeviceFilter>({
    keyword: '',
    status: 'all',
    factory: '',
    line: '',
    page: 1,
    pageSize: 20,
  })
  const total = ref(0)
  const loading = ref(false)
  const stats = ref<DeviceStats | null>(null)

  // --- Getters ---
  const onlineDevices = computed(() =>
    devices.value.filter(d => d.status === 'online')
  )
  const offlineDevices = computed(() =>
    devices.value.filter(d => d.status === 'offline')
  )
  const alarmDevices = computed(() =>
    devices.value.filter(d => d.hasAlarm)
  )
  const pagination = computed(() => ({
    page: filter.value.page,
    pageSize: filter.value.pageSize,
    total: total.value,
    totalPages: Math.ceil(total.value / filter.value.pageSize),
  }))

  // --- Actions ---
  async function fetchDevices(params?: Partial<DeviceFilter>) {
    loading.value = true
    try {
      if (params) Object.assign(filter.value, params)
      const { data, total: count } = await deviceApi.getList(filter.value)
      devices.value = data
      total.value = count
    } finally {
      loading.value = false
    }
  }

  async function fetchDeviceDetail(id: string) {
    const device = await deviceApi.getDetail(id)
    currentDevice.value = device
    return device
  }

  async function createDevice(data: Omit<Device, 'id' | 'createdAt'>) {
    const newDevice = await deviceApi.create(data)
    devices.value.unshift(newDevice)
    total.value++
    return newDevice
  }

  async function batchDeleteDevices(ids: string[]) {
    await deviceApi.batchDelete(ids)
    devices.value = devices.value.filter(d => !ids.includes(d.id))
    total.value -= ids.length
  }

  function updateFilter(patch: Partial<DeviceFilter>) {
    filter.value = { ...filter.value, ...patch }
  }

  function resetFilter() {
    filter.value = {
      keyword: '',
      status: 'all',
      factory: '',
      line: '',
      page: 1,
      pageSize: 20,
    }
  }

  async function fetchStats() {
    stats.value = await deviceApi.getStats()
  }

  return {
    // state
    devices, currentDevice, filter, total, loading, stats,
    // getters
    onlineDevices, offlineDevices, alarmDevices, pagination,
    // actions
    fetchDevices, fetchDeviceDetail, createDevice,
    batchDeleteDevices, updateFilter, resetFilter, fetchStats,
  }
}, {
  // Pinia 持久化配置
  persist: {
    key: 'device-store',
    paths: ['filter'],  // 只持久化筛选条件
    storage: localStorage,
  }
})

大型项目目录结构

经过多个大型项目的迭代,我们沉淀了以下目录结构。其核心设计原则是:按功能模块组织(而非按技术类型)、composable 提取可复用逻辑、类型定义集中管理。

src/
├── assets/                    # 静态资源
│   ├── images/
│   ├── styles/
│   │   ├── variables.css      # CSS 变量(设计 Token)
│   │   ├── global.css         # 全局样式
│   │   └── utilities.css      # 工具类
│   └── fonts/
├── components/                # 通用组件
│   ├── common/                # 基础通用组件
│   │   ├── AppHeader.vue
│   │   ├── AppSidebar.vue
│   │   ├── AppBreadcrumb.vue
│   │   └── ConfirmDialog.vue
│   ├── business/              # 业务通用组件
│   │   ├── DeviceStatusBadge.vue
│   │   ├── SensorChart.vue
│   │   ├── AlarmTimeline.vue
│   │   └── DataExportButton.vue
│   └── form/                  # 表单组件
│       ├── FormItem.vue
│       ├── SelectInput.vue
│       └── DateRangePicker.vue
├── composables/               # 可复用组合函数
│   ├── useAsyncData.ts
│   ├── useWebSocket.ts
│   ├── useEventListener.ts
│   ├── usePermission.ts
│   ├── useNotification.ts
│   ├── useDebounce.ts
│   └── useDeviceManagement.ts
├── directives/                # 自定义指令
│   ├── vPermission.ts         # 权限指令
│   ├── vLoading.ts
│   └── vClickOutside.ts
├── layouts/                   # 布局组件
│   ├── DefaultLayout.vue
│   ├── BlankLayout.vue
│   └── DashboardLayout.vue
├── pages/                     # 页面组件(按功能模块)
│   ├── dashboard/
│   │   ├── index.vue
│   │   ├── components/
│   │   │   ├── RealtimeChart.vue
│   │   │   ├── DeviceMap.vue
│   │   │   └── AlarmPanel.vue
│   │   └── composables/
│   │       └── useDashboardData.ts
│   ├── devices/
│   │   ├── index.vue          # 设备列表
│   │   ├── [id].vue           # 设备详情(动态路由)
│   │   ├── create.vue
│   │   └── composables/
│   │       └── useDeviceDetail.ts
│   ├── alarms/
│   │   ├── index.vue
│   │   └── components/
│   └── settings/
│       ├── index.vue
│       └── components/
├── stores/                    # Pinia Store
│   ├── index.ts               # Store 初始化 + 插件
│   ├── device.ts
│   ├── alarm.ts
│   ├── user.ts
│   └── app.ts
├── router/                    # 路由配置
│   ├── index.ts               # 路由实例
│   ├── routes.ts              # 路由表
│   └── guards.ts              # 路由守卫
├── api/                       # API 层
│   ├── request.ts             # Axios 实例 + 拦截器
│   ├── device.ts
│   ├── alarm.ts
│   └── user.ts
├── types/                     # TypeScript 类型定义
│   ├── device.ts
│   ├── alarm.ts
│   ├── user.ts
│   ├── api.ts                 # 通用 API 响应类型
│   └── global.d.ts
├── utils/                     # 工具函数
│   ├── format.ts
│   ├── storage.ts
│   ├── validators.ts
│   └── constants.ts
├── plugins/                   # 插件
│   ├── i18n.ts
│   └── dayjs.ts
├── App.vue
├── main.ts
└── env.d.ts

总结

Vue 3 的 Composition API 不仅是一种新的代码组织方式,更是一种思维模式的转变。它让我们能够以更灵活、更可组合的方式构建前端应用。通过合理设计 composable、善用 TypeScript 类型系统、关注性能细节,我们可以构建出既优雅又高效的大型前端应用。团队中的每一位开发者都应该深入理解这些核心概念,并在实际项目中不断磨练和完善自己的实践。