【Vue组件】封装组件该考虑的核心点

封装组件该考虑的事情 - 从规则到边界...

封装组件不只是看到能复用的UI或者功能封装,既要考虑以一打十,又能单一牛逼,才是王道!!有些xd封装的组件里面业务代码和基础功能耦合混杂,尤其是边界极为不清晰,导致别人用到该组件时,不能很好的扩展和自定义,最后别人就弃用了,还不如自己写自己的..,下面结合规则先行给大家聊聊组件封装该考虑的核心点,团队都能用的爽~~

目录

  1. 封装组件的动机与价值
  2. 封装组件要遵守的规则
  3. 定义边界:组件能做什么/不能做什么
  4. 如何封装一个"完美"的组件:步骤与检查清单
  5. 通用组件封装示例:BaseSelect(Vue3+TS)
  6. [ElementPlus 表格二次封装:DataTablePlus](#ElementPlus 表格二次封装:DataTablePlus "#elementplus-%E8%A1%A8%E6%A0%BC%E4%BA%8C%E6%AC%A1%E5%B0%81%E8%A3%85datatableplus")
  7. 总结要点与落地建议

为什么要封装组件:动机与价值

  • 复用与一致性:抽象通用交互与样式,降低重复代码与视觉/行为差异。
  • 可维护性:集中修复缺陷与升级依赖,减少"同样问题修多次"。
  • 性能与可用性:在统一抽象层引入缓存、虚拟滚动、懒加载、a11y 等策略。
  • 可扩展性:通过插槽、事件与配置,平衡"开箱即用"和"按需扩展"。

封装组件要遵守的规则

  1. 单一职责与最小API面:每个组件仅解决一个清晰问题;API 小而稳。
  2. 受控优先(v-model)+ 同步事件:值从外部控制,内部通过 update:modelValue 同步。
  3. 不劫持布局与样式:提供 classstylesizestatus 等可覆写点;避免硬编码布局。
  4. 组合大于继承:以组合(slots、props、emits)实现扩展,避免在同一组件里堆功能。
  5. 边界清晰:只做展示/交互逻辑,不内置业务;业务交给上层处理。
  6. 类型先行:用 TS 限定输入输出,显式化事件与插槽契约。
  7. 性能默认优化:避免不必要的响应式深拷贝;用 computed 缓存;按需渲染;支持异步/懒加载。
  8. 可测试可观测:暴露关键方法;定义可测行为;重要路径有日志与错误边界。
  9. 文档先于实现变更:任何破坏性变更需在文档与类型上先行体现。

定义边界:组件能做什么/不能做什么

  • 能做:
    • 提供清晰的输入 props,输出 emits/slots;
    • 封装通用交互(校验、状态、空态、加载态);
    • 提供 UI 可定制点(插槽/样式变量/配置);
    • 性能优化策略的挂载点(虚拟滚动、节流、防抖)。
  • 不能做:
    • 内置具体业务请求/权限/路由跳转;
    • 强入侵的全局样式与布局;
    • 把"数据获取策略"与"视图展示"耦合在一起。

经验法则:当你想往组件里加入"接口调用/存储/路由逻辑"时,优先抽到外部,通过 props 或回调传入。

如何封装一个"完美"的组件:步骤与检查清单

步骤:

  • 需求澄清:列出"必须/可选/未来可能"能力,绘制状态机。
  • API 草案:定义 props/emits/slots/expose,按"最小API"删减一次。
  • 性能设计:确定响应式粒度、渲染分层、缓存/懒加载策略。
  • 可定制设计:样式覆写点、插槽范围、国际化/无障碍。
  • 错误与边界:空数据/异常/加载/超时/禁用态的体验。
  • 测试点:快照、交互、类型、可访问性、性能。

检查清单(节选):

  • Props 有默认值且文档化;
  • 至少一个具名插槽能改写核心内容;
  • 值受控,支持 v-modelupdate:*
  • 重要交互通过事件冒泡到父级;
  • 渲染数量与依赖计算可控;
  • 对外暴露必要方法(defineExpose);
  • TS 严格模式通过,关键类型导出;
  • 有演示用例与单测。

通用组件封装示例:BaseSelect(Vue3+TS)

目标:在 ElementPlus el-select 之上封装可搜索、远程加载、受控值、性能优化、可插槽扩展的选择器。

vue 复制代码
<!-- components/BaseSelect.vue -->
<template>
  <el-select
    :model-value="modelValue"
    :placeholder="placeholder"
    :filterable="filterable"
    :clearable="clearable"
    :disabled="disabled || loading"
    :loading="loading"
    :remote="remote"
    :remote-method="remote ? handleRemote : undefined"
    :reserve-keyword="true"
    v-bind="$attrs"
    @change="val => emit('update:modelValue', val)"
  >
    <slot name="prefix" />
    <el-option
      v-for="opt in displayOptions"
      :key="getOptionKey(opt)"
      :label="getOptionLabel(opt)"
      :value="getOptionValue(opt)"
      :disabled="opt.disabled"
    >
      <slot name="option" :option="opt">
        {{ getOptionLabel(opt) }}
      </slot>
    </el-option>
    <template #empty>
      <slot name="empty">
        <div class="empty">{{ emptyText }}</div>
      </slot>
    </template>
  </el-select>
  </template>

<script setup lang="ts">
import { computed, shallowRef } from 'vue'

interface BaseSelectProps<T = any> {
  modelValue?: any
  options?: T[]
  placeholder?: string
  disabled?: boolean
  clearable?: boolean
  filterable?: boolean
  loading?: boolean
  remote?: boolean
  emptyText?: string
  labelKey?: string
  valueKey?: string
  optionKey?: string
  remoteMethod?: (query: string) => Promise<T[]>
}

const props = withDefaults(defineProps<BaseSelectProps>(), {
  options: () => [],
  placeholder: '请选择',
  disabled: false,
  clearable: true,
  filterable: true,
  loading: false,
  remote: false,
  emptyText: '暂无数据',
  labelKey: 'label',
  valueKey: 'value',
  optionKey: 'value'
})

const emit = defineEmits<{ (e: 'update:modelValue', val: any): void }>()

const remoteCache = shallowRef<any[]>([])
const displayOptions = computed(() => props.remote ? remoteCache.value : props.options)

const getOptionLabel = (opt: any) => (typeof opt === 'object' ? opt[props.labelKey] : String(opt))
const getOptionValue = (opt: any) => (typeof opt === 'object' ? opt[props.valueKey] : opt)
const getOptionKey = (opt: any) => (typeof opt === 'object' ? opt[props.optionKey] ?? opt[props.valueKey] : opt)

const handleRemote = async (query: string) => {
  if (!props.remoteMethod) return
  const list = await props.remoteMethod(query)
  remoteCache.value = Array.isArray(list) ? list : []
}

defineExpose({ refreshRemote: () => handleRemote('') })
</script>

<style scoped>
.empty { color: #999; padding: 8px 12px; }
</style>

说明要点:

  • 受控值:通过 modelValue + update:modelValue
  • 性能:shallowRef 缓存远程数据,避免深层响应开销;
  • 扩展:option/empty 插槽重写选项与空态;
  • 边界:远程数据获取由上层注入 remoteMethod,组件不触达业务。

进阶:虚拟滚动片段(可复用到长列表/下拉)

vue 复制代码
<template>
  <div class="virtual-list" ref="containerRef" @scroll="handleScroll">
    <div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="virtual-list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'

interface VirtualItem { id: number; content: string }

const props = defineProps<{ items: VirtualItem[]; itemHeight: number; containerHeight: number }>()
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)

const visibleCount = computed(() => Math.ceil(props.containerHeight / props.itemHeight) + 2)
const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const totalHeight = computed(() => props.items.length * props.itemHeight)
const offsetY = computed(() => startIndex.value * props.itemHeight)

const handleScroll = (e: Event) => { scrollTop.value = (e.target as HTMLElement).scrollTop }

let timer: number | null = null
const debouncedScroll = (e: Event) => { if (timer) clearTimeout(timer); timer = setTimeout(() => handleScroll(e), 16) as any }

onMounted(() => containerRef.value?.addEventListener('scroll', debouncedScroll))
onUnmounted(() => containerRef.value?.removeEventListener('scroll', debouncedScroll))
</script>

<style scoped>
.virtual-list { height: 400px; overflow-y: auto; position: relative; }
.virtual-list-phantom { position: absolute; top: 0; left: 0; right: 0; z-index: -1; }
.virtual-list-content { position: absolute; top: 0; left: 0; right: 0; }
.virtual-list-item { display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid #eee; }
</style>

ElementPlus 表格二次封装:DataTablePlus

目标:在 el-table 基础上统一"工具栏 + 表格 + 分页"的模式,抽象列定义、选择、排序、搜索、分页通信;保持业务无侵入,开放插槽与事件。

vue 复制代码
<!-- components/DataTablePlus.vue -->
<template>
  <div class="data-table">
    <!-- 工具栏 -->
    <div v-if="showToolbar" class="table-toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left">
          <el-button v-if="showAdd" type="primary" :icon="Plus" @click="handleAdd">新增</el-button>
          <el-button v-if="showBatchDelete && hasSelection" type="danger" :icon="Delete" @click="handleBatchDelete">批量删除</el-button>
        </slot>
      </div>
      <div class="toolbar-right">
        <slot name="toolbar-right">
          <el-input v-if="showSearch" v-model="searchKeyword" placeholder="请输入搜索关键词" :style="{ width: '200px' }" clearable @input="handleSearch">
            <template #prefix><el-icon><Search /></el-icon></template>
          </el-input>
          <el-button v-if="showRefresh" :icon="Refresh" circle @click="handleRefresh" />
        </slot>
      </div>
    </div>

    <!-- 表格 -->
    <el-table
      ref="tableRef"
      :data="tableData"
      :loading="loading"
      :height="height"
      :max-height="maxHeight"
      :stripe="stripe"
      :border="border"
      :row-key="rowKey"
      :default-sort="defaultSort"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
      v-bind="$attrs"
    >
      <el-table-column v-if="showSelection" type="selection" width="55" :selectable="selectable" />
      <el-table-column v-if="showIndex" type="index" label="序号" width="80" :index="getIndex" />
      <el-table-column
        v-for="column in columns"
        :key="column.prop"
        :prop="column.prop"
        :label="column.label"
        :width="column.width"
        :min-width="column.minWidth"
        :fixed="column.fixed"
        :sortable="column.sortable"
        :show-overflow-tooltip="column.showOverflowTooltip !== false"
        :align="column.align || 'left'"
        :header-align="column.headerAlign || column.align || 'left'"
      >
        <template #default="{ row, column: col, $index }">
          <slot :name="column.prop" :row="row" :column="col" :index="$index" :value="row[column.prop]">
            <span v-if="column.formatter">{{ column.formatter(row, col, row[column.prop], $index) }}</span>
            <span v-else>{{ row[column.prop] }}</span>
          </slot>
        </template>
      </el-table-column>

      <el-table-column v-if="showActions" label="操作" :width="actionWidth" :fixed="actionFixed" align="center">
        <template #default="{ row, $index }">
          <slot name="actions" :row="row" :index="$index">
            <el-button v-if="showEdit" type="primary" link size="small" @click="handleEdit(row, $index)">编辑</el-button>
            <el-button v-if="showDelete" type="danger" link size="small" @click="handleDelete(row, $index)">删除</el-button>
          </slot>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页 -->
    <div v-if="showPagination" class="table-pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :total="total"
        :page-sizes="pageSizes"
        :layout="paginationLayout"
        :background="paginationBackground"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElTable } from 'element-plus'
import { Plus, Delete, Search, Refresh } from '@element-plus/icons-vue'

interface TableColumn {
  prop: string
  label: string
  width?: number | string
  minWidth?: number | string
  fixed?: boolean | 'left' | 'right'
  sortable?: boolean | 'custom'
  align?: 'left' | 'center' | 'right'
  headerAlign?: 'left' | 'center' | 'right'
  showOverflowTooltip?: boolean
  formatter?: (row: any, column: any, cellValue: any, index: number) => string
}

interface DataTableProps {
  data: any[]
  columns: TableColumn[]
  loading?: boolean
  height?: number | string
  maxHeight?: number | string
  stripe?: boolean
  border?: boolean
  rowKey?: string | ((row: any) => string | number)
  defaultSort?: { prop: string; order: 'ascending' | 'descending' }
  showToolbar?: boolean
  showAdd?: boolean
  showBatchDelete?: boolean
  showSearch?: boolean
  showRefresh?: boolean
  showSelection?: boolean
  showIndex?: boolean
  showActions?: boolean
  showEdit?: boolean
  showDelete?: boolean
  actionWidth?: number | string
  actionFixed?: boolean | 'left' | 'right'
  selectable?: (row: any, index: number) => boolean
  showPagination?: boolean
  total?: number
  currentPage?: number
  pageSize?: number
  pageSizes?: number[]
  paginationLayout?: string
  paginationBackground?: boolean
}

const props = withDefaults(defineProps<DataTableProps>(), {
  loading: false,
  stripe: false,
  border: true,
  rowKey: 'id',
  showToolbar: true,
  showAdd: true,
  showBatchDelete: true,
  showSearch: true,
  showRefresh: true,
  showSelection: false,
  showIndex: false,
  showActions: true,
  showEdit: true,
  showDelete: true,
  actionWidth: 150,
  actionFixed: 'right',
  showPagination: true,
  total: 0,
  currentPage: 1,
  pageSize: 10,
  pageSizes: () => [10, 20, 50, 100],
  paginationLayout: 'total, sizes, prev, pager, next, jumper',
  paginationBackground: true
})

const emit = defineEmits<{
  (e: 'update:currentPage', page: number): void
  (e: 'update:pageSize', size: number): void
  (e: 'add'): void
  (e: 'edit', row: any, index: number): void
  (e: 'delete', row: any, index: number): void
  (e: 'batch-delete', selection: any[]): void
  (e: 'search', keyword: string): void
  (e: 'refresh'): void
  (e: 'selection-change', selection: any[]): void
  (e: 'sort-change', sort: { prop: string; order: string }): void
  (e: 'row-click', row: any, column: any, event: Event): void
  (e: 'size-change', size: number): void
  (e: 'current-change', page: number): void
}>()

const tableRef = ref<InstanceType<typeof ElTable>>()
const searchKeyword = ref('')
const selection = ref<any[]>([])

const tableData = computed(() => props.data)
const hasSelection = computed(() => selection.value.length > 0)

const getIndex = (index: number) => (props.currentPage - 1) * props.pageSize + index + 1
const handleAdd = () => emit('add')
const handleEdit = (row: any, index: number) => emit('edit', row, index)
const handleDelete = (row: any, index: number) => emit('delete', row, index)
const handleBatchDelete = () => emit('batch-delete', selection.value)
const handleSearch = (keyword: string) => emit('search', keyword)
const handleRefresh = () => emit('refresh')
const handleSelectionChange = (val: any[]) => { selection.value = val; emit('selection-change', val) }
const handleSortChange = (sort: { prop: string; order: string }) => emit('sort-change', sort)
const handleRowClick = (row: any, column: any, event: Event) => emit('row-click', row, column, event)
const handleSizeChange = (size: number) => { emit('update:pageSize', size); emit('size-change', size) }
const handleCurrentChange = (page: number) => { emit('update:currentPage', page); emit('current-change', page) }

defineExpose({
  clearSelection: () => tableRef.value?.clearSelection(),
  toggleRowSelection: (row: any, selected?: boolean) => tableRef.value?.toggleRowSelection(row, selected),
  toggleAllSelection: () => tableRef.value?.toggleAllSelection(),
  sort: (prop: string, order: 'ascending' | 'descending') => tableRef.value?.sort(prop, order),
  clearSort: () => tableRef.value?.clearSort()
})
</script>

<style scoped>
.data-table { background: #fff; border-radius: 4px; }
.table-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid #ebeef5; }
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; }
.table-pagination { display: flex; justify-content: flex-end; padding: 16px; border-top: 1px solid #ebeef5; }
</style>

使用建议:

  • 将搜索/筛选参数、数据请求逻辑放在父组件;通过 data/total 与分页双向绑定驱动渲染;
  • 列渲染优先使用具名插槽覆盖;操作列复用 actions 插槽;
  • 长列表结合虚拟滚动或服务端分页。

使用示例

vue 复制代码
<!-- pages/UserManagement.vue -->
<template>
  <div class="user-management">
    <DataTablePlus
      :data="userList"
      :columns="columns"
      :loading="loading"
      :total="total"
      v-model:current-page="currentPage"
      v-model:page-size="pageSize"
      @add="handleAdd"
      @edit="handleEdit"
      @delete="handleDelete"
      @search="handleSearch"
      @refresh="handleRefresh"
    >
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'danger'">{{ row.status === 1 ? '启用' : '禁用' }}</el-tag>
      </template>
      <template #actions="{ row, index }">
        <el-button type="primary" link size="small" @click="handleEdit(row, index)">编辑</el-button>
        <el-button type="success" link size="small" @click="handleToggleStatus(row)">{{ row.status === 1 ? '禁用' : '启用' }}</el-button>
        <el-button type="danger" link size="small" @click="handleDelete(row, index)">删除</el-button>
      </template>
    </DataTablePlus>

    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" @close="handleDialogClose">
      <!-- 可配合 BaseSelect/FormBuilder 等通用组件 -->
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import DataTablePlus from '@/components/DataTablePlus.vue'

interface User { id: number; name: string; email: string; phone: string; status: number; createTime: string }

const columns = [
  { prop: 'name', label: '姓名', width: 120 },
  { prop: 'email', label: '邮箱', minWidth: 200 },
  { prop: 'phone', label: '手机号', width: 130 },
  { prop: 'status', label: '状态', width: 100 },
  { prop: 'createTime', label: '创建时间', width: 180 }
]

const userList = ref<User[]>([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)

const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const formData = reactive<Partial<User>>({})

const loadUserList = async () => {
  loading.value = true
  try {
    const response = await fetchUsers({ page: currentPage.value, size: pageSize.value })
    userList.value = response.data
    total.value = response.total
  } finally {
    loading.value = false
  }
}

const handleAdd = () => { dialogTitle.value = '新增用户'; isEdit.value = false; dialogVisible.value = true }
const handleEdit = (row: User) => { dialogTitle.value = '编辑用户'; isEdit.value = true; Object.assign(formData, row); dialogVisible.value = true }
const handleDelete = async (row: User) => { await deleteUser(row.id); loadUserList() }
const handleToggleStatus = async (row: User) => { await updateUserStatus(row.id, row.status === 1 ? 0 : 1); loadUserList() }
const handleSearch = () => loadUserList()
const handleRefresh = () => loadUserList()
const handleDialogClose = () => Object.assign(formData, {})

const fetchUsers = async (params: any) => new Promise<any>(r => setTimeout(() => r({
  data: Array.from({ length: params.size }, (_, i) => ({
    id: i + 1,
    name: `用户${i + 1}`,
    email: `user${i + 1}@example.com`,
    phone: `138${String(i + 1).padStart(8, '0')}`,
    status: i % 2,
    createTime: new Date().toISOString()
  })),
  total: 100
}), 400))
const updateUserStatus = async () => new Promise(r => setTimeout(r, 300))
const deleteUser = async () => new Promise(r => setTimeout(r, 300))

onMounted(loadUserList)
</script>

<style scoped>
.user-management { padding: 20px; }
</style>

设计取舍:

  • 不做数据请求,保持无业务侵入;
  • 通过事件把交互上抛;
  • 通过列插槽和操作插槽满足差异化渲染;
  • 通过 defineExpose 暴露选择/排序方法,支持父组件控制。

总结要点与落地建议

  • 先规则再实现:单一职责、受控优先、最小API、边界清晰。
  • 可扩展但不耦合:插槽/事件/样式变量优先于新增 props。
  • 性能内建:computed 缓存、浅响应、懒加载、虚拟滚动、分页。
  • 类型即文档:TS 明确 props/emits/slots;暴露方法可测可管。
  • 二次封装库时:不改语义、补齐缺口(工具栏/分页/空态/国际化),保持与原库 API 心智对齐。
  • 建议沉淀:在 components/ 下建立 base-biz- 两层,基础可复用,业务按域聚合;配套 Story/Docs 与单测。

高性能组件封装

1. 响应式数据优化

使用 shallowRef 和 shallowReactive
vue 复制代码
<template>
  <div class="data-table">
    <div v-for="item in displayData" :key="item.id" class="table-row">
      {{ item.name }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, shallowRef, watchEffect } from 'vue'

interface TableItem {
  id: number
  name: string
  status: string
  // ... 其他字段
}

// ❌ 错误做法:使用 ref 包装大量数据
// const tableData = ref<TableItem[]>([])

// ✅ 正确做法:使用 shallowRef 避免深度响应式
const tableData = shallowRef<TableItem[]>([])

// 使用计算属性进行数据转换
const displayData = computed(() => {
  return tableData.value.map(item => ({
    id: item.id,
    name: item.name,
    // 只保留需要显示的数据
  }))
})

// 使用 watchEffect 进行副作用处理
watchEffect(() => {
  // 只在必要时更新DOM
  if (tableData.value.length > 0) {
    // 处理数据更新逻辑
  }
})
</script>
合理使用 computed 和 watch
vue 复制代码
<script setup lang="ts">
import { ref, computed, watch, watchEffect } from 'vue'

const props = defineProps<{
  data: any[]
  filter: string
  sortBy: string
}>()

// ✅ 使用 computed 进行数据转换
const filteredData = computed(() => {
  return props.data.filter(item => 
    item.name.toLowerCase().includes(props.filter.toLowerCase())
  )
})

const sortedData = computed(() => {
  return [...filteredData.value].sort((a, b) => {
    return a[props.sortBy] > b[props.sortBy] ? 1 : -1
  })
})

// ✅ 使用 watch 监听特定变化
watch(
  () => props.filter,
  (newFilter, oldFilter) => {
    // 只在过滤条件真正改变时执行
    if (newFilter !== oldFilter) {
      console.log('Filter changed:', newFilter)
    }
  }
)

// ❌ 避免在模板中直接使用复杂计算
// <div v-for="item in data.filter(...).sort(...)" :key="item.id">
</script>

2. 组件懒加载和代码分割

typescript 复制代码
// components/LazyTable.vue
<template>
  <div class="lazy-table">
    <Suspense>
      <template #default>
        <AsyncTableContent :data="data" />
      </template>
      <template #fallback>
        <div class="loading">加载中...</div>
      </template>
    </Suspense>
  </div>
</template>

<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// 异步组件,实现代码分割
const AsyncTableContent = defineAsyncComponent({
  loader: () => import('./TableContent.vue'),
  loadingComponent: () => import('./TableLoading.vue'),
  errorComponent: () => import('./TableError.vue'),
  delay: 200,
  timeout: 3000
})

defineProps<{
  data: any[]
}>()
</script>

3. 虚拟滚动优化

vue 复制代码
<template>
  <div class="virtual-list" ref="containerRef" @scroll="handleScroll">
    <div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="virtual-list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'

interface VirtualItem {
  id: number
  content: string
}

const props = defineProps<{
  items: VirtualItem[]
  itemHeight: number
  containerHeight: number
}>()

const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)

// 计算可见区域
const visibleCount = computed(() => 
  Math.ceil(props.containerHeight / props.itemHeight) + 2
)

const startIndex = computed(() => 
  Math.floor(scrollTop.value / props.itemHeight)
)

const endIndex = computed(() => 
  Math.min(startIndex.value + visibleCount.value, props.items.length)
)

const visibleItems = computed(() => 
  props.items.slice(startIndex.value, endIndex.value)
)

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

const offsetY = computed(() => 
  startIndex.value * props.itemHeight
)

const handleScroll = (e: Event) => {
  const target = e.target as HTMLElement
  scrollTop.value = target.scrollTop
}

// 防抖处理
let scrollTimer: number | null = null
const debouncedScroll = (e: Event) => {
  if (scrollTimer) clearTimeout(scrollTimer)
  scrollTimer = setTimeout(() => handleScroll(e), 16) // 60fps
}

onMounted(() => {
  containerRef.value?.addEventListener('scroll', debouncedScroll)
})

onUnmounted(() => {
  containerRef.value?.removeEventListener('scroll', debouncedScroll)
})
</script>

<style scoped>
.virtual-list {
  height: 400px;
  overflow-y: auto;
  position: relative;
}

.virtual-list-phantom {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}

.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.virtual-list-item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
}
</style>

高维护性组件封装

1. TypeScript 类型定义

typescript 复制代码
// types/component.ts
export interface BaseComponentProps {
  id?: string
  class?: string | string[] | Record<string, boolean>
  style?: string | Record<string, any>
  disabled?: boolean
  loading?: boolean
}

export interface TableColumn {
  prop: string
  label: string
  width?: number | string
  minWidth?: number | string
  fixed?: boolean | 'left' | 'right'
  sortable?: boolean
  formatter?: (row: any, column: TableColumn, cellValue: any) => string
  render?: (row: any, column: TableColumn, cellValue: any) => VNode
}

export interface TableProps extends BaseComponentProps {
  data: any[]
  columns: TableColumn[]
  height?: number | string
  maxHeight?: number | string
  stripe?: boolean
  border?: boolean
  showHeader?: boolean
  highlightCurrentRow?: boolean
  emptyText?: string
  loading?: boolean
  loadingText?: string
}

export interface TableEmits {
  (e: 'selection-change', selection: any[]): void
  (e: 'row-click', row: any, column: TableColumn, event: Event): void
  (e: 'sort-change', { column, prop, order }: { column: TableColumn, prop: string, order: string }): void
  (e: 'update:loading', loading: boolean): void
}

2. 组件 API 设计

vue 复制代码
<!-- components/BaseTable.vue -->
<template>
  <div 
    :id="props.id"
    :class="tableClasses"
    :style="props.style"
  >
    <div v-if="props.loading" class="table-loading">
      <el-icon class="is-loading">
        <Loading />
      </el-icon>
      <span>{{ props.loadingText }}</span>
    </div>
    
    <div v-else-if="isEmpty" class="table-empty">
      {{ props.emptyText }}
    </div>
    
    <div v-else class="table-content">
      <table class="base-table">
        <thead v-if="props.showHeader">
          <tr>
            <th
              v-for="column in props.columns"
              :key="column.prop"
              :style="getColumnStyle(column)"
              :class="getColumnClass(column)"
            >
              <div class="cell" @click="handleSort(column)">
                {{ column.label }}
                <el-icon v-if="column.sortable" class="sort-icon">
                  <Sort />
                </el-icon>
              </div>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr
            v-for="(row, rowIndex) in props.data"
            :key="getRowKey(row, rowIndex)"
            :class="getRowClass(row, rowIndex)"
            @click="handleRowClick(row, $event)"
          >
            <td
              v-for="column in props.columns"
              :key="column.prop"
              :style="getColumnStyle(column)"
              :class="getCellClass(row, column)"
            >
              <div class="cell">
                <component
                  :is="column.render"
                  v-if="column.render"
                  :row="row"
                  :column="column"
                  :value="getCellValue(row, column)"
                />
                <span v-else>{{ formatCellValue(row, column) }}</span>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, toRefs } from 'vue'
