Skip to content

结论先说:不用另起炉灶。**在现有组件上做“模式开关”**更划算——把“虚拟滚动”做成可拔插策略,同时新增两种模式: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":外部维护 pagepageSize,监听 visible-data-updated 获取当前页可见行;需要翻页时 emit('update:page', newPage)(如需内置分页条可再加一个 slot/分页条封装)。
  • mode="infinite":外部维护数据与 hasMore;收到 load-more 后请求下一批并 append 到 dataloading 控制节流(已在阈值判断里避免抖动)。
  • mode="virtual":保持现状,渲染海量数据时性能最佳。

这样改动最小、复用最大:一套表头/列定义/粘性列/排序/展开逻辑通吃三种模式;虚拟逻辑被“按需启用”,不会污染分页与加载更多的渲染路径。

本站总访问