【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 以增强可读性与类型提示。

相关推荐
顾安r10 小时前
11.8 脚本网页 星际逃生
c语言·前端·javascript·flask
Hello.Reader10 小时前
Data Sink定义、参数与可落地示例
java·前端·网络
im_AMBER11 小时前
React 17
前端·javascript·笔记·学习·react.js·前端框架
一雨方知深秋11 小时前
2.fs模块对计算机硬盘进行读写操作(Promise进行封装)
javascript·node.js·promise·v8·cpython
谷歌开发者12 小时前
Web 开发指向标 | Chrome 开发者工具学习资源 (六)
前端·chrome·学习
一晌小贪欢12 小时前
【Html模板】电商运营可视化大屏模板 Excel存储 + 一键导出(已上线-可预览)
前端·数据分析·html·excel·数据看板·电商大屏·大屏看板
发现你走远了12 小时前
连接模拟器网页进行h5的调试(使用Chrome远程调试(推荐)) 保姆级图文
前端·chrome
街尾杂货店&13 小时前
css - 实现三角形 div 容器,用css画一个三角形(提供示例源码)简单粗暴几行代码搞定!
前端·css
顺凡13 小时前
删一个却少俩:Antd Tag 多节点同时消失的原因
前端·javascript·面试
小白路过13 小时前
CSS transform矩阵变换全面解析
前端·css·矩阵