import { Loading, Sort } from '@element-plus/icons-vue'
import type { TableProps, TableEmits, TableColumn } from '../types/component'

const props = withDefaults(defineProps<TableProps>(), {
  showHeader: true,
  stripe: false,
  border: false,
  highlightCurrentRow: false,
  emptyText: '暂无数据',
  loadingText: '加载中...',
  loading: false
})

const emit = defineEmits<TableEmits>()

// 计算属性
const tableClasses = computed(() => [
  'base-table-wrapper',
  {
    'is-striped': props.stripe,
    'is-bordered': props.border,
    'is-loading': props.loading,
    'is-disabled': props.disabled
  },
  props.class
])

const isEmpty = computed(() => !props.data || props.data.length === 0)

// 方法
const getRowKey = (row: any, index: number) => {
  return row.id || row.key || index
}

const getRowClass = (row: any, index: number) => {
  return {
    'table-row': true,
    'is-striped': props.stripe && index % 2 === 1,
    'is-current': props.highlightCurrentRow && currentRow.value === row
  }
}

const getColumnStyle = (column: TableColumn) => {
  const style: Record<string, any> = {}
  if (column.width) style.width = typeof column.width === 'number' ? `${column.width}px` : column.width
  if (column.minWidth) style.minWidth = typeof column.minWidth === 'number' ? `${column.minWidth}px` : column.minWidth
  return style
}

