Vue 3 Composition API 实战:构建可复用的响应式组件库

WellVictor
WellVictor 2026-02-08T01:07:15+08:00
0 0 0

前言

随着前端技术的快速发展,Vue 3 的发布为开发者带来了全新的开发体验。其中最引人注目的特性之一就是 Composition API,它让开发者能够更灵活地组织和复用逻辑代码。在现代前端开发中,构建可复用、可维护的组件库已成为提升开发效率的关键。

本文将深入探讨 Vue 3 Composition API 的高级用法,通过实际案例展示如何封装通用逻辑、创建可复用的组合式函数,并最终构建一个响应式的组件库。我们将从基础概念出发,逐步深入到复杂场景的应用实践,帮助开发者掌握这一强大的工具。

Vue 3 Composition API 核心概念

什么是 Composition API

Composition API 是 Vue 3 中引入的一种新的组件逻辑组织方式。与传统的 Options API 不同,Composition API 允许我们以函数的形式组织和复用组件逻辑,使得代码更加灵活和可组合。

// 传统 Options API
export default {
  data() {
    return {
      count: 0,
      message: ''
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  computed: {
    doubledCount() {
      return this.count * 2
    }
  }
}
// Composition API
import { ref, computed } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const message = ref('')
    
    const doubledCount = computed(() => count.value * 2)
    
    const increment = () => {
      count.value++
    }
    
    return {
      count,
      message,
      doubledCount,
      increment
    }
  }
}

Composition API 的优势

  1. 逻辑复用:通过组合式函数,可以轻松地在多个组件间共享逻辑
  2. 更好的类型支持:TypeScript 集成更加自然
  3. 更灵活的代码组织:按照功能而非选项类型来组织代码
  4. 更小的包体积:按需引入,减少不必要的代码

创建可复用的组合式函数

基础组合式函数示例

让我们从一个简单的例子开始,创建一个用于处理表单验证的组合式函数:

// composables/useFormValidation.js
import { ref, reactive, computed } from 'vue'

export function useFormValidation(initialData = {}) {
  const formData = reactive({ ...initialData })
  const errors = ref({})
  const isValid = computed(() => Object.keys(errors.value).length === 0)
  
  const validateField = (fieldName, value, rules) => {
    if (!rules || rules.length === 0) return ''
    
    for (const rule of rules) {
      if (rule.required && !value) {
        return rule.message || `${fieldName} 是必填项`
      }
      
      if (rule.minLength && value.length < rule.minLength) {
        return rule.message || `${fieldName} 长度不能少于 ${rule.minLength} 位`
      }
      
      if (rule.pattern && !rule.pattern.test(value)) {
        return rule.message || `${fieldName} 格式不正确`
      }
    }
    
    return ''
  }
  
  const validate = (fieldRules) => {
    const newErrors = {}
    
    for (const [fieldName, rules] of Object.entries(fieldRules)) {
      const value = formData[fieldName]
      const error = validateField(fieldName, value, rules)
      
      if (error) {
        newErrors[fieldName] = error
      }
    }
    
    errors.value = newErrors
    return isValid.value
  }
  
  const setFieldValue = (fieldName, value) => {
    formData[fieldName] = value
    // 实时验证
    if (errors.value[fieldName]) {
      const fieldRules = {} // 需要根据实际场景获取规则
      validateField(fieldName, value, fieldRules[fieldName])
    }
  }
  
  return {
    formData,
    errors,
    isValid,
    validate,
    setFieldValue
  }
}

高级组合式函数:数据获取和缓存

// composables/useApi.js
import { ref, watch } from 'vue'

