Appearance
结论先说:不用另起炉灶。**在现有组件上做“模式开关”**更划算——把“虚拟滚动”做成可拔插策略,同时新增两种模式:pagination(分页)和 infinite(滚动加载更多)。这样一套列定义、排序、展开、固定列粘性、对外事件全复用,维护成本最低。
下面只给“需要改动的代码片段”,并在复杂逻辑上一行加注释(遵循你的偏好)。
1) 新增 props 与 emits
ts
// 复杂:声明三种渲染模式与滚动加载参数
props: {
// ...
mode: {
type: String as PropType<'virtual' | 'pagination' | 'infinite'>,
default: 'virtual',
},
// pagination 模式(由父组件驱动页码)
page: { type: Number, default: 1 },
pageSize: { type: Number, default: 50 },
// infinite 模式(由父组件控制是否还有更多、触发加载)
hasMore: { type: Boolean, default: true },
loadMoreThreshold: { type: Number, default: 200 }, // 距底部阈值(px)
},
emits: [
'visible-data-updated',
'row-click',
'update:expanded-keys',
'sorting-change',
// 复杂:分页与加载的对外事件
'update:page',
'load-more',
],2) 抽象“可见区行”策略
ts
setup(props, { emit }) {
// ...已有代码
// 复杂:三种模式下的“可见行范围”统一口径
const pageStart = computed(() =>
props.mode === 'pagination' ? (props.page - 1) * props.pageSize : 0,
)
const pageEnd = computed(() =>
props.mode === 'pagination' ? pageStart.value + props.pageSize : rows.value.length,
)
// 复杂:非虚拟模式直接切片,虚拟模式走 virtualizer
const nonVirtualVisibleRows = computed(() => {
if (props.mode === 'pagination') {
return rows.value.slice(pageStart.value, Math.min(pageEnd.value, rows.value.length))
}
// infinite:父组件不断 append data,这里全量渲染当前已有行
if (props.mode === 'infinite') {
return rows.value
}
return []
})
// 复杂:只有虚拟模式才创建 virtualizer,避免不必要的开销
const rowVirtualizerOptions = computed(() => ({
count: props.mode === 'virtual' ? rows.value.length : 0,
getScrollElement: () => parentRef.value,
estimateSize: () => props.rowHeight,
overscan: 5,
}))
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)
const virtualRows = computed(() =>
props.mode === 'virtual' ? rowVirtualizer.value.getVirtualItems() : [],
)
const totalSize = computed(() =>
props.mode === 'virtual' ? rowVirtualizer.value.getTotalSize() : undefined,
)
// 复杂:统一触发“可见数据”更新(虚拟用 index 计算,非虚拟直接切片)
const emitVisible = () => {
if (props.mode === 'virtual') {
if (!virtualRows.value.length) return
const start = virtualRows.value[0].index
const end = virtualRows.value[virtualRows.value.length - 1].index
const visibleRows = rows.value.slice(start, end + 1).map((r) => r.original)
emit('visible-data-updated', { rows: visibleRows, start, end })
} else {
const start = props.mode === 'pagination' ? pageStart.value : 0
const end =
props.mode === 'pagination' ? Math.min(pageEnd.value, rows.value.length) : rows.value.length
const visibleRows = nonVirtualVisibleRows.value.map((r) => r.original)
emit('visible-data-updated', { rows: visibleRows, start, end: end - 1 })
}
}
watch([virtualRows, rows, () => props.page, () => props.pageSize, () => props.mode], emitVisible, {
immediate: true,
})
// 复杂:infinite 模式滚动触底触发 load-more
const handleScroll = (e: Event) => {
if (props.mode !== 'infinite') return
const el = e.target as HTMLElement
const distanceToBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distanceToBottom <= props.loadMoreThreshold && props.hasMore && !props.loading) {
emit('load-more')
}
}
return {
// ...已有返回
virtualRows,
totalSize,
nonVirtualVisibleRows,
handleScroll,
}
}3) render 中按模式分支(容器尺寸、行渲染、transform)
tsx
render() {
// ...已有 headerStickyBase
// 复杂:根据模式决定容器高度策略
const containerStyle =
this.mode === 'virtual'
? {
height: `${this.totalSize}px`,
minHeight: '100%',
width: this.allColumnsFixed ? 'fit-content' : '100%',
}
: {
// 非虚拟:不需要占位高度,由内容自然撑开
minHeight: '100%',
width: this.allColumnsFixed ? 'fit-content' : '100%',
}
// 复杂:统一一份“行循环源”,虚拟输出 virtualRows,非虚拟输出可见行并映射出类似结构
const rowLoop =
this.mode === 'virtual'
? this.virtualRows
: this.nonVirtualVisibleRows.map((_, i) => ({
index: i,
// 非虚拟下不参与 translateY 计算,填充所需字段
start: 0,
size: this.props.rowHeight,
}))
return (
<div
ref="parentRef"
class={[
this.allColumnsFixed ? `fit-content` : 'w-full',
'h-full overflow-y-auto overflow-x-auto h-virtual-table',
]}
// 复杂:仅 infinite 模式监听滚动触底
onScroll={this.handleScroll}
>
<div class={this.allColumnsFixed ? '' : 'w-full'} style={containerStyle}>
<table
class={[this.allColumnsFixed ? 'table-fixed' : 'w-full table-fixed']}
style={{ width: this.allColumnsFixed ? `${this.totalFixedWidth}px` : '100%' }}
>
{/* ...thead 保持不变 */}
<tbody>
{rowLoop.map((virtualRow, index) => {
// 复杂:取真实行索引:虚拟为 virtualRow.index,非虚拟为分页/完整切片的 index
const rowIndex =
this.mode === 'virtual' ? virtualRow.index : (this.mode === 'pagination' ? (this.page - 1) * this.pageSize + index : index)
const bizKey = this.rows[rowIndex]?.original?.[this.props.rowKey]
return (
<tr
key={this.rows[rowIndex].id}
style={
// 复杂:仅虚拟模式使用 translateY,占位定位;非虚拟不需要
this.mode === 'virtual'
? { transform: `translateY(${virtualRow.start - index * virtualRow.size}px)` }
: undefined
}
onClick={(e) => {
e.stopPropagation()
this.handleRowClick({ row: this.rows[rowIndex], rowIndex })
}}
>
{this.rows[rowIndex].getVisibleCells().map((cell) => {
const ctx = cell.getContext()
const tdClass =
typeof this.props.cellClass === 'function'
? this.props.cellClass(ctx)
: this.props.cellClass
return (
<td
key={cell.id}
class={[
tdClass,
this.alignClasses[
((cell.column.columnDef.meta as any)?.align || 'left') as any
].th,
]}
style={{
...((cell.column.columnDef as any).size
? { width: `${(cell.column.columnDef as any).size}px` }
: (cell.column.columnDef as any).minSize
? { minWidth: `${(cell.column.columnDef as any).minSize}px` }
: {}),
...this.getStickyStyle(
cell.column.columnDef.meta as any,
cell.column.id,
false,
),
}}
>
<FlexRender render={cell.column.columnDef.cell} props={ctx} />
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}4) 使用建议(对父组件)
mode="pagination":外部维护page与pageSize,监听visible-data-updated获取当前页可见行;需要翻页时emit('update:page', newPage)(如需内置分页条可再加一个 slot/分页条封装)。mode="infinite":外部维护数据与hasMore;收到load-more后请求下一批并 append 到data;loading控制节流(已在阈值判断里避免抖动)。mode="virtual":保持现状,渲染海量数据时性能最佳。
这样改动最小、复用最大:一套表头/列定义/粘性列/排序/展开逻辑通吃三种模式;虚拟逻辑被“按需启用”,不会污染分页与加载更多的渲染路径。