const getColumnClass = (column: TableColumn) => {
  return {
    'table-column': true,
    'is-sortable': column.sortable,
    'is-fixed': !!column.fixed,
    'is-fixed-left': column.fixed === 'left',
    'is-fixed-right': column.fixed === 'right'
  }
}

const getCellClass = (row: any, column: TableColumn) => {
  return {
    'table-cell': true,
    'is-fixed': !!column.fixed,
    'is-fixed-left': column.fixed === 'left',
    'is-fixed-right': column.fixed === 'right'
  }
}

const getCellValue = (row: any, column: TableColumn) => {
  return row[column.prop]
}

const formatCellValue = (row: any, column: TableColumn) => {
  const value = getCellValue(row, column)
  return column.formatter ? column.formatter(row, column, value) : value
}

const handleSort = (column: TableColumn) => {
  if (!column.sortable) return
  // 排序逻辑
  emit('sort-change', { column, prop: column.prop, order: 'ascending' })
}

const handleRowClick = (row: any, event: Event) => {
  if (props.disabled) return
  emit('row-click', row, {} as TableColumn, event)
}

// 当前行状态
const currentRow = ref<any>(null)
</script>

<style scoped>
.base-table-wrapper {
  position: relative;
  overflow: hidden;
}

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

.table-loading,
.table-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 200px;
  color: #999;
}