export function useApi(apiFunction, options = {}) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)
  const cache = new Map()
  
  const { 
    cacheKey = null, 
    autoLoad = true, 
    refreshOnMount = false 
  } = options
  
  const fetchData = async (params = {}) => {
    try {
      loading.value = true
      error.value = null
      
      // 检查缓存
      if (cacheKey && cache.has(cacheKey)) {
        data.value = cache.get(cacheKey)
        return data.value
      }
      
      const result = await apiFunction(params)
      data.value = result
      
      // 存储到缓存
      if (cacheKey) {
        cache.set(cacheKey, result)
      }
      
      return result
    } catch (err) {
      error.value = err
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const refresh = async () => {
    if (cacheKey && cache.has(cacheKey)) {
      cache.delete(cacheKey)
    }
    return fetchData()
  }
  
  // 自动加载
  if (autoLoad) {
    fetchData()
  }
  
  // 监听参数变化并重新获取数据
  watch(() => data.value, () => {
    // 可以在这里添加数据变更后的逻辑
  })
  
  return {
    data,
    loading,
    error,
    refresh,
    fetchData
  }
}

状态管理组合式函数

// composables/useGlobalState.js
import { ref, reactive, watch } from 'vue'

export function useGlobalState(initialState = {}) {
  const state = reactive({ ...initialState })
  
  // 持久化存储
  const persistKey = '__global_state__'
  
  const loadFromStorage = () => {
    try {
      const stored = localStorage.getItem(persistKey)
      if (stored) {
        Object.assign(state, JSON.parse(stored))
      }
    } catch (error) {
      console.error('Failed to load state from storage:', error)
    }
  }
  
  const saveToStorage = () => {
    try {
      localStorage.setItem(persistKey, JSON.stringify(state))
    } catch (error) {
      console.error('Failed to save state to storage:', error)
    }
  }
  
  // 监听状态变化并保存到存储
  watch(state, saveToStorage, { deep: true })
  
  // 初始化时从存储加载
  loadFromStorage()
  
  const setState = (key, value) => {
    state[key] = value
  }
  
  const resetState = () => {
    Object.assign(state, initialState)
  }
  
  return {
    state,
    setState,
    resetState,
    loadFromStorage,
    saveToStorage
  }
}

构建响应式组件库

组件库架构设计

一个良好的组件库应该具备以下特性:

  1. 模块化:每个组件独立,可按需引入
  2. 可配置性:支持丰富的配置选项
  3. 可扩展性:易于扩展和定制
  4. 类型安全:提供完整的 TypeScript 支持
// components/DataTable.vue
<template>
  <div class="data-table">
    <div class="table-header">
      <div class="search-box" v-if="showSearch">
        <input 
          v-model="searchQuery" 
          placeholder="搜索..."
          class="search-input"
        />
      </div>
      
      <div class="actions" v-if="showActions">
        <button @click="refreshData" class="btn-refresh">
          刷新
        </button>
        <slot name="actions"></slot>
      </div>
    </div>
    
    <div class="table-container">
      <table class="table">
        <thead>
          <tr>
            <th 
              v-for="column in columns" 
              :key="column.key"
              @click="sortColumn(column.key)"
            >
              {{ column.title }}
              <span v-if="sortBy === column.key" class="sort-indicator">
                {{ sortOrder === 'asc' ? '↑' : '↓' }}
              </span>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="row in filteredData" :key="row.id">
            <td v-for="column in columns" :key="column.key">
              <component 
                :is="column.component || 'span'"
                :data="row"
                :value="row[column.key]"
                v-bind="column.props || {}"
              >
                {{ formatValue(row[column.key], column) }}
              </component>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    
    <div class="pagination" v-if="showPagination">
      <button 
        @click="prevPage" 
        :disabled="currentPage === 1"
        class="btn-page"
      >
        上一页
      </button>
      
      <span class="page-info">
        {{ currentPage }} / {{ totalPages }}
      </span>
      
      <button 
        @click="nextPage" 
        :disabled="currentPage === totalPages"
        class="btn-page"
      >
        下一页
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { useApi } from '../composables/useApi'

const props = defineProps({
  columns: {
    type: Array,
    required: true
  },
  apiUrl: {
    type: String,
    required: true
  },
  pageSize: {
    type: Number,
    default: 10
  },
  showSearch: {
    type: Boolean,
    default: true
  },
  showActions: {
    type: Boolean,
    default: true
  },
  showPagination: {
    type: Boolean,
    default: true
  }
})

const emit = defineEmits(['row-click', 'data-loaded'])

// 组合式函数
const { data, loading, error, fetchData } = useApi(
  (params) => fetch(props.apiUrl, { params }),
  { autoLoad: false }
)

// 响应式状态
const searchQuery = ref('')
const currentPage = ref(1)
const sortBy = ref('')
const sortOrder = ref('asc')

// 计算属性
const filteredData = computed(() => {
  if (!data.value) return []
  
  let result = [...data.value.data]
  
  // 搜索过滤
  if (searchQuery.value) {
    const query = searchQuery.value.toLowerCase()
    result = result.filter(item => 
      Object.values(item).some(value => 
        String(value).toLowerCase().includes(query)
      )
    )
  }
  
  // 排序
  if (sortBy.value) {
    result.sort((a, b) => {
      const aValue = a[sortBy.value]
      const bValue = b[sortBy.value]
      
      if (aValue < bValue) return sortOrder.value === 'asc' ? -1 : 1
      if (aValue > bValue) return sortOrder.value === 'asc' ? 1 : -1
      return 0
    })
  }
  
  return result
})

const totalPages = computed(() => {
  if (!data.value) return 1
  return Math.ceil(data.value.total / props.pageSize)
})

const paginatedData = computed(() => {
  const start = (currentPage.value - 1) * props.pageSize
  const end = start + props.pageSize
  return filteredData.value.slice(start, end)
})

// 方法
const refreshData = async () => {
  await fetchData()
  emit('data-loaded', data.value)
}

const sortColumn = (columnKey) => {
  if (sortBy.value === columnKey) {
    sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
  } else {
    sortBy.value = columnKey
    sortOrder.value = 'asc'
  }
}

const prevPage = () => {
  if (currentPage.value > 1) {
    currentPage.value--
  }
}

const nextPage = () => {
  if (currentPage.value < totalPages.value) {
    currentPage.value++
  }
}

const formatValue = (value, column) => {
  if (column.formatter) {
    return column.formatter(value)
  }
  return value
}

// 监听数据变化
watch(data, () => {
  currentPage.value = 1
})

// 初始化加载数据
refreshData()
</script>

<style scoped>
.data-table {
  width: 100%;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.table-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border-bottom: 1px solid #eee;
}

.search-box {
  flex: 1;
  max-width: 300px;
}

.search-input {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}

.actions {
  display: flex;
  gap: 8px;
}

.btn-refresh, .btn-page {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 4px;
  cursor: pointer;
}

.btn-refresh:hover, .btn-page:hover {
  background: #f5f5f5;
}

.table-container {
  overflow-x: auto;
}

.table {
  width: 100%;
  border-collapse: collapse;
}

.table th,
.table td {
  padding: 12px;
  text-align: left;
  border-bottom: 1px solid #eee;
}

.table th {
  background-color: #f8f9fa;
  font-weight: 600;
  cursor: pointer;
  user-select: none;
}

.table th:hover {
  background-color: #e9ecef;
}

.sort-indicator {
  margin-left: 4px;
  font-size: 12px;
}

.pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 16px;
  gap: 8px;
}

