Vue 3 高级 Table 组件实现指南

579 阅读5分钟

Vue 3 高级 Table 组件实现指南

一、组件架构设计

1. 类型定义

// types/table.ts
export interface TableColumn<T = any> {
  key: string
  title: string
  dataIndex: string
  width?: number | string
  fixed?: 'left' | 'right'
  sortable?: boolean
  filterable?: boolean
  align?: 'left' | 'center' | 'right'
  customRender?: (value: any, record: T, index: number) => VNode | string
}

export interface TableProps<T = any> {
  columns: TableColumn<T>[]
  dataSource: T[]
  rowKey: string | ((record: T) => string)
  loading?: boolean
  bordered?: boolean
  size?: 'small' | 'middle' | 'large'
  scroll?: {
    x?: number | string
    y?: number | string
  }
  pagination?: TablePaginationConfig | false
}

export interface SorterResult<T> {
  column: TableColumn<T>
  order: 'ascend' | 'descend' | null
  field: string
}

export interface FilterResult {
  [key: string]: any[]
}

2. 基础组件结构

<!-- components/Table/Table.vue -->
<template>
  <div 
    class="v-table-wrapper"
    :class="[
      size && `v-table-${size}`,
      { 'v-table-bordered': bordered }
    ]"
  >
    <!-- 加载状态 -->
    <div v-if="loading" class="v-table-loading">
      <div class="v-table-loading-content">
        <slot name="loading">
          <LoadingOutlined spin />
        </slot>
      </div>
    </div>
    
    <!-- 表格主体 -->
    <div 
      class="v-table"
      :style="tableStyle"
      @scroll="handleTableScroll"
    >
      <!-- 表头 -->
      <div class="v-table-header" ref="headerRef">
        <table-header
          :columns="columns"
          :sort="sorter"
          :filters="filters"
          @sort="handleSort"
          @filter="handleFilter"
        />
      </div>
      
      <!-- 表格内容 -->
      <div class="v-table-body" ref="bodyRef">
        <table-body
          :columns="columns"
          :data-source="displayData"
          :row-key="rowKey"
          @row-click="handleRowClick"
        >
          <template v-for="slot in Object.keys($slots)" #[slot]="slotProps">
            <slot :name="slot" v-bind="slotProps" />
          </template>
        </table-body>
      </div>
      
      <!-- 固定列 -->
      <template v-if="hasFixedColumn">
        <div class="v-table-fixed-left">
          <table-fixed
            position="left"
            :columns="leftFixedColumns"
            :data-source="displayData"
          />
        </div>
        <div class="v-table-fixed-right">
          <table-fixed
            position="right"
            :columns="rightFixedColumns"
            :data-source="displayData"
          />
        </div>
      </template>
    </div>
    
    <!-- 分页 -->
    <div v-if="showPagination" class="v-table-pagination">
      <table-pagination
        v-model:current="currentPage"
        v-model:pageSize="pageSize"
        :total="total"
        @change="handlePageChange"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type { TableProps, SorterResult, FilterResult } from './types'
import TableHeader from './TableHeader.vue'
import TableBody from './TableBody.vue'
import TableFixed from './TableFixed.vue'
import TablePagination from './TablePagination.vue'

// Props 定义
const props = withDefaults(defineProps<TableProps>(), {
  bordered: false,
  size: 'middle'
})

// 事件
const emit = defineEmits<{
  (e: 'change', pagination: any, filters: FilterResult, sorter: SorterResult): void
  (e: 'row-click', record: any, index: number): void
}>()

// 状态管理
const currentPage = ref(1)
const pageSize = ref(10)
const sorter = ref<SorterResult | null>(null)
const filters = ref<FilterResult>({})

// 计算属性
const displayData = computed(() => {
  let result = [...props.dataSource]
  
  // 应用筛选
  Object.entries(filters.value).forEach(([key, values]) => {
    if (values && values.length) {
      result = result.filter(item => values.includes(item[key]))
    }
  })
  
  // 应用排序
  if (sorter.value) {
    const { field, order } = sorter.value
    result.sort((a, b) => {
      const aValue = a[field]
      const bValue = b[field]
      if (order === 'ascend') {
        return aValue > bValue ? 1 : -1
      }
      return aValue < bValue ? 1 : -1
    })
  }
  
  // 应用分页
  if (props.pagination !== false) {
    const start = (currentPage.value - 1) * pageSize.value
    result = result.slice(start, start + pageSize.value)
  }
  
  return result
})