.table-row {
  transition: background-color 0.25s;
}

.table-row:hover {
  background-color: #f5f7fa;
}

.table-row.is-current {
  background-color: #ecf5ff;
}

.table-row.is-striped {
  background-color: #fafafa;
}

.table-column {
  background-color: #fafafa;
  color: #909399;
  font-weight: 500;
  text-align: left;
  padding: 12px 0;
  border-bottom: 1px solid #ebeef5;
}

.cell {
  padding: 0 10px;
  display: flex;
  align-items: center;
  min-height: 32px;
}

.sort-icon {
  margin-left: 4px;
  cursor: pointer;
}

.table-cell {
  padding: 12px 0;
  border-bottom: 1px solid #ebeef5;
}

.is-bordered .table-cell {
  border-right: 1px solid #ebeef5;
}

.is-bordered .table-column {
  border-right: 1px solid #ebeef5;
}
</style>

3. 组件状态管理

typescript 复制代码
// composables/useTableState.ts
import { ref, computed, watch } from 'vue'

export interface TableState {
  currentPage: number
  pageSize: number
  total: number
  loading: boolean
  selection: any[]
  sort: {
    prop: string
    order: 'ascending' | 'descending' | null
  }
  filter: Record<string, any>
}

export function useTableState(initialState?: Partial<TableState>) {
  const state = ref<TableState>({
    currentPage: 1,
    pageSize: 10,
    total: 0,
    loading: false,
    selection: [],
    sort: {
      prop: '',
      order: null
    },
    filter: {},
    ...initialState
  })

  // 分页相关
  const pagination = computed(() => ({
    currentPage: state.value.currentPage,
    pageSize: state.value.pageSize,
    total: state.value.total,
    pageCount: Math.ceil(state.value.total / state.value.pageSize)
  }))

  // 选择相关
  const hasSelection = computed(() => state.value.selection.length > 0)
  const selectionCount = computed(() => state.value.selection.length)

  // 方法
  const setCurrentPage = (page: number) => {
    state.value.currentPage = page
  }

  const setPageSize = (size: number) => {
    state.value.pageSize = size
    state.value.currentPage = 1 // 重置到第一页
  }

  const setTotal = (total: number) => {
    state.value.total = total
  }

  const setLoading = (loading: boolean) => {
    state.value.loading = loading
  }

  const setSelection = (selection: any[]) => {
    state.value.selection = selection
  }

  const clearSelection = () => {
    state.value.selection = []
  }

  const setSort = (prop: string, order: 'ascending' | 'descending' | null) => {
    state.value.sort = { prop, order }
  }

  const setFilter = (filter: Record<string, any>) => {
    state.value.filter = { ...state.value.filter, ...filter }
  }

  const clearFilter = () => {
    state.value.filter = {}
  }

  const reset = () => {
    state.value = {
      currentPage: 1,
      pageSize: 10,
      total: 0,
      loading: false,
      selection: [],
      sort: { prop: '', order: null },
      filter: {}
    }
  }

  return {
    state: readonly(state),
    pagination,
    hasSelection,
    selectionCount,
    setCurrentPage,
    setPageSize,
    setTotal,
    setLoading,
    setSelection,
    clearSelection,
    setSort,
    setFilter,
    clearFilter,
    reset
  }
}

