当产品经理说"我们需要展示一万条数据"时,你的浏览器瑟瑟发抖。本文将手把手教你实现一个支持 虚拟滚动 + 固定列 的高性能表格组件,让它重新自信起来。
📖 故事的开始:一个让浏览器崩溃的需求
某天,产品经理兴冲冲地跑过来:"我们的后台需要展示用户的操作日志,大概有一万条,要支持左右滚动,还要固定前两列方便对照。"
你心想:一万条数据,每条 8 个字段,渲染出来就是...
10000 行 × 8 列 = 80000 个 DOM 节点 😱
打开 Chrome DevTools,内存飙升到 2GB,页面卡顿 5 秒才响应一次滚动。用户体验?不存在的。
但如果换一种思路呢?
用户的屏幕就那么大,一次最多能看到 20 行数据。那我们为什么要渲染全部一万条?
20 行 × 8 列 = 160 个 DOM 节点 ✨
性能提升 500 倍!这就是虚拟表格的核心思想。
🧠 虚拟表格的本质:一场精心设计的"障眼法"
想象一个电影布景
虚拟滚动就像电影里的"移动背景"技巧:演员站在原地跑步,背景在身后快速移动,观众却以为演员在奔跑。
虚拟表格也是这样:
- 滚动条 是真实的(让用户感觉内容很多)
- 可见的行 是真实渲染的(用户能看到、能交互)
- 看不见的行 根本不存在(节省内存和渲染时间)
当用户滚动时,我们只是快速"换装"——把离开视野的行回收,把即将进入视野的行渲染出来。
与普通虚拟列表的区别
你可能用过或听说过虚拟列表,虚拟表格是它的"升级版":
| 特性 | 虚拟列表 | 虚拟表格 |
|---|---|---|
| 滚动方向 | 仅纵向 | 纵向 + 横向 |
| 固定区域 | 无 | 左固定列 + 右固定列 |
| 同步难度 | 简单 | 需要多区域联动 |
🏗️ 架构设计:三明治式的组件结构
在动手写代码之前,先想清楚架构。我们参考 Element Plus 的设计思路,将表格划分为三个区域:
┌─────────────────────────────────────────────────────────────────┐
│ VirtualTable │
├───────────────┬─────────────────────────────┬───────────────────┤
│ 🔒 左固定区 │ 📜 中间滚动区 │ 🔒 右固定区 │
│ │ │ │
│ ┌───────────┐ │ ┌─────────────────────────┐ │ ┌───────────────┐ │
│ │ 表头 │ │ │ 表头 │ │ │ 表头 │ │
│ ├───────────┤ │ ├─────────────────────────┤ │ ├───────────────┤ │
│ │ 表体 │ │ │ 表体 │ │ │ 表体 │ │
│ │ (跟随Y) │ │ │ (虚拟滚动 + 滚动条) │ │ │ (跟随Y) │ │
│ └───────────┘ │ └─────────────────────────┘ │ └───────────────┘ │
└───────────────┴─────────────────────────────┴───────────────────┘
为什么这样设计?
- 中间区域:拥有滚动条,是滚动事件的"发起者"
- 左右固定区:隐藏滚动条,但监听中间区域的
scrollTop,保持同步 - 三个区域独立渲染:各自只关心自己那几列,互不干扰
组件结构也就清晰了:
VirtualTable.vue # 🎯 主组件:统筹三个区域,协调滚动同步
├── TableHeader.vue # 📋 表头组件:渲染列标题
└── TableBody.vue # 🚀 表体组件:虚拟滚动的核心实现
🎮 核心组件一:VirtualTable(总指挥)
VirtualTable 是整个表格的"大脑",它不直接处理虚拟滚动,而是负责:
- 把列配置分成三组(左固定、中间滚动、右固定)
- 协调三个区域的滚动同步
- 对外暴露 API(如
scrollToRow)
Props 设计
先想清楚组件需要什么参数:
// 列配置结构
interface Column {
key: string // 列唯一标识
dataKey: string // 对应数据字段名
title: string // 列标题
width: number // 列宽度(必须指定,虚拟滚动需要)
fixed?: 'left' | 'right' | false // 固定位置
}
// 组件 Props
interface Props {
columns: Column[] // 列配置
data: any[] // 数据源
width: number // 表格总宽度
height: number // 表格总高度
rowHeight: number // 行高(固定值,虚拟滚动需要)
headerHeight: number // 表头高度
bufferSize: number // 缓冲区行数(防止快速滚动白屏)
}
为什么行高必须固定?
虚拟滚动需要提前计算"第 N 行在什么位置",如果行高不固定,就需要先渲染才能知道高度,这就失去了虚拟滚动的意义。(动态行高方案更复杂,本文暂不讨论)
模板结构
<template>
<div ref="tableContainerRef" class="virtual-table" :style="tableStyle">
<!-- 🔒 左侧固定列区域 -->
<div v-if="fixedLeftColumns.length" class="fixed-left-region" :style="{ width: `${fixedLeftWidth}px` }">
<TableHeader :columns="fixedLeftColumns" :row-height="headerHeight" />
<TableBody
ref="leftBodyRef"
:columns="fixedLeftColumns"
:data="data"
:row-height="rowHeight"
:height="bodyHeight"
:buffer-size="bufferSize"
:scroll-top="scrollTop"
:show-scrollbar="false"
@scroll="handleLeftBodyScroll"
/>
<!-- 滚动时显示阴影,增强层次感 -->
<div v-if="scrollLeft > 0" class="shadow-right"></div>
</div>
<!-- 📜 中间滚动区域(主角) -->
<div class="scrollable-region" :style="scrollableRegionStyle">
<!-- 表头需要跟随横向滚动 -->
<div class="scrollable-header-wrapper">
<div
class="scrollable-header-content"
:style="{ transform: `translateX(-${scrollLeft}px)`, width: `${scrollableWidth}px` }"
>
<TableHeader :columns="scrollableColumns" :row-height="headerHeight" />
</div>
</div>
<!-- 表体:虚拟滚动 + 双向滚动条 -->
<TableBody
ref="mainBodyRef"
:columns="scrollableColumns"
:data="data"
:row-height="rowHeight"
:height="bodyHeight"
:total-width="scrollableWidth"
:buffer-size="bufferSize"
:show-scrollbar="true"
@scroll="handleMainBodyScroll"
>
<template v-for="col in scrollableColumns" #[`cell-${col.key}`]="{ row, column, rowIndex }">
<slot :name="`cell-${col.key}`" :row="row" :column="column" :row-index="rowIndex">
{{ row[column.dataKey] }}
</slot>
</template>
</TableBody>
</div>
<!-- 🔒 右侧固定列区域 -->
<div v-if="fixedRightColumns.length" class="fixed-right-region" :style="{ width: `${fixedRightWidth}px` }">
<TableHeader :columns="fixedRightColumns" :row-height="headerHeight" />
<TableBody
ref="rightBodyRef"
:columns="fixedRightColumns"
:data="data"
:row-height="rowHeight"
:height="bodyHeight"
:buffer-size="bufferSize"
:scroll-top="scrollTop"
:show-scrollbar="false"
@scroll="handleRightBodyScroll"
/>
<div v-if="showRightShadow" class="shadow-left"></div>
</div>
<!-- 空状态 -->
<div v-if="!data.length" class="empty-placeholder">暂无数据</div>
</div>
</template>
核心逻辑
<script setup>
import { ref, computed, nextTick } from 'vue'
import TableHeader from './VirtualTable/tableHeader.vue'
import TableBody from './VirtualTable/tableBody.vue'
const props = defineProps({
columns: { type: Array, required: true, default: () => [] },
data: { type: Array, required: true, default: () => [] },
width: { type: Number, default: 800 },
height: { type: Number, default: 400 },
rowHeight: { type: Number, default: 48 },
headerHeight: { type: Number, default: 50 },
bufferSize: { type: Number, default: 5 }
})
const emit = defineEmits(['scroll'])
// ============ 组件引用 ============
const leftBodyRef = ref(null)
const mainBodyRef = ref(null)
const rightBodyRef = ref(null)
// ============ 滚动状态 ============
const scrollLeft = ref(0)
const scrollTop = ref(0)
const isSyncingScroll = ref(false) // 🔑 关键:防止滚动同步死循环
// ============ 尺寸计算 ============
const bodyHeight = computed(() => props.height - props.headerHeight)
const tableStyle = computed(() => ({ width: `${props.width}px`, height: `${props.height}px` }))
// ============ 列分组 ============
// 标准化列配置,确保必要字段都存在
const normalizedColumns = computed(() => {
return props.columns.map((col, index) => ({
...col,
key: col.key || col.dataKey || `col-${index}`,
dataKey: col.dataKey || col.key,
width: col.width || 100,
fixed: col.fixed || false
}))
})
// 三组列:左固定、右固定、中间滚动
const fixedLeftColumns = computed(() => normalizedColumns.value.filter(col => col.fixed === 'left'))
const fixedRightColumns = computed(() => normalizedColumns.value.filter(col => col.fixed === 'right'))
const scrollableColumns = computed(() => normalizedColumns.value.filter(col => !col.fixed))
// ============ 宽度计算 ============
const fixedLeftWidth = computed(() => fixedLeftColumns.value.reduce((sum, col) => sum + col.width, 0))
const fixedRightWidth = computed(() => fixedRightColumns.value.reduce((sum, col) => sum + col.width, 0))
const scrollableWidth = computed(() => scrollableColumns.value.reduce((sum, col) => sum + col.width, 0))
const scrollableRegionWidth = computed(() => props.width - fixedLeftWidth.value - fixedRightWidth.value)
const scrollableRegionStyle = computed(() => ({
width: `${scrollableRegionWidth.value}px`,
flex: '1 1 auto'
}))
// 右侧阴影:只有还没滚到最右边时才显示
const showRightShadow = computed(() => {
return scrollLeft.value < scrollableWidth.value - scrollableRegionWidth.value
})
// ============ 🔑 滚动同步(最关键的部分) ============
const syncVerticalScroll = (newScrollTop, source) => {
// 如果正在同步中,直接返回,防止死循环
if (isSyncingScroll.value) return
isSyncingScroll.value = true
scrollTop.value = newScrollTop
// 等 DOM 更新后,同步其他区域的滚动位置
nextTick(() => {
if (source !== 'left' && leftBodyRef.value) {
leftBodyRef.value.setScrollTop(newScrollTop)
}
if (source !== 'main' && mainBodyRef.value) {
mainBodyRef.value.setScrollTop(newScrollTop)
}
if (source !== 'right' && rightBodyRef.value) {
rightBodyRef.value.setScrollTop(newScrollTop)
}
// 用 setTimeout 确保同步完成后再解锁
setTimeout(() => { isSyncingScroll.value = false }, 0)
})
}
// 三个区域的滚动事件处理
const handleLeftBodyScroll = ({ scrollTop: top }) => {
syncVerticalScroll(top, 'left')
emit('scroll', { scrollTop: top, scrollLeft: scrollLeft.value })
}
const handleMainBodyScroll = ({ scrollTop: top, scrollLeft: left }) => {
scrollLeft.value = left // 只有中间区域能改变水平滚动
syncVerticalScroll(top, 'main')
emit('scroll', { scrollTop: top, scrollLeft: left })
}
const handleRightBodyScroll = ({ scrollTop: top }) => {
syncVerticalScroll(top, 'right')
emit('scroll', { scrollTop: top, scrollLeft: scrollLeft.value })
}
// ============ 对外暴露 API ============
const scrollTo = (options) => {
if (options.top !== undefined) {
syncVerticalScroll(options.top, 'api')
}
if (options.left !== undefined) {
scrollLeft.value = options.left
mainBodyRef.value?.setScrollLeft(options.left)
}
}
const scrollToRow = (rowIndex) => {
syncVerticalScroll(rowIndex * props.rowHeight, 'api')
}
defineExpose({ scrollTo, scrollToRow })
</script>
💡 关键设计解析
1. 为什么需要 isSyncingScroll 标志?
想象一下没有这个标志会发生什么:
用户滚动中间区域 → 中间区域触发 scroll 事件
→ 同步左侧区域 scrollTop → 左侧区域触发 scroll 事件
→ 又要同步中间区域 → 中间区域触发 scroll 事件
→ 无限循环... 💥
加上 isSyncingScroll 后,同步期间忽略其他滚动事件,问题解决。
2. 为什么用 transform 而不是 scrollLeft 同步表头?
中间区域的表头不在滚动容器内,无法直接用 scrollLeft。我们用 transform: translateX(-${scrollLeft}px) 来实现"假滚动"效果,视觉上完全一致。
📋 核心组件二:TableHeader(表头)
TableHeader 是最简单的组件,职责单一:渲染列标题,保持与表体列宽一致。
<template>
<div class="virtual-table-header" :style="headerStyle">
<div class="header-row">
<div
v-for="column in columns"
:key="column.key"
class="header-cell"
:style="getCellStyle(column)"
>
<div class="cell-content">
<slot :name="`header-${column.key}`" :column="column">
{{ column.title }}
</slot>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
columns: { type: Array, default: () => [] },
rowHeight: { type: Number, default: 50 }
})
const headerStyle = computed(() => ({
height: `${props.rowHeight}px`,
lineHeight: `${props.rowHeight}px`
}))
const getCellStyle = (column) => ({
width: `${column.width}px`,
minWidth: `${column.minWidth || column.width}px`,
textAlign: column.align || 'left',
flex: `0 0 ${column.width}px` // 不放大、不缩小、固定宽度
})
</script>
为什么用 flex: 0 0 ${width}px?
这确保列宽严格固定,不会因为内容多少而伸缩,保证表头和表体的列完美对齐。
🚀 核心组件三:TableBody(虚拟滚动的心脏)
这是整个方案最精髓的部分。让我们一步步理解它是如何工作的。
虚拟滚动的"三层蛋糕"
┌──────────────────────────────────────┐ ← 滚动容器(固定高度,overflow: auto)
│ ┌──────────────────────────────────┐ │
│ │ │ │
│ │ 👻 幽灵层 │ │ ← 高度 = 数据总数 × 行高(撑起滚动条)
│ │ (一个空的超高 div) │ │
│ │ │ │
│ └──────────────────────────────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ 🎯 内容层(真实渲染的行) │ │ ← 通过 transform 定位到可视区域
│ │ ┌────────────────────────────┐ │ │
│ │ │ Row 15 │ │ │
│ │ │ Row 16 │ │ │
│ │ │ Row 17 ← 用户看到的 │ │ │
│ │ │ Row 18 │ │ │
│ │ │ Row 19 │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
工作流程:
- 幽灵层高度 = 10000 行 × 48px = 480000px,滚动条以为内容有这么多
- 用户滚动时,根据
scrollTop计算当前应该显示第几行到第几行 - 内容层只渲染这些行,并用
transform: translateY()定位到正确位置 - 用户看到的效果和渲染全部数据一模一样,但 DOM 节点只有几十个
关键公式
// 假设:scrollTop = 720px, rowHeight = 48px, 可视高度 = 400px, bufferSize = 5
// 1. 计算当前滚动到第几行
currentRow = Math.floor(720 / 48) = 15
// 2. 起始索引(往上留 5 行缓冲)
startIndex = Math.max(0, 15 - 5) = 10
// 3. 可见行数
visibleCount = Math.ceil(400 / 48) + 5 * 2 = 9 + 10 = 19
// 4. 结束索引
endIndex = Math.min(10 + 19, 数据总数) = 29
// 5. 内容层偏移
offset = 10 * 48 = 480px
// 结果:渲染第 10-29 行,内容层向下偏移 480px
组件代码
<template>
<div
ref="containerRef"
class="virtual-table-body"
:class="{ 'hide-scrollbar': !showScrollbar }"
:style="containerStyle"
@scroll="handleScroll"
>
<!-- 👻 幽灵层:只有高度,没有内容 -->
<div class="phantom" :style="phantomStyle"></div>
<!-- 🎯 内容层:实际渲染的行 -->
<div class="content-layer" :style="contentStyle">
<div
v-for="(row, idx) in visibleData"
:key="getRowKey(row, startIndex + idx)"
class="table-row"
:style="rowStyle"
>
<div
v-for="column in columns"
:key="column.key"
class="table-cell"
:style="getCellStyle(column)"
>
<div class="cell-content">
<slot
:name="`cell-${column.key}`"
:row="row"
:column="column"
:row-index="startIndex + idx"
>
{{ row[column.dataKey] }}
</slot>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
columns: { type: Array, default: () => [] },
data: { type: Array, default: () => [] },
rowHeight: { type: Number, default: 48 },
height: { type: Number, default: 350 },
totalWidth: { type: Number, default: 0 },
bufferSize: { type: Number, default: 5 },
rowKey: { type: [String, Function], default: 'id' },
scrollTop: { type: Number, default: undefined }, // 外部传入,用于同步
showScrollbar: { type: Boolean, default: true }
})
const emit = defineEmits(['scroll'])
// ============ 组件状态 ============
const containerRef = ref(null)
const startIndex = ref(0)
const contentOffset = ref(0)
const ticking = ref(false) // RAF 节流标志
const isSyncing = ref(false) // 同步标志
// ============ 尺寸计算 ============
const columnsWidth = computed(() => props.columns.reduce((sum, col) => sum + col.width, 0))
const contentWidth = computed(() => props.totalWidth || columnsWidth.value)
// 幽灵层高度 = 数据总数 × 行高
const phantomHeight = computed(() => props.data.length * props.rowHeight)
// 可见行数 = 容器高度 ÷ 行高 + 双倍缓冲区
const visibleCount = computed(() => {
return Math.ceil(props.height / props.rowHeight) + props.bufferSize * 2
})
// 结束索引
const endIndex = computed(() => {
return Math.min(startIndex.value + visibleCount.value, props.data.length)
})
// 🔑 可见数据:这就是我们实际渲染的部分!
const visibleData = computed(() => {
return props.data.slice(startIndex.value, endIndex.value)
})
// ============ 样式计算 ============
const containerStyle = computed(() => ({ height: `${props.height}px` }))
const phantomStyle = computed(() => ({
height: `${phantomHeight.value}px`,
width: `${contentWidth.value}px`
}))
const contentStyle = computed(() => ({
transform: `translateY(${contentOffset.value}px)`, // 🔑 定位魔法
width: `${contentWidth.value}px`
}))
const rowStyle = computed(() => ({ height: `${props.rowHeight}px` }))
// ============ 工具函数 ============
const getRowKey = (row, index) => {
if (typeof props.rowKey === 'function') return props.rowKey(row)
return row[props.rowKey] ?? index
}
const getCellStyle = (column) => ({
width: `${column.width}px`,
flex: `0 0 ${column.width}px`
})
// 根据 scrollTop 计算起始索引
const getStartIndex = (scrollTop) => {
return Math.max(0, Math.floor(scrollTop / props.rowHeight) - props.bufferSize)
}
// ============ 🔑 虚拟滚动核心:更新渲染范围 ============
const updateVirtualState = (scrollTop) => {
const newStartIndex = getStartIndex(scrollTop)
// 只有索引变化时才更新,避免无谓的计算
if (newStartIndex !== startIndex.value) {
startIndex.value = newStartIndex
contentOffset.value = startIndex.value * props.rowHeight
}
}
// ============ 滚动事件处理(RAF 节流) ============
const handleScroll = (e) => {
// 正在同步中,忽略事件
if (isSyncing.value) return
// RAF 节流:确保一帧只处理一次
if (!ticking.value) {
requestAnimationFrame(() => {
if (!containerRef.value) {
ticking.value = false
return
}
const { scrollTop, scrollLeft } = e.target
updateVirtualState(scrollTop)
emit('scroll', { scrollTop, scrollLeft })
ticking.value = false
})
ticking.value = true
}
}
// ============ 外部调用方法 ============
const setScrollTop = (top) => {
if (containerRef.value && Math.abs(containerRef.value.scrollTop - top) > 1) {
isSyncing.value = true
containerRef.value.scrollTop = top
updateVirtualState(top)
nextTick(() => { isSyncing.value = false })
}
}
const setScrollLeft = (left) => {
if (containerRef.value) {
containerRef.value.scrollLeft = left
}
}
// 监听外部传入的 scrollTop,实现同步
watch(() => props.scrollTop, (newVal) => {
if (newVal !== undefined && !isSyncing.value) {
setScrollTop(newVal)
}
})
defineExpose({ setScrollTop, setScrollLeft, containerRef })
</script>
💡 性能优化要点
1. 为什么用 requestAnimationFrame 节流?
滚动事件触发频率非常高(每秒可能上百次),如果每次都更新状态,会导致性能问题。RAF 确保每一帧最多更新一次,流畅度刚刚好。
2. 为什么用 transform 而不是 top 或 margin-top?
transform 不会触发回流(reflow),只会触发重绘(repaint),性能更好。现代浏览器还会对 transform 进行 GPU 加速。
3. 为什么需要缓冲区(bufferSize)?
如果只渲染可视区域,快速滚动时会出现白屏——旧的行已销毁,新的行还没渲染出来。缓冲区就是提前多渲染几行,给浏览器预留反应时间。
🎨 三个区域如何协同工作
让我们用一张表总结三个区域的关系:
共享的配置参数
| 参数 | 作用 | 谁传给谁 |
|---|---|---|
data | 表格数据 | VirtualTable → 所有 TableBody |
rowHeight | 行高 | VirtualTable → 所有子组件 |
headerHeight | 表头高度 | VirtualTable → 所有 TableHeader |
bufferSize | 缓冲区大小 | VirtualTable → 所有 TableBody |
同步的状态
| 状态 | 来源 | 流向 |
|---|---|---|
scrollTop | 中间区域滚动触发 | → 左右固定区域同步 |
scrollLeft | 中间区域滚动触发 | → 中间表头 transform 同步 |
事件流
用户滚动中间区域
↓
mainBodyRef.scroll 事件触发
↓
handleMainBodyScroll() 被调用
↓
├── scrollLeft.value = newLeft(更新水平位置)
└── syncVerticalScroll(newTop, 'main')
↓
├── scrollTop.value = newTop(更新垂直位置)
├── leftBodyRef.setScrollTop(newTop)(同步左侧)
└── rightBodyRef.setScrollTop(newTop)(同步右侧)
📊 最终效果对比
| 指标 | 传统表格 | 虚拟表格 |
|---|---|---|
| 1万行 DOM 节点数 | ~80,000 | ~200 |
| 首屏渲染时间 | 3-5秒 | <100ms |
| 滚动帧率 | 卡顿 | 60fps |
| 内存占用 | 500MB+ | <50MB |
演示
✅ 总结
通过本文,我们实现了一个完整的虚拟表格组件,核心要点回顾:
- 虚拟滚动原理:只渲染可视区域,用"幽灵层"撑起滚动条
- 三区域架构:左固定 + 中间滚动 + 右固定,各自独立又协同工作
- 滚动同步:通过
isSyncingScroll标志防止死循环 - 性能优化:RAF 节流、transform 定位、缓冲区预渲染
这个方案可以轻松应对 万级甚至十万级 数据的展示需求。如果你的业务中也有大数据表格的场景,不妨试试这个方案!
🏹 完整代码
VirtualTable
<template>
<div ref="tableContainerRef" class="virtual-table" :style="tableStyle">
<!-- 左侧固定列区域 -->
<div v-if="fixedLeftColumns.length" class="fixed-left-region" :style="{ width: `${fixedLeftWidth}px` }">
<TableHeader :columns="fixedLeftColumns" :row-height="headerHeight" />
<TableBody ref="leftBodyRef" :columns="fixedLeftColumns" :data="data" :row-height="rowHeight"
:height="bodyHeight" :buffer-size="bufferSize" :scroll-top="scrollTop" :show-scrollbar="false"
@scroll="handleLeftBodyScroll" />
<div v-if="scrollLeft > 0" class="shadow-right"></div>
</div>
<!-- 中间滚动区域 -->
<div class="scrollable-region" :style="scrollableRegionStyle">
<div class="scrollable-header-wrapper">
<div class="scrollable-header-content"
:style="{ transform: `translateX(-${scrollLeft}px)`, width: `${scrollableWidth}px` }">
<TableHeader :columns="scrollableColumns" :row-height="headerHeight" />
</div>
</div>
<TableBody ref="mainBodyRef" :columns="scrollableColumns" :data="data" :row-height="rowHeight"
:height="bodyHeight" :total-width="scrollableWidth" :buffer-size="bufferSize" :show-scrollbar="true"
@scroll="handleMainBodyScroll">
<template v-for="col in scrollableColumns" #[`cell-${col.key}`]="{ row, column, rowIndex }">
<slot :name="`cell-${col.key}`" :row="row" :column="column" :row-index="rowIndex">
{{ row[column.dataKey] }}
</slot>
</template>
</TableBody>
</div>
<!-- 右侧固定列区域 -->
<div v-if="fixedRightColumns.length" class="fixed-right-region" :style="{ width: `${fixedRightWidth}px` }">
<TableHeader :columns="fixedRightColumns" :row-height="headerHeight" />
<TableBody ref="rightBodyRef" :columns="fixedRightColumns" :data="data" :row-height="rowHeight"
:height="bodyHeight" :buffer-size="bufferSize" :scroll-top="scrollTop" :show-scrollbar="false"
@scroll="handleRightBodyScroll" />
<div v-if="showRightShadow" class="shadow-left"></div>
</div>
<!-- 空数据 -->
<div v-if="!data.length" class="empty-placeholder">暂无数据</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import TableHeader from './VirtualTable/tableHeader.vue'
import TableBody from './VirtualTable/tableBody.vue'
const props = defineProps({
columns: { type: Array, required: true, default: () => [] },
data: { type: Array, required: true, default: () => [] },
width: { type: Number, default: 800 },
height: { type: Number, default: 400 },
rowHeight: { type: Number, default: 48 },
headerHeight: { type: Number, default: 50 },
bufferSize: { type: Number, default: 5 }
})
const emit = defineEmits(['scroll'])
// refs
const leftBodyRef = ref(null)
const mainBodyRef = ref(null)
const rightBodyRef = ref(null)
// 滚动位置
const scrollLeft = ref(0)
const scrollTop = ref(0)
const isSyncingScroll = ref(false)
// 表体高度
const bodyHeight = computed(() => props.height - props.headerHeight)
// 表格样式
const tableStyle = computed(() => ({ width: `${props.width}px`, height: `${props.height}px` }))
// 标准化列配置
const normalizedColumns = computed(() => {
return props.columns.map((col, index) => ({
...col,
key: col.key || col.dataKey || `col-${index}`,
dataKey: col.dataKey || col.key,
width: col.width || 100,
fixed: col.fixed || false
}))
})
// 列分组
const fixedLeftColumns = computed(() => normalizedColumns.value.filter(col => col.fixed === 'left'))
const fixedRightColumns = computed(() => normalizedColumns.value.filter(col => col.fixed === 'right'))
const scrollableColumns = computed(() => normalizedColumns.value.filter(col => !col.fixed))
// 宽度计算
const fixedLeftWidth = computed(() => fixedLeftColumns.value.reduce((sum, col) => sum + col.width, 0))
const fixedRightWidth = computed(() => fixedRightColumns.value.reduce((sum, col) => sum + col.width, 0))
const scrollableWidth = computed(() => scrollableColumns.value.reduce((sum, col) => sum + col.width, 0))
const scrollableRegionWidth = computed(() => props.width - fixedLeftWidth.value - fixedRightWidth.value)
// 样式
const scrollableRegionStyle = computed(() => ({ width: `${scrollableRegionWidth.value}px`, flex: '1 1 auto' }))
// 右侧阴影
const showRightShadow = computed(() => scrollLeft.value < scrollableWidth.value - scrollableRegionWidth.value)
// 同步垂直滚动
const syncVerticalScroll = (newScrollTop, source) => {
if (isSyncingScroll.value) return
isSyncingScroll.value = true
scrollTop.value = newScrollTop
nextTick(() => {
if (source !== 'left' && leftBodyRef.value) leftBodyRef.value.setScrollTop(newScrollTop)
if (source !== 'main' && mainBodyRef.value) mainBodyRef.value.setScrollTop(newScrollTop)
if (source !== 'right' && rightBodyRef.value) rightBodyRef.value.setScrollTop(newScrollTop)
setTimeout(() => { isSyncingScroll.value = false }, 0)
})
}
// 滚动事件处理
const handleLeftBodyScroll = ({ scrollTop: top }) => {
syncVerticalScroll(top, 'left')
emit('scroll', { scrollTop: top, scrollLeft: scrollLeft.value })
}
const handleMainBodyScroll = ({ scrollTop: top, scrollLeft: left }) => {
scrollLeft.value = left
syncVerticalScroll(top, 'main')
emit('scroll', { scrollTop: top, scrollLeft: left })
}
const handleRightBodyScroll = ({ scrollTop: top }) => {
syncVerticalScroll(top, 'right')
emit('scroll', { scrollTop: top, scrollLeft: scrollLeft.value })
}
// 暴露方法
const scrollTo = (options) => {
if (options.top !== undefined) syncVerticalScroll(options.top, 'api')
if (options.left !== undefined) {
scrollLeft.value = options.left
mainBodyRef.value?.setScrollLeft(options.left)
}
}
const scrollToRow = (rowIndex) => syncVerticalScroll(rowIndex * props.rowHeight, 'api')
defineExpose({ scrollTo, scrollToRow })
</script>
<style lang="less" scoped>
.virtual-table {
position: relative;
display: flex;
border: 1px solid #ebeef5;
border-radius: 4px;
background-color: #fff;
overflow: hidden;
font-size: 14px;
color: #606266;
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 3px;
}
}
.fixed-left-region,
.fixed-right-region {
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
z-index: 2;
background-color: #fff;
}
.fixed-left-region .shadow-right {
position: absolute;
top: 0;
right: -10px;
width: 10px;
height: 100%;
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
.fixed-right-region .shadow-left {
position: absolute;
top: 0;
left: -10px;
width: 10px;
height: 100%;
box-shadow: inset -10px 0 8px -8px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
.scrollable-region {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.scrollable-header-wrapper {
flex-shrink: 0;
overflow: hidden;
}
.scrollable-header-content {
will-change: transform;
}
.empty-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #909399;
}
</style>
TableHeader
<template>
<div class="virtual-table-header" :style="headerStyle">
<div class="header-row">
<div v-for="column in columns" :key="column.key" class="header-cell" :style="getCellStyle(column)">
<div class="cell-content">
<slot :name="`header-${column.key}`" :column="column">
{{ column.title }}
</slot>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
columns: {
type: Array,
default: () => []
},
rowHeight: {
type: Number,
default: 50
}
})
const headerStyle = computed(() => ({
height: `${props.rowHeight}px`,
lineHeight: `${props.rowHeight}px`
}))
const getCellStyle = (column) => ({
width: `${column.width}px`,
minWidth: `${column.minWidth || column.width}px`,
textAlign: column.align || 'left',
flex: `0 0 ${column.width}px`
})
</script>
<style lang="less" scoped>
.virtual-table-header {
display: flex;
background-color: #f5f7fa;
border-bottom: 1px solid #ebeef5;
font-weight: 500;
flex-shrink: 0;
}
.header-row {
display: flex;
}
.header-cell {
display: flex;
align-items: center;
padding: 0 12px;
border-right: 1px solid #ebeef5;
box-sizing: border-box;
overflow: hidden;
&:last-child {
border-right: none;
}
}
.cell-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
TableBody
<template>
<div ref="containerRef" class="virtual-table-body" :class="{ 'hide-scrollbar': !showScrollbar }"
:style="containerStyle" @scroll="handleScroll">
<!-- 幽灵层:撑起滚动高度 -->
<div class="phantom" :style="phantomStyle"></div>
<!-- 内容层:实际渲染的行 -->
<div class="content-layer" :style="contentStyle">
<div v-for="(row, idx) in visibleData" :key="getRowKey(row, startIndex + idx)" class="table-row"
:style="rowStyle">
<div v-for="column in columns" :key="column.key" class="table-cell" :style="getCellStyle(column)">
<div class="cell-content">
<slot :name="`cell-${column.key}`" :row="row" :column="column" :row-index="startIndex + idx">
{{ row[column.dataKey] }}
</slot>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
columns: { type: Array, default: () => [] },
data: { type: Array, default: () => [] },
rowHeight: { type: Number, default: 48 },
height: { type: Number, default: 350 },
totalWidth: { type: Number, default: 0 },
bufferSize: { type: Number, default: 5 },
rowKey: { type: [String, Function], default: 'id' },
scrollTop: { type: Number, default: undefined },
showScrollbar: { type: Boolean, default: true }
})
const emit = defineEmits(['scroll'])
const containerRef = ref(null)
const startIndex = ref(0)
const contentOffset = ref(0)
const ticking = ref(false)
const isSyncing = ref(false)
// 列总宽度
const columnsWidth = computed(() => props.columns.reduce((sum, col) => sum + col.width, 0))
const contentWidth = computed(() => props.totalWidth || columnsWidth.value)
// 幽灵层高度
const phantomHeight = computed(() => props.data.length * props.rowHeight)
// 可见行数
const visibleCount = computed(() => Math.ceil(props.height / props.rowHeight) + props.bufferSize * 2)
// 结束索引
const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.data.length))
// 可见数据
const visibleData = computed(() => props.data.slice(startIndex.value, endIndex.value))
// 样式
const containerStyle = computed(() => ({ height: `${props.height}px` }))
const phantomStyle = computed(() => ({ height: `${phantomHeight.value}px`, width: `${contentWidth.value}px` }))
const contentStyle = computed(() => ({ transform: `translateY(${contentOffset.value}px)`, width: `${contentWidth.value}px` }))
const rowStyle = computed(() => ({ height: `${props.rowHeight}px` }))
// 获取行 key
const getRowKey = (row, index) => {
if (typeof props.rowKey === 'function') return props.rowKey(row)
return row[props.rowKey] ?? index
}
// 获取单元格样式
const getCellStyle = (column) => ({
width: `${column.width}px`,
flex: `0 0 ${column.width}px`
})
// 计算起始索引
const getStartIndex = (scrollTop) => Math.max(0, Math.floor(scrollTop / props.rowHeight) - props.bufferSize)
// 更新虚拟滚动状态
const updateVirtualState = (scrollTop) => {
const newStartIndex = getStartIndex(scrollTop)
if (newStartIndex !== startIndex.value) {
startIndex.value = newStartIndex
contentOffset.value = startIndex.value * props.rowHeight
}
}
// 滚动事件(RAF节流)
const handleScroll = (e) => {
if (isSyncing.value) return
if (!ticking.value) {
requestAnimationFrame(() => {
if (!containerRef.value) { ticking.value = false; return }
const { scrollTop, scrollLeft } = e.target
updateVirtualState(scrollTop)
emit('scroll', { scrollTop, scrollLeft })
ticking.value = false
})
ticking.value = true
}
}
// 外部调用方法
const setScrollTop = (top) => {
if (containerRef.value && Math.abs(containerRef.value.scrollTop - top) > 1) {
isSyncing.value = true
containerRef.value.scrollTop = top
updateVirtualState(top)
nextTick(() => { isSyncing.value = false })
}
}
const setScrollLeft = (left) => {
if (containerRef.value) containerRef.value.scrollLeft = left
}
// 监听外部 scrollTop 变化
watch(() => props.scrollTop, (newVal) => {
if (newVal !== undefined && !isSyncing.value) setScrollTop(newVal)
})
defineExpose({ setScrollTop, setScrollLeft, containerRef })
</script>
<style lang="less" scoped>
.virtual-table-body {
position: relative;
overflow: auto;
flex: 1;
&.hide-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}
.phantom {
position: absolute;
top: 0;
left: 0;
z-index: -1;
}
.content-layer {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.table-row {
display: flex;
border-bottom: 1px solid #ebeef5;
background-color: #fff;
&:hover {
background-color: #f5f7fa;
}
}
.table-cell {
display: flex;
align-items: center;
padding: 0 12px;
border-right: 1px solid #ebeef5;
overflow: hidden;
&:last-child {
border-right: none;
}
}
.cell-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>