// 事件处理
const handleSort = (result: SorterResult) => {
  sorter.value = result
  emit('change', 
    { current: currentPage.value, pageSize: pageSize.value },
    filters.value,
    result
  )
}

const handleFilter = (result: FilterResult) => {
  filters.value = result
  emit('change',
    { current: currentPage.value, pageSize: pageSize.value },
    result,
    sorter.value
  )
}

const handlePageChange = (page: number, size: number) => {
  currentPage.value = page
  pageSize.value = size
  emit('change',
    { current: page, pageSize: size },
    filters.value,
    sorter.value
  )
}

const handleRowClick = (record: any, index: number) => {
  emit('row-click', record, index)
}
</script>

<style lang="scss">
.v-table-wrapper {
  position: relative;
  
  .v-table {
    position: relative;
    overflow: auto;
    
    table {
      width: 100%;
      border-collapse: separate;
      border-spacing: 0;
    }
    
    th, td {
      padding: 16px;
      border-bottom: 1px solid #f0f0f0;
    }
    
    &-fixed-left,
    &-fixed-right {
      position: absolute;
      top: 0;
      z-index: 1;
      overflow: hidden;
      pointer-events: none;
      
      table {
        background: #fff;
      }
    }
    
    &-fixed-left {
      left: 0;
      box-shadow: 6px 0 6px -4px rgba(0,0,0,.15);
    }
    
    &-fixed-right {
      right: 0;
      box-shadow: -6px 0 6px -4px rgba(0,0,0,.15);
    }
  }
}
</style>

三、高级功能实现

1. 虚拟滚动

// hooks/useVirtualScroll.ts
export function useVirtualScroll(props: {
  dataSource: any[]
  itemHeight: number
  containerHeight: number
}) {
  const startIndex = ref(0)
  const endIndex = ref(0)
  const visibleCount = computed(() => 
    Math.ceil(props.containerHeight / props.itemHeight)
  )
  
  const visibleData = computed(() => {
    const start = startIndex.value
    const end = Math.min(endIndex.value, props.dataSource.length)
    return props.dataSource.slice(start, end)
  })
  
  const handleScroll = (scrollTop: number) => {
    startIndex.value = Math.floor(scrollTop / props.itemHeight)
    endIndex.value = startIndex.value + visibleCount.value + 1
  }
  
  return {
    visibleData,
    handleScroll
  }
}

2. 列拖拽排序

// hooks/useDraggableColumns.ts
export function useDraggableColumns(columns: Ref<TableColumn[]>) {
  const draggedColumn = ref<TableColumn | null>(null)
  const targetColumn = ref<TableColumn | null>(null)
  
  const handleDragStart = (column: TableColumn) => {
    draggedColumn.value = column
  }
  
  const handleDragOver = (column: TableColumn) => {
    targetColumn.value = column
  }
  
  const handleDrop = () => {
    if (!draggedColumn.value || !targetColumn.value) return
    
    const newColumns = [...columns.value]
    const dragIndex = newColumns.indexOf(draggedColumn.value)
    const dropIndex = newColumns.indexOf(targetColumn.value)
    
    newColumns.splice(dragIndex, 1)
    newColumns.splice(dropIndex, 0, draggedColumn.value)
    
    columns.value = newColumns
  }
  
  return {
    handleDragStart,
    handleDragOver,
    handleDrop
  }
}

3. 列宽调整

// hooks/useResizableColumns.ts
export function useResizableColumns() {
  const resizing = ref(false)
  const startX = ref(0)
  const startWidth = ref(0)
  
  const handleResizeStart = (e: MouseEvent, column: TableColumn) => {
    resizing.value = true
    startX.value = e.clientX
    startWidth.value = column.width as number
    
    document.addEventListener('mousemove', handleResizing)
    document.addEventListener('mouseup', handleResizeEnd)
  }
  
  const handleResizing = (e: MouseEvent) => {
    if (!resizing.value) return
    
    const diff = e.clientX - startX.value
    const newWidth = startWidth.value + diff
    
    // 更新列宽
    column.width = Math.max(50, newWidth)
  }
  
  const handleResizeEnd = () => {
    resizing.value = false
    document.removeEventListener('mousemove', handleResizing)
    document.removeEventListener('mouseup', handleResizeEnd)
  }
  
  return {
    handleResizeStart
  }
}

4. 行选择与展开