ElementPlus 二次封装最佳实践

1. 表单组件封装

vue 复制代码
<!-- components/FormBuilder.vue -->
<template>
  <el-form
    ref="formRef"
    :model="formData"
    :rules="rules"
    :label-width="labelWidth"
    :label-position="labelPosition"
    v-bind="$attrs"
    @submit.prevent
  >
    <el-row :gutter="gutter">
      <el-col
        v-for="field in fields"
        :key="field.prop"
        :span="field.span || 24"
        :offset="field.offset || 0"
      >
        <el-form-item
          :label="field.label"
          :prop="field.prop"
          :required="field.required"
          :rules="field.rules"
        >
          <component
            :is="getFieldComponent(field)"
            v-model="formData[field.prop]"
            v-bind="getFieldProps(field)"
            @change="handleFieldChange(field, $event)"
          />
        </el-form-item>
      </el-col>
    </el-row>
    
    <el-form-item v-if="showActions" class="form-actions">
      <el-button
        v-if="showReset"
        @click="handleReset"
        :disabled="loading"
      >
        重置
      </el-button>
      <el-button
        type="primary"
        @click="handleSubmit"
        :loading="loading"
      >
        {{ submitText }}
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'

interface FormField {
  prop: string
  label: string
  type: 'input' | 'select' | 'date' | 'number' | 'textarea' | 'switch' | 'radio' | 'checkbox'
  component?: string
  span?: number
  offset?: number
  required?: boolean
  rules?: FormRules[string]
  options?: Array<{ label: string; value: any; disabled?: boolean }>
  placeholder?: string
  disabled?: boolean
  readonly?: boolean
  [key: string]: any
}

interface FormBuilderProps {
  fields: FormField[]
  modelValue: Record<string, any>
  rules?: FormRules
  labelWidth?: string
  labelPosition?: 'left' | 'right' | 'top'
  gutter?: number
  showActions?: boolean
  showReset?: boolean
  submitText?: string
  loading?: boolean
}

const props = withDefaults(defineProps<FormBuilderProps>(), {
  labelWidth: '120px',
  labelPosition: 'right',
  gutter: 20,
  showActions: true,
  showReset: true,
  submitText: '提交',
  loading: false
})

const emit = defineEmits<{
  (e: 'update:modelValue', value: Record<string, any>): void
  (e: 'submit', data: Record<string, any>): void
  (e: 'reset'): void
  (e: 'field-change', field: FormField, value: any): void
}>()

const formRef = ref<FormInstance>()

// 表单数据
const formData = reactive({ ...props.modelValue })

// 监听外部数据变化
watch(
  () => props.modelValue,
  (newValue) => {
    Object.assign(formData, newValue)
  },
  { deep: true }
)

// 监听内部数据变化
watch(
  formData,
  (newValue) => {
    emit('update:modelValue', { ...newValue })
  },
  { deep: true }
)

// 获取字段组件
const getFieldComponent = (field: FormField) => {
  const componentMap = {
    input: 'el-input',
    select: 'el-select',
    date: 'el-date-picker',
    number: 'el-input-number',
    textarea: 'el-input',
    switch: 'el-switch',
    radio: 'el-radio-group',
    checkbox: 'el-checkbox-group'
  }
  
  return field.component || componentMap[field.type] || 'el-input'
}

// 获取字段属性
const getFieldProps = (field: FormField) => {
  const baseProps = {
    placeholder: field.placeholder,
    disabled: field.disabled,
    readonly: field.readonly,
    clearable: true
  }

  switch (field.type) {
    case 'select':
      return {
        ...baseProps,
        options: field.options,
        filterable: true
      }
    case 'date':
      return {
        ...baseProps,
        type: 'date',
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD'
      }
    case 'textarea':
      return {
        ...baseProps,
        type: 'textarea',
        rows: 3
      }
    case 'radio':
      return {
        ...baseProps,
        options: field.options
      }
    case 'checkbox':
      return {
        ...baseProps,
        options: field.options
      }
    default:
      return baseProps
  }
}

// 字段变化处理
const handleFieldChange = (field: FormField, value: any) => {
  emit('field-change', field, value)
}

// 提交处理
const handleSubmit = async () => {
  if (!formRef.value) return
  
  try {
    await formRef.value.validate()
    emit('submit', { ...formData })
  } catch (error) {
    console.error('Form validation failed:', error)
  }
}

// 重置处理
const handleReset = () => {
  formRef.value?.resetFields()
  emit('reset')
}

// 验证表单
const validate = () => {
  return formRef.value?.validate()
}

// 清除验证
const clearValidate = () => {
  formRef.value?.clearValidate()
}

// 暴露方法
defineExpose({
  validate,
  clearValidate,
  resetFields: handleReset
})
</script>

<style scoped>
.form-actions {
  text-align: right;
  margin-top: 20px;
}

.form-actions .el-button + .el-button {
  margin-left: 10px;
}
</style>

2. 表格组件封装

vue 复制代码
<!-- components/DataTable.vue -->
<template>
  <div class="data-table">
    <!-- 工具栏 -->
    <div v-if="showToolbar" class="table-toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left">
          <el-button
            v-if="showAdd"
            type="primary"
            :icon="Plus"
            @click="handleAdd"
          >
            新增
          </el-button>
          <el-button
            v-if="showBatchDelete && hasSelection"
            type="danger"
            :icon="Delete"
            @click="handleBatchDelete"
          >
            批量删除
          </el-button>
        </slot>
      </div>
      
      <div class="toolbar-right">
        <slot name="toolbar-right">
          <el-input
            v-if="showSearch"
            v-model="searchKeyword"
            placeholder="请输入搜索关键词"
            :style="{ width: '200px' }"
            clearable
            @input="handleSearch"
          >
            <template #prefix>
              <el-icon><Search /></el-icon>
            </template>
          </el-input>
          
          <el-button
            v-if="showRefresh"
            :icon="Refresh"
            circle
            @click="handleRefresh"
          />
        </slot>
      </div>
    </div>

    <!-- 表格 -->
    <el-table
      ref="tableRef"
      :data="tableData"
      :loading="loading"
      :height="height"
      :max-height="maxHeight"
      :stripe="stripe"
      :border="border"
      :row-key="rowKey"
      :default-sort="defaultSort"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
      v-bind="$attrs"
    >
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        :selectable="selectable"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        label="序号"
        width="80"
        :index="getIndex"
      />
      
      <!-- 数据列 -->
      <el-table-column
        v-for="column in columns"
        :key="column.prop"
        :prop="column.prop"
        :label="column.label"
        :width="column.width"
        :min-width="column.minWidth"
        :fixed="column.fixed"
        :sortable="column.sortable"
        :show-overflow-tooltip="column.showOverflowTooltip !== false"
        :align="column.align || 'left'"
        :header-align="column.headerAlign || column.align || 'left'"
      >
        <template #default="{ row, column: col, $index }">
          <slot
            :name="column.prop"
            :row="row"
            :column="col"
            :index="$index"
            :value="row[column.prop]"
          >
            <span v-if="column.formatter">
              {{ column.formatter(row, col, row[column.prop], $index) }}
            </span>
            <span v-else>{{ row[column.prop] }}</span>
          </slot>
        </template>
      </el-table-column>
      
      <!-- 操作列 -->
      <el-table-column
        v-if="showActions"
        label="操作"
        :width="actionWidth"
        :fixed="actionFixed"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot name="actions" :row="row" :index="$index">
            <el-button
              v-if="showEdit"
              type="primary"
              link
              size="small"
              @click="handleEdit(row, $index)"
            >
              编辑
            </el-button>
            <el-button
              v-if="showDelete"
              type="danger"
              link
              size="small"
              @click="handleDelete(row, $index)"
            >
              删除
            </el-button>
          </slot>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页 -->
    <div v-if="showPagination" class="table-pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :total="total"
        :page-sizes="pageSizes"
        :layout="paginationLayout"
        :background="paginationBackground"
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ElTable, ElTableColumn, ElPagination, ElButton, ElInput } from 'element-plus'
import { Plus, Delete, Search, Refresh } from '@element-plus/icons-vue'