.page-info {
  font-size: 14px;
  color: #666;
}
</style>

自定义 Hook 的使用

// composables/useDataTable.js
import { ref, computed, watch } from 'vue'
import { useApi } from './useApi'

export function useDataTable(props) {
  const { 
    apiUrl, 
    pageSize = 10,
    showSearch = true,
    showActions = true,
    showPagination = true 
  } = props
  
  // API 状态
  const { data, loading, error, fetchData, refresh } = useApi(
    (params) => fetch(apiUrl, { params }),
    { autoLoad: false }
  )
  
  // 表格状态
  const searchQuery = ref('')
  const currentPage = ref(1)
  const sortBy = ref('')
  const sortOrder = ref('asc')
  
  // 计算属性
  const filteredData = computed(() => {
    if (!data.value) return []
    
    let result = [...data.value.data]
    
    // 搜索过滤
    if (searchQuery.value) {
      const query = searchQuery.value.toLowerCase()
      result = result.filter(item => 
        Object.values(item).some(value => 
          String(value).toLowerCase().includes(query)
        )
      )
    }
    
    // 排序
    if (sortBy.value) {
      result.sort((a, b) => {
        const aValue = a[sortBy.value]
        const bValue = b[sortBy.value]
        
        if (aValue < bValue) return sortOrder.value === 'asc' ? -1 : 1
        if (aValue > bValue) return sortOrder.value === 'asc' ? 1 : -1
        return 0
      })
    }
    
    return result
  })
  
  const totalPages = computed(() => {
    if (!data.value) return 1
    return Math.ceil(data.value.total / pageSize)
  })
  
  const paginatedData = computed(() => {
    const start = (currentPage.value - 1) * pageSize
    const end = start + pageSize
    return filteredData.value.slice(start, end)
  })
  
  // 方法
  const refreshData = async () => {
    await refresh()
  }
  
  const sortColumn = (columnKey) => {
    if (sortBy.value === columnKey) {
      sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
    } else {
      sortBy.value = columnKey
      sortOrder.value = 'asc'
    }
  }
  
  const prevPage = () => {
    if (currentPage.value > 1) {
      currentPage.value--
    }
  }
  
  const nextPage = () => {
    if (currentPage.value < totalPages.value) {
      currentPage.value++
    }
  }
  
  // 监听数据变化
  watch(data, () => {
    currentPage.value = 1
  })
  
  return {
    data,
    loading,
    error,
    searchQuery,
    currentPage,
    sortBy,
    sortOrder,
    filteredData,
    totalPages,
    paginatedData,
    refreshData,
    sortColumn,
    prevPage,
    nextPage
  }
}

