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
}
}
七、最佳实践建议
-
性能优化
- 使用虚拟滚动处理大数据
- 合理使用 computed 和 watch
- 避免不必要的渲染
-
代码组织
- 功能模块化
- 复用逻辑抽离成 hooks
- 类型定义完整
-
用户体验
- 适当的加载状态
- 友好的错误提示
- 合理的默认配置
-
可维护性
- 清晰的代码结构
- 完整的注释文档
- 统一的代码风格
总结
本指南涵盖了:
- 基础表格组件实现
- 高级功能扩展
- 性能优化策略
- 实用功能封装
关键点:
- 组件设计要合理
- 性能优化要到位
- 扩展性要良好
- 使用体验要友好