interface TableColumn {
  prop: string
  label: string
  width?: number | string
  minWidth?: number | string
  fixed?: boolean | 'left' | 'right'
  sortable?: boolean | 'custom'
  align?: 'left' | 'center' | 'right'
  headerAlign?: 'left' | 'center' | 'right'
  showOverflowTooltip?: boolean
  formatter?: (row: any, column: any, cellValue: any, index: number) => string
}

interface DataTableProps {
  data: any[]
  columns: TableColumn[]
  loading?: boolean
  height?: number | string
  maxHeight?: number | string
  stripe?: boolean
  border?: boolean
  rowKey?: string | ((row: any) => string | number)
  defaultSort?: { prop: string; order: 'ascending' | 'descending' }
  
  // 工具栏
  showToolbar?: boolean
  showAdd?: boolean
  showBatchDelete?: boolean
  showSearch?: boolean
  showRefresh?: boolean
  
  // 表格
  showSelection?: boolean
  showIndex?: boolean
  showActions?: boolean
  showEdit?: boolean
  showDelete?: boolean
  actionWidth?: number | string
  actionFixed?: boolean | 'left' | 'right'
  selectable?: (row: any, index: number) => boolean
  
  // 分页
  showPagination?: boolean
  total?: number
  currentPage?: number
  pageSize?: number
  pageSizes?: number[]
  paginationLayout?: string
  paginationBackground?: boolean
}

const props = withDefaults(defineProps<DataTableProps>(), {
  loading: false,
  stripe: false,
  border: true,
  rowKey: 'id',
  
  showToolbar: true,
  showAdd: true,
  showBatchDelete: true,
  showSearch: true,
  showRefresh: true,
  
  showSelection: false,
  showIndex: false,
  showActions: true,
  showEdit: true,
  showDelete: true,
  actionWidth: 150,
  actionFixed: 'right',
  
  showPagination: true,
  total: 0,
  currentPage: 1,
  pageSize: 10,
  pageSizes: () => [10, 20, 50, 100],
  paginationLayout: 'total, sizes, prev, pager, next, jumper',
  paginationBackground: true
})

const emit = defineEmits<{
  (e: 'update:currentPage', page: number): void
  (e: 'update:pageSize', size: number): void
  (e: 'add'): void
  (e: 'edit', row: any, index: number): void
  (e: 'delete', row: any, index: number): void
  (e: 'batch-delete', selection: any[]): void
  (e: 'search', keyword: string): void
  (e: 'refresh'): void
  (e: 'selection-change', selection: any[]): void
  (e: 'sort-change', sort: { prop: string; order: string }): void
  (e: 'row-click', row: any, column: any, event: Event): void
  (e: 'size-change', size: number): void
  (e: 'current-change', page: number): void
}>()

const tableRef = ref<InstanceType<typeof ElTable>>()
const searchKeyword = ref('')

// 计算属性
const tableData = computed(() => props.data)
const hasSelection = computed(() => selection.value.length > 0)

// 状态
const selection = ref<any[]>([])

// 方法
const getIndex = (index: number) => {
  return (props.currentPage - 1) * props.pageSize + index + 1
}

const handleAdd = () => {
  emit('add')
}

const handleEdit = (row: any, index: number) => {
  emit('edit', row, index)
}

const handleDelete = (row: any, index: number) => {
  emit('delete', row, index)
}

const handleBatchDelete = () => {
  emit('batch-delete', selection.value)
}

const handleSearch = (keyword: string) => {
  emit('search', keyword)
}

const handleRefresh = () => {
  emit('refresh')
}

const handleSelectionChange = (selection: any[]) => {
  selection.value = selection
  emit('selection-change', selection)
}

const handleSortChange = (sort: { prop: string; order: string }) => {
  emit('sort-change', sort)
}

const handleRowClick = (row: any, column: any, event: Event) => {
  emit('row-click', row, column, event)
}

const handleSizeChange = (size: number) => {
  emit('update:pageSize', size)
  emit('size-change', size)
}

const handleCurrentChange = (page: number) => {
  emit('update:currentPage', page)
  emit('current-change', page)
}

// 暴露方法
const clearSelection = () => {
  tableRef.value?.clearSelection()
}

const toggleRowSelection = (row: any, selected?: boolean) => {
  tableRef.value?.toggleRowSelection(row, selected)
}

const toggleAllSelection = () => {
  tableRef.value?.toggleAllSelection()
}

const sort = (prop: string, order: 'ascending' | 'descending') => {
  tableRef.value?.sort(prop, order)
}

const clearSort = () => {
  tableRef.value?.clearSort()
}

defineExpose({
  clearSelection,
  toggleRowSelection,
  toggleAllSelection,
  sort,
  clearSort
})
</script>

<style scoped>
.data-table {
  background: #fff;
  border-radius: 4px;
}

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

.toolbar-left,
.toolbar-right {
  display: flex;
  align-items: center;
  gap: 8px;
}

.table-pagination {
  display: flex;
  justify-content: flex-end;
  padding: 16px;
  border-top: 1px solid #ebeef5;
}
</style>

3. 使用示例

vue 复制代码
<!-- pages/UserManagement.vue -->
<template>
  <div class="user-management">
    <DataTable
      :data="userList"
      :columns="columns"
      :loading="loading"
      :total="total"
      v-model:current-page="currentPage"
      v-model:page-size="pageSize"
      @add="handleAdd"
      @edit="handleEdit"
      @delete="handleDelete"
      @search="handleSearch"
      @refresh="handleRefresh"
    >
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'danger'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
      
      <template #actions="{ row, index }">
        <el-button
          type="primary"
          link
          size="small"
          @click="handleEdit(row, index)"
        >
          编辑
        </el-button>
        <el-button
          type="success"
          link
          size="small"
          @click="handleToggleStatus(row)"
        >
          {{ row.status === 1 ? '禁用' : '启用' }}
        </el-button>
        <el-button
          type="danger"
          link
          size="small"
          @click="handleDelete(row, index)"
        >
          删除
        </el-button>
      </template>
    </DataTable>

    <!-- 用户表单弹窗 -->
    <el-dialog
      v-model="dialogVisible"
      :title="dialogTitle"
      width="600px"
      @close="handleDialogClose"
    >
      <FormBuilder
        v-model="formData"
        :fields="formFields"
        :rules="formRules"
        @submit="handleSubmit"
      />
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import DataTable from '@/components/DataTable.vue'
import FormBuilder from '@/components/FormBuilder.vue'

interface User {
  id: number
  name: string
  email: string
  phone: string
  status: number
  createTime: string
}

// 表格列配置
const columns = [
  { prop: 'name', label: '姓名', width: 120 },
  { prop: 'email', label: '邮箱', minWidth: 200 },
  { prop: 'phone', label: '手机号', width: 130 },
  { prop: 'status', label: '状态', width: 100 },
  { prop: 'createTime', label: '创建时间', width: 180 }
]