最佳实践和性能优化

组件性能优化

// components/OptimizedList.vue
<template>
  <div class="optimized-list">
    <div 
      v-for="item in visibleItems" 
      :key="item.id"
      class="list-item"
      @click="$emit('item-click', item)"
    >
      {{ item.name }}
    </div>
    
    <!-- 虚拟滚动 -->
    <div 
      v-if="virtualScroll" 
      class="scroll-placeholder"
      :style="{ height: totalHeight + 'px' }"
    ></div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  visibleCount: {
    type: Number,
    default: 10
  },
  virtualScroll: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['item-click'])

// 滚动相关状态
const scrollTop = ref(0)
const containerHeight = ref(0)

// 计算可见项
const visibleItems = computed(() => {
  if (!props.virtualScroll) {
    return props.items.slice(0, props.visibleCount)
  }
  
  const startIndex = Math.floor(scrollTop.value / props.itemHeight)
  const endIndex = Math.min(
    startIndex + Math.ceil(containerHeight.value / props.itemHeight),
    props.items.length
  )
  
  return props.items.slice(startIndex, endIndex)
})

const totalHeight = computed(() => {
  return props.items.length * props.itemHeight
})

// 滚动处理
const handleScroll = (event) => {
  scrollTop.value = event.target.scrollTop
}

// 初始化容器高度
onMounted(() => {
  const container = document.querySelector('.optimized-list')
  if (container) {
    containerHeight.value = container.clientHeight
  }
})

onUnmounted(() => {
  // 清理资源
})
</script>

<style scoped>
.optimized-list {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #ddd;
}

.list-item {
  padding: 12px;
  border-bottom: 1px solid #eee;
  cursor: pointer;
  transition: background-color 0.2s;
}

.list-item:hover {
  background-color: #f5f5f5;
}

.scroll-placeholder {
  width: 100%;
}
</style>

响应式数据管理

// composables/useReactiveData.js
import { ref, reactive, computed, watch } from 'vue'

export function useReactiveData(initialData = {}) {
  const data = reactive({ ...initialData })
  const loading = ref(false)
  const error = ref(null)
  
  // 数据操作方法
  const setData = (key, value) => {
    data[key] = value
  }
  
  const updateData = (updates) => {
    Object.assign(data, updates)
  }
  
  const deleteData = (key) => {
    delete data[key]
  }
  
  // 计算属性
  const isEmpty = computed(() => {
    return Object.keys(data).length === 0
  })
  
  const hasKey = (key) => {
    return key in data
  }
  
  // 监听数据变化
  watch(data, () => {
    // 数据变更后的副作用处理
  }, { deep: true })
  
  // 返回所有可访问的属性
  return {
    data,
    loading,
    error,
    setData,
    updateData,
    deleteData,
    isEmpty,
    hasKey
  }
}

实际应用场景

表单组件库构建

// components/FormBuilder.vue
<template>
  <form @submit.prevent="handleSubmit" class="form-builder">
    <div 
      v-for="field in fields" 
      :key="field.name"
      class="form-field"
    >
      <label v-if="field.label" class="field-label">
        {{ field.label }}
        <span v-if="field.required" class="required">*</span>
      </label>
      
      <component
        :is="getFieldComponent(field.type)"
        v-model="formData[field.name]"
        :field="field"
        :errors="errors[field.name]"
        @input="handleFieldInput"
      />
      
      <div v-if="errors[field.name]" class="error-message">
        {{ errors[field.name] }}
      </div>
    </div>
    
    <div class="form-actions">
      <button type="submit" :disabled="loading || !isValid" class="btn-submit">
        {{ loading ? '提交中...' : '提交' }}
      </button>
      <button 
        type="button" 
        @click="resetForm" 
        class="btn-reset"
      >
        重置
      </button>
    </div>
  </form>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'