// hooks/useRowSelection.ts
export function useRowSelection(props: {
  dataSource: any[]
  rowKey: string | ((record: any) => string)
}) {
  const selectedRowKeys = ref<string[]>([])
  
  const getRowKey = (record: any) => {
    return typeof props.rowKey === 'function'
      ? props.rowKey(record)
      : record[props.rowKey]
  }
  
  const isSelected = (record: any) => {
    const key = getRowKey(record)
    return selectedRowKeys.value.includes(key)
  }
  
  const toggleSelection = (record: any) => {
    const key = getRowKey(record)
    const index = selectedRowKeys.value.indexOf(key)
    
    if (index > -1) {
      selectedRowKeys.value.splice(index, 1)
    } else {
      selectedRowKeys.value.push(key)
    }
  }
  
  const selectAll = (checked: boolean) => {
    if (checked) {
      selectedRowKeys.value = props.dataSource.map(getRowKey)
    } else {
      selectedRowKeys.value = []
    }
  }
  
  return {
    selectedRowKeys,
    isSelected,
    toggleSelection,
    selectAll
  }
}

四、性能优化

1. 渲染优化

// 使用 v-memo 优化行渲染
<tr
  v-for="(record, index) in dataSource"
  :key="getRowKey(record)"
  v-memo="[record, selectedKeys.includes(getRowKey(record))]"
>
  <!-- 行内容 -->
</tr>

// 使用虚拟滚动优化大数据渲染
const { visibleData, handleScroll } = useVirtualScroll({
  dataSource: props.dataSource,
  itemHeight: 48,
  containerHeight: 400
})

2. 事件优化

// 使用节流优化滚动事件
import { throttle } from 'lodash-es'

const handleScroll = throttle((e: Event) => {
  const { scrollLeft, scrollTop } = e.target as HTMLElement
  updateFixedShadow(scrollLeft)
  updateScrollPosition(scrollTop)
}, 16)

// 使用防抖优化筛选事件
const handleFilter = debounce((values: any[], column: TableColumn) => {
  filters.value[column.key] = values
  emit('change', getPaginationData(), filters.value, sorter.value)
}, 300)

3. 计算优化

// 缓存计算结果
const sortedData = computed(() => {
  const cacheKey = JSON.stringify({
    data: props.dataSource,
    sorter: sorter.value,
    filters: filters.value
  })
  
  if (cache.has(cacheKey)) {
    return cache.get(cacheKey)
  }
  
  const result = calculateSortedData()
  cache.set(cacheKey, result)
  return result
})

五、高级特性

1. 自定义列渲染

<template>
  <v-table :columns="columns" :data-source="data">
    <template #action="{ record }">
      <a-space>
        <a @click="handleEdit(record)">编辑</a>
        <a @click="handleDelete(record)">删除</a>
      </a-space>
    </template>
  </v-table>
</template>

<script setup lang="ts">
const columns = [
  {
    key: 'action',
    title: '操作',
    customRender: (_, record) => ({
      name: 'action',
      record
    })
  }
]
</script>

2. 合并单元格

// 行合并 const getRowSpan = (record: any, index: number) => { if (index % 2 === 0) { return { rowSpan: 2, colSpan: 1 } } return { rowSpan: 0, colSpan: 0 } }

// 列合并 const getColSpan = (text: string, record: any, index: number) => { if (index === 0) { return { rowSpan: 1, colSpan: 2 } } return {} }

// 使用示例 const columns = [ { title: '姓名', dataIndex: 'name', onCell: (: any, index: number) => getRowSpan(, index) }, { title: '年龄', dataIndex: 'age', onCell: (record: any, index: number) => getColSpan(record.age, record, index) } ]


### 3. 树形数据展示