// 表单字段配置
const formFields = [
  {
    prop: 'name',
    label: '姓名',
    type: 'input',
    required: true,
    rules: [
      { required: true, message: '请输入姓名', trigger: 'blur' },
      { min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
    ]
  },
  {
    prop: 'email',
    label: '邮箱',
    type: 'input',
    required: true,
    rules: [
      { required: true, message: '请输入邮箱', trigger: 'blur' },
      { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
    ]
  },
  {
    prop: 'phone',
    label: '手机号',
    type: 'input',
    required: true,
    rules: [
      { required: true, message: '请输入手机号', trigger: 'blur' },
      { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
    ]
  },
  {
    prop: 'status',
    label: '状态',
    type: 'radio',
    required: true,
    options: [
      { label: '启用', value: 1 },
      { label: '禁用', value: 0 }
    ]
  }
]

// 数据状态
const userList = ref<User[]>([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)

// 弹窗状态
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formData = reactive<Partial<User>>({})
const isEdit = ref(false)

// 计算属性
const formRules = computed(() => {
  const rules: Record<string, any> = {}
  formFields.forEach(field => {
    if (field.rules) {
      rules[field.prop] = field.rules
    }
  })
  return rules
})

// 方法
const loadUserList = async () => {
  loading.value = true
  try {
    // 模拟API调用
    const response = await fetchUsers({
      page: currentPage.value,
      size: pageSize.value
    })
    userList.value = response.data
    total.value = response.total
  } catch (error) {
    console.error('加载用户列表失败:', error)
  } finally {
    loading.value = false
  }
}

const handleAdd = () => {
  dialogTitle.value = '新增用户'
  isEdit.value = false
  Object.assign(formData, {
    name: '',
    email: '',
    phone: '',
    status: 1
  })
  dialogVisible.value = true
}

const handleEdit = (row: User) => {
  dialogTitle.value = '编辑用户'
  isEdit.value = true
  Object.assign(formData, { ...row })
  dialogVisible.value = true
}

const handleDelete = async (row: User) => {
  try {
    await ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    
    // 模拟API调用
    await deleteUser(row.id)
    ElMessage.success('删除成功')
    loadUserList()
  } catch (error) {
    if (error !== 'cancel') {
      ElMessage.error('删除失败')
    }
  }
}

const handleToggleStatus = async (row: User) => {
  try {
    const newStatus = row.status === 1 ? 0 : 1
    await updateUserStatus(row.id, newStatus)
    ElMessage.success('状态更新成功')
    loadUserList()
  } catch (error) {
    ElMessage.error('状态更新失败')
  }
}

const handleSearch = (keyword: string) => {
  // 实现搜索逻辑
  console.log('搜索关键词:', keyword)
  loadUserList()
}

const handleRefresh = () => {
  loadUserList()
}

const handleSubmit = async (data: Partial<User>) => {
  try {
    if (isEdit.value) {
      await updateUser(data.id!, data)
      ElMessage.success('更新成功')
    } else {
      await createUser(data)
      ElMessage.success('创建成功')
    }
    dialogVisible.value = false
    loadUserList()
  } catch (error) {
    ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
  }
}

const handleDialogClose = () => {
  Object.assign(formData, {})
}

// 模拟API函数
const fetchUsers = async (params: any) => {
  // 模拟API调用
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        data: Array.from({ length: params.size }, (_, i) => ({
          id: i + 1,
          name: `用户${i + 1}`,
          email: `user${i + 1}@example.com`,
          phone: `138${String(i + 1).padStart(8, '0')}`,
          status: i % 2,
          createTime: new Date().toISOString()
        })),
        total: 100
      })
    }, 1000)
  })
}

const createUser = async (data: Partial<User>) => {
  // 模拟API调用
  return new Promise(resolve => setTimeout(resolve, 1000))
}

const updateUser = async (id: number, data: Partial<User>) => {
  // 模拟API调用
  return new Promise(resolve => setTimeout(resolve, 1000))
}

const updateUserStatus = async (id: number, status: number) => {
  // 模拟API调用
  return new Promise(resolve => setTimeout(resolve, 1000))
}

const deleteUser = async (id: number) => {
  // 模拟API调用
  return new Promise(resolve => setTimeout(resolve, 1000))
}

onMounted(() => {
  loadUserList()
})
</script>

<style scoped>
.user-management {
  padding: 20px;
}
</style>

组件设计原则

1. 单一职责原则

每个组件应该只负责一个功能或一个UI区域:

vue 复制代码
<!-- ❌ 错误:组件职责过多 -->
<template>
  <div class="user-management">
    <!-- 用户列表 -->
    <el-table>...</el-table>
    <!-- 用户表单 -->
    <el-form>...</el-form>
    <!-- 权限管理 -->
    <el-tree>...</el-tree>
  </div>
</template>

<!-- ✅ 正确:职责分离 -->
<template>
  <div class="user-management">
    <UserList @edit="handleEdit" />
    <UserForm v-model:visible="formVisible" :user="currentUser" />
    <UserPermission v-model:visible="permissionVisible" :user="currentUser" />
  </div>
</template>

2. 开放封闭原则

组件应该对扩展开放,对修改封闭:

vue 复制代码
<template>
  <div class="base-button" :class="buttonClasses">
    <el-button
      v-bind="$attrs"
      :type="computedType"
      :size="computedSize"
      :disabled="disabled || loading"
      @click="handleClick"
    >
      <el-icon v-if="loading" class="is-loading">
        <Loading />
      </el-icon>
      <el-icon v-else-if="icon" :class="iconClass">
        <component :is="icon" />
      </el-icon>
      <span v-if="$slots.default">
        <slot />
      </span>
    </el-button>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

interface ButtonProps {
  type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'text'
  size?: 'large' | 'default' | 'small'
  icon?: string
  loading?: boolean
  disabled?: boolean
}

const props = withDefaults(defineProps<ButtonProps>(), {
  type: 'default',
  size: 'default',
  loading: false,
  disabled: false
})

const emit = defineEmits<{
  (e: 'click', event: MouseEvent): void
}>()

// 计算属性
const buttonClasses = computed(() => [
  'base-button',
  `base-button--${props.type}`,
  `base-button--${props.size}`
])

const computedType = computed(() => props.type)
const computedSize = computed(() => props.size)
const iconClass = computed(() => ({
  'button-icon': true,
  'button-icon--left': true
}))

// 方法
const handleClick = (event: MouseEvent) => {
  if (props.disabled || props.loading) return
  emit('click', event)
}
</script>

3. 依赖倒置原则

组件应该依赖抽象而不是具体实现:

typescript 复制代码
// 抽象接口
interface DataProvider {
  fetchData(params: any): Promise<any>
  createData(data: any): Promise<any>
  updateData(id: string, data: any): Promise<any>
  deleteData(id: string): Promise<any>
}

// 具体实现
class ApiDataProvider implements DataProvider {
  async fetchData(params: any) {
    const response = await fetch('/api/data', {
      method: 'GET',
      body: JSON.stringify(params)
    })
    return response.json()
  }
  
  async createData(data: any) {
    const response = await fetch('/api/data', {
      method: 'POST',
      body: JSON.stringify(data)
    })
    return response.json()
  }
  
  // ... 其他方法
}

// 组件使用
const useDataTable = (provider: DataProvider) => {
  const data = ref([])
  const loading = ref(false)
  
  const loadData = async (params: any) => {
    loading.value = true
    try {
      data.value = await provider.fetchData(params)
    } finally {
      loading.value = false
    }
  }
  
  return {
    data: readonly(data),
    loading: readonly(loading),
    loadData
  }
}

性能优化策略

1. 组件懒加载

typescript 复制代码
// 路由懒加载
const routes = [
  {
    path: '/users',
    component: () => import('@/views/UserManagement.vue')
  }
]

// 组件懒加载
const LazyComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingComponent,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000
})

2. 虚拟滚动

vue 复制代码
<template>
  <div class="virtual-scroll" ref="containerRef">
    <div class="virtual-scroll-phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="virtual-scroll-item"
        :style="{ height: itemHeight + 'px' }"
      >
        <slot :item="item" :index="item.index" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'

interface VirtualScrollProps {
  items: any[]
  itemHeight: number
  containerHeight: number
  overscan?: number
}

const props = withDefaults(defineProps<VirtualScrollProps>(), {
  overscan: 5
})

const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)

const visibleCount = computed(() => 
  Math.ceil(props.containerHeight / props.itemHeight) + props.overscan
)

const startIndex = computed(() => 
  Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - Math.floor(props.overscan / 2))
)

const endIndex = computed(() => 
  Math.min(props.items.length, startIndex.value + visibleCount.value)
)