import { useFormValidation } from '../composables/useFormValidation'

const props = defineProps({
  fields: {
    type: Array,
    required: true
  },
  initialData: {
    type: Object,
    default: () => ({})
  }
})

const emit = defineEmits(['submit', 'reset'])

// 表单验证
const { formData, errors, isValid, validate } = useFormValidation(props.initialData)

// 状态管理
const loading = ref(false)
const submitted = ref(false)

// 字段组件映射
const getFieldComponent = (type) => {
  const components = {
    text: 'InputField',
    select: 'SelectField',
    textarea: 'TextAreaField',
    checkbox: 'CheckboxField',
    radio: 'RadioField'
  }
  return components[type] || 'InputField'
}

// 处理字段输入
const handleFieldInput = (field, value) => {
  formData[field.name] = value
  // 实时验证
  if (errors.value[field.name]) {
    validateField(field.name, value)
  }
}

// 验证单个字段
const validateField = (fieldName, value) => {
  const field = props.fields.find(f => f.name === fieldName)
  if (!field || !field.rules) return
  
  const error = validateFieldRule(value, field.rules)
  if (error) {
    errors.value[fieldName] = error
  } else {
    delete errors.value[fieldName]
  }
}

// 提交表单
const handleSubmit = async () => {
  if (!isValid.value) {
    validate()
    return
  }
  
  loading.value = true
  try {
    await emit('submit', { ...formData })
    submitted.value = true
  } catch (err) {
    console.error('Form submission error:', err)
  } finally {
    loading.value = false
  }
}

// 重置表单
const resetForm = () => {
  Object.keys(formData).forEach(key => {
    formData[key] = props.initialData[key] || ''
  })
  errors.value = {}
  submitted.value = false
  emit('reset')
}

// 字段验证规则
const validateFieldRule = (value, rules) => {
  for (const rule of rules) {
    if (rule.required && !value) {
      return rule.message || '此字段为必填项'
    }
    
    if (rule.minLength && value.length < rule.minLength) {
      return rule.message || `最少需要 ${rule.minLength} 个字符`
    }
    
    if (rule.pattern && !rule.pattern.test(value)) {
      return rule.message || '输入格式不正确'
    }
  }
  return ''
}

// 初始化表单数据
Object.keys(props.initialData).forEach(key => {
  formData[key] = props.initialData[key]
})
</script>

<style scoped>
.form-builder {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
}

.form-field {
  margin-bottom: 20px;
}

.field-label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
}

.required {
  color: #e74c3c;
}

.error-message {
  color: #e74c3c;
  font-size: 12px;
  margin-top: 4px;
}

.form-actions {
  display: flex;
  gap: 12px;
  margin-top: 30px;
}

.btn-submit, .btn-reset {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.btn-submit {
  background-color: #3498db;
  color: white;
}

.btn-submit:disabled {
  background-color: #bdc3c7;
  cursor: not-allowed;
}

.btn-reset {
  background-color: #95a5a6;
  color: white;
}
</style>

总结与展望

通过本文的深入探讨,我们看到了 Vue 3 Composition API 在构建可复用组件库方面的强大能力。从基础的组合式函数创建,到复杂的响应式数据管理,再到实际的组件实现,Composition API 提供了前所未有的灵活性和可组合性。

关键收获

  1. 逻辑复用:通过组合式函数,可以将通用逻辑封装成独立的可复用单元
  2. 状态管理:更直观的状态管理和响应式数据处理方式
  3. 性能优化:结合虚拟滚动、懒加载等技术提升组件性能
  4. 类型安全:更好的 TypeScript 支持让代码更加健壮

未来发展趋势

随着 Vue 生态系统的不断发展,Composition API 将会:

  1. 进一步完善:更多的内置组合式函数和工具函数 2
相关推荐
广告位招租

相似文章

    评论 (0)

    0/2000