```typescript
// hooks/useTreeData.ts
export function useTreeData(props: {
  dataSource: any[]
  childrenKey?: string
}) {
  const { childrenKey = 'children' } = props
  const expandedKeys = ref<string[]>([])
  
  const hasChildren = (record: any) => {
    return record[childrenKey]?.length > 0
  }
  
  const toggleExpand = (key: string) => {
    const index = expandedKeys.value.indexOf(key)
    if (index > -1) {
      expandedKeys.value.splice(index, 1)
    } else {
      expandedKeys.value.push(key)
    }
  }
  
  const getIndent = (level: number) => {
    return level * 24
  }
  
  const renderTreeNode = (record: any, index: number, level = 0) => {
    const nodes = []
    const key = getRowKey(record)
    const expanded = expandedKeys.value.includes(key)
    
    nodes.push(
      <tr key={key} data-row-key={key}>
        <td style={{ paddingLeft: getIndent(level) }}>
          {hasChildren(record) && (
            <CaretRightOutlined
              rotate={expanded ? 90 : 0}
              onClick={() => toggleExpand(key)}
            />
          )}
          {record.name}
        </td>
      </tr>
    )
    
    if (expanded && hasChildren(record)) {
      record[childrenKey].forEach((child: any, childIndex: number) => {
        nodes.push(...renderTreeNode(child, childIndex, level + 1))
      })
    }
    
    return nodes
  }
  
  return {
    expandedKeys,
    renderTreeNode
  }
}

4. 自定义列设置

// components/ColumnSetting.vue
<template>
  <div class="column-setting">
    <a-dropdown>
      <SettingOutlined />
      <template #overlay>
        <a-menu>
          <!-- 列显示切换 -->
          <a-menu-item 
            v-for="col in columns"
            :key="col.key"
          >
            <a-checkbox
              v-model:checked="visibleColumns[col.key]"
              @change="handleColumnToggle(col.key)"
            >
              {{ col.title }}
            </a-checkbox>
          </a-menu-item>
          
          <!-- 尺寸设置 -->
          <a-menu-divider />
          <a-menu-item>
            <a-radio-group v-model:value="size">
              <a-radio-button value="small"></a-radio-button>
              <a-radio-button value="middle"></a-radio-button>
              <a-radio-button value="large"></a-radio-button>
            </a-radio-group>
          </a-menu-item>
        </a-menu>
      </template>
    </a-dropdown>
  </div>
</template>

<script setup lang="ts">
const visibleColumns = ref<Record<string, boolean>>({})
const size = ref<'small' | 'middle' | 'large'>('middle')

// 初始化可见列状态
onMounted(() => {
  props.columns.forEach(col => {
    visibleColumns.value[col.key] = true
  })
})

// 列显示切换处理
const handleColumnToggle = (key: string) => {
  emit('columnChange', {
    ...visibleColumns.value,
    [key]: !visibleColumns.value[key]
  })
}

// 尺寸变更处理
watch(size, (newSize) => {
  emit('sizeChange', newSize)
})
</script>

六、扩展功能

1. 导出功能

// utils/export.ts
import { utils, writeFile } from 'xlsx'

export function exportToExcel(columns: TableColumn[], data: any[], filename: string) {
  const header: string[] = []
  const headerMap: Record<string, string> = {}
  
  // 构建表头
  columns.forEach(col => {
    header.push(col.title)
    headerMap[col.dataIndex] = col.title
  })
  
  // 转换数据
  const excelData = data.map(record => {
    const row: Record<string, any> = {}
    columns.forEach(col => {
      row[headerMap[col.dataIndex]] = record[col.dataIndex]
    })
    return row
  })
  
  // 创建工作簿
  const ws = utils.json_to_sheet([header, ...excelData])
  const wb = utils.book_new()
  utils.book_append_sheet(wb, ws, 'Sheet1')
  
  // 导出文件
  writeFile(wb, `${filename}.xlsx`)
}

2. 行编辑功能

// hooks/useRowEdit.ts
export function useRowEdit() {
  const editingKey = ref<string>('')
  
  const isEditing = (record: any) => {
    return getRowKey(record) === editingKey.value
  }
  
  const edit = (key: string) => {
    editingKey.value = key
  }
  
  const save = async (key: string) => {
    try {
      const row = await form.validateFields()
      const newData = [...dataSource.value]
      const index = newData.findIndex(item => getRowKey(item) === key)
      
      if (index > -1) {
        newData[index] = { ...newData[index], ...row }
        dataSource.value = newData
        editingKey.value = ''
      }
    } catch (error) {
      console.error('Validation failed:', error)
    }
  }
  
  const cancel = () => {
    editingKey.value = ''
  }
  
  return {
    editingKey,
    isEditing,
    edit,
    save,
    cancel
  }
}

七、最佳实践建议

  1. 性能优化

    • 使用虚拟滚动处理大数据
    • 合理使用 computed 和 watch
    • 避免不必要的渲染
  2. 代码组织

    • 功能模块化
    • 复用逻辑抽离成 hooks
    • 类型定义完整
  3. 用户体验

    • 适当的加载状态
    • 友好的错误提示
    • 合理的默认配置
  4. 可维护性

    • 清晰的代码结构
    • 完整的注释文档
    • 统一的代码风格

总结

本指南涵盖了:

  1. 基础表格组件实现
  2. 高级功能扩展
  3. 性能优化策略
  4. 实用功能封装

关键点:

  • 组件设计要合理
  • 性能优化要到位
  • 扩展性要良好
  • 使用体验要友好

参考资源