const visibleItems = computed(() => 
  props.items.slice(startIndex.value, endIndex.value).map((item, index) => ({
    ...item,
    index: startIndex.value + index
  }))
)

const totalHeight = computed(() => props.items.length * props.itemHeight)
const offsetY = computed(() => startIndex.value * props.itemHeight)

const handleScroll = (e: Event) => {
  const target = e.target as HTMLElement
  scrollTop.value = target.scrollTop
}

onMounted(() => {
  containerRef.value?.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  containerRef.value?.removeEventListener('scroll', handleScroll)
})
</script>

3. 防抖和节流

typescript 复制代码
// 防抖
export function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout | null = null
  
  return (...args: Parameters<T>) => {
    if (timeout) clearTimeout(timeout)
    timeout = setTimeout(() => func(...args), wait)
  }
}

// 节流
export function throttle<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let lastTime = 0
  
  return (...args: Parameters<T>) => {
    const now = Date.now()
    if (now - lastTime >= wait) {
      lastTime = now
      func(...args)
    }
  }
}

// 在组件中使用
const debouncedSearch = debounce((keyword: string) => {
  // 搜索逻辑
}, 300)

const throttledScroll = throttle((event: Event) => {
  // 滚动处理逻辑
}, 16) // 60fps

测试与文档

1. 单元测试

typescript 复制代码
// tests/components/BaseTable.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import BaseTable from '@/components/BaseTable.vue'

describe('BaseTable', () => {
  const mockData = [
    { id: 1, name: 'John', age: 30 },
    { id: 2, name: 'Jane', age: 25 }
  ]

  const mockColumns = [
    { prop: 'name', label: '姓名' },
    { prop: 'age', label: '年龄' }
  ]

  it('renders table with data', () => {
    const wrapper = mount(BaseTable, {
      props: {
        data: mockData,
        columns: mockColumns
      }
    })

    expect(wrapper.find('.base-table').exists()).toBe(true)
    expect(wrapper.findAll('.table-row')).toHaveLength(2)
  })

  it('emits row-click event', async () => {
    const wrapper = mount(BaseTable, {
      props: {
        data: mockData,
        columns: mockColumns
      }
    })

    await wrapper.find('.table-row').trigger('click')
    expect(wrapper.emitted('row-click')).toBeTruthy()
  })

  it('shows loading state', () => {
    const wrapper = mount(BaseTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        loading: true
      }
    })

    expect(wrapper.find('.table-loading').exists()).toBe(true)
  })
})

2. 组件文档

vue 复制代码
<!-- components/BaseTable.vue -->
<template>
  <!-- 组件模板 -->
</template>

<script setup lang="ts">
/**
 * 基础表格组件
 * 
 * @description 基于ElementPlus封装的表格组件,提供数据展示、排序、筛选等功能
 * @author Your Name
 * @since 1.0.0
 */

interface TableProps {
  /** 表格数据 */
  data: any[]
  /** 表格列配置 */
  columns: TableColumn[]
  /** 表格高度 */
  height?: number | string
  /** 是否显示斑马纹 */
  stripe?: boolean
  /** 是否显示边框 */
  border?: boolean
  /** 是否显示表头 */
  showHeader?: boolean
  /** 是否高亮当前行 */
  highlightCurrentRow?: boolean
  /** 空数据时显示的文本 */
  emptyText?: string
  /** 是否显示加载状态 */
  loading?: boolean
  /** 加载状态文本 */
  loadingText?: string
}

interface TableEmits {
  /** 行点击事件 */
  (e: 'row-click', row: any, column: TableColumn, event: Event): void
  /** 排序变化事件 */
  (e: 'sort-change', { column, prop, order }: { column: TableColumn, prop: string, order: string }): void
  /** 加载状态变化事件 */
  (e: 'update:loading', loading: boolean): void
}

// 组件实现...
</script>

总结下

封装高质量Vue3组件需要考虑多个维度:

  1. 性能优化:合理使用响应式API、虚拟滚动、懒加载等技术
  2. 维护性:清晰的类型定义、良好的代码结构、完善的文档
  3. 可用性:直观的API设计、丰富的配置选项、良好的错误处理
  4. 可扩展性:遵循设计原则、提供插槽和事件、支持主题定制
  5. 测试覆盖:单元测试、集成测试、端到端测试

扩展:组件目录结构与命名规范

1) 通用 + 业务分层目录

bash 复制代码
src/
  components/
    base/                 # 通用基础组件(无业务,跨项目可复用)
      BaseButton/
        BaseButton.vue
        index.ts          # 导出与按需引入
        BaseButton.test.ts
        BaseButton.stories.vue
        README.md
      BaseSelect/
        BaseSelect.vue
        useBaseSelect.ts  # 仅服务该组件的组合式函数
        index.ts
    compose/              # 组合型组件(由多个 base 组合而成)
      DataTablePlus/
        DataTablePlus.vue
        useTableState.ts
        index.ts
    biz/                  # 业务域组件(与具体领域绑定,可被多个页面复用)
      user/
        UserForm/
          UserForm.vue
          index.ts
        UserTable/
          UserTable.vue
          index.ts
  pages/
    user/
      UserManagement.vue  # 页面(装配业务组件与数据)
  composables/            # 跨组件的通用组合式函数(与 UI 解耦)
  styles/                 # 设计令牌、主题变量、全局样式
  types/                  # 跨组件共享的类型定义

设计要点:

  • 基础组件放在 components/base,仅做通用交互与展示;不引入业务 API。
  • 组合组件放在 components/compose,对通用能力进行组合编排(如工具栏+表格+分页)。
  • 业务域组件放在 components/biz/<domain>,与领域模型、表单字段、策略等绑定。
  • 跨组件复用的逻辑放 composables/;仅服务单组件的逻辑与该组件同目录共存。

2) 单组件目录模板(推荐)

bash 复制代码
components/base/BaseXxx/
  BaseXxx.vue             # 组件主体
  useBaseXxx.ts           # 仅服务该组件的逻辑(可选)
  index.ts                # 统一导出
  BaseXxx.test.ts         # 单元测试(可选)
  BaseXxx.stories.vue     # 演示/文档(可选)
  README.md               # 使用文档(可选)

示例 index.ts

ts 复制代码
import BaseXxx from './BaseXxx.vue'
export { BaseXxx }
export default BaseXxx

3) 命名规范

  • 组件命名前缀:
    • Base* 表示通用基础组件(无业务);
    • *Plus 表示在三方组件之上做的增强组合;
    • 业务组件按域分组,文件夹使用小写短横线或驼峰,组件名使用大驼峰:UserForm, user/
  • 文件命名:
    • 组件文件与目录同名:BaseSelect/BaseSelect.vue
    • 导出文件统一 index.ts
    • 组合式函数以 useXxx.ts 命名;
    • 类型文件以 *.d.ts 或集中在 types/
  • 样式:
    • 优先局部样式 scoped/CSS Modules;
    • 全局设计令牌放 styles/tokens.scss,组件通过 CSS 变量消费。

4) 文档与示例共存

  • 每个通用/组合组件建议提供最小可运行示例:.stories.vueREADME.md 中嵌入代码片段;
  • 关键 props/slots/emits 在 README 顶部声明表格;
  • 破坏性变更在 CHANGELOG.md 标注并同步到 README。

5) 测试与可观测

  • 优先为通用/组合组件补单测:渲染、交互、事件、边界;
  • 将可观测点(关键 emit、错误分支)稳定化,便于测试与排障;
  • 大型组件拆分:将复杂状态抽到同目录下的 useXxx.ts,降低单文件复杂度。

6) 按需导出与入口聚合(可选)

ts 复制代码
// components/index.ts - 聚合所有对外导出的组件
export * from './base/BaseButton'
export * from './base/BaseSelect'
export * from './compose/DataTablePlus'
export * from './biz/user/UserForm'
export * from './biz/user/UserTable'

结合构建工具(如 unplugin-vue-components)可实现自动按需加载,但仍建议保留显式 index.ts 以增强可读性与类型提示。

相关推荐
崔庆才丨静觅11 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606111 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了12 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅12 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅12 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅12 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment12 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅13 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端
爱敲代码的小鱼13 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax