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 })

性能优化实战技巧
在一个包含 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 类型系统、关注性能细节,我们可以构建出既优雅又高效的大型前端应用。团队中的每一位开发者都应该深入理解这些核心概念,并在实际项目中不断磨练和完善自己的实践。