el-table 虚拟滚动指令:从零构建到生产可用
1. 一个卡死的表格页
你有一个页面,用 el-table 渲染 10 万条数据:
<el-table :data="allData" height="400px">
<el-table-column prop="id" label="ID" />
<el-table-column prop="name" label="姓名" />
</el-table>
allData 是 10 万条的数组。页面打开直接白屏好几秒,滚动时更是灾难。
2. 为什么卡?
10 万条数据 = 10 万个 <tr> DOM 节点。渲染和滚动时的重绘会瞬间耗尽主线程。
直觉上的解法:用户一眼最多看到 10 行,那就只渲染这 10 行,滚动时再换一批。这就是虚拟滚动的核心。
3. 怎么只渲染看得见的行?
el-table 内部的可滚动容器是 .el-scrollbar__wrap。它能提供两个关键数值:
scrollTop:已经滚动了多少像素clientHeight:容器本身有多高(可见区域高度)
假设我们规定每行高度固定为 48px,那么:
- 当前应该从第几行开始显示?
startIndex = Math.floor(scrollTop / 48) - 应该显示到第几行?
endIndex = Math.ceil((scrollTop + clientHeight) / 48) - 1
比如 scrollTop = 960,clientHeight = 480,那么应当显示第 20 行到第 30 行。我们只要让表格的数据只包含这 10 条,性能立刻起飞。
4. 滚动条怎么办?
如果只给表格 10 条数据,滚动条会短得像只有 10 行数据,不但位置错误,而且滚到 10 行就到底了。
关键技巧:我们给 <table> 元素设置 padding-top 和 padding-bottom,把隐藏的那些行的高度“伪造”出来。
- 上方隐藏了 20 行 →
padding-top: 20 * 48px - 下方隐藏了 99980 行 →
padding-bottom: 99980 * 48px
这样滚动条的高度和行为就像真的有 10 万行,但实际渲染的始终只有那 10 个 <tr>。
5. 最基础的指令实现
先把上面这套逻辑写成自定义指令,能跑起来再说。
// v1:只实现基本滚动
export const vElTableVirtual = {
mounted(el, binding) {
const { rowHeight, totalCount, onRangeChange } = binding.value
const container = el.querySelector('.el-scrollbar__wrap')
const table = container.querySelector('table')
container.addEventListener('scroll', () => {
const scrollTop = container.scrollTop
const visibleH = container.clientHeight
const total = totalCount.value
let start = Math.floor(scrollTop / rowHeight)
let end = Math.ceil((scrollTop + visibleH) / rowHeight) - 1
start = Math.max(0, start)
end = Math.min(total - 1, end)
table.style.paddingTop = `${start * rowHeight}px`
table.style.paddingBottom = `${(total - end - 1) * rowHeight}px`
onRangeChange(start, end)
})
}
}
使用它:
<el-table v-el-table-virtual="virtualConfig" :data="visibleData" height="400px">
...
</el-table>
<script setup>
const allData = ref(/* 10万条 */)
const range = reactive({ start: 0, end: 0 })
const visibleData = computed(() => allData.value.slice(range.start, range.end + 1))
const virtualConfig = {
rowHeight: 48,
totalCount: computed(() => allData.value.length),
onRangeChange: (s, e) => { range.start = s; range.end = e }
}
</script>
页面秒开了。但快速滚动时特别涩,而且偶尔会闪一下空白。
6. 加入 requestAnimationFrame 节流
scroll 事件一秒触发上百次,每次都触发 Vue 响应式更新,肯定卡。我们限制每帧只更新一次。
export const vElTableVirtual = {
mounted(el, binding) {
const { rowHeight, totalCount, onRangeChange } = binding.value
const container = el.querySelector('.el-scrollbar__wrap')
const table = container.querySelector('table')
let ticking = false
function update() {
const scrollTop = container.scrollTop
const visibleH = container.clientHeight
const total = totalCount.value
let start = Math.floor(scrollTop / rowHeight)
let end = Math.ceil((scrollTop + visibleH) / rowHeight) - 1
start = Math.max(0, start)
end = Math.min(total - 1, end)
table.style.paddingTop = `${start * rowHeight}px`
table.style.paddingBottom = `${(total - end - 1) * rowHeight}px`
onRangeChange(start, end)
}
container.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
update()
ticking = false
})
ticking = true
}
})
}
}
现在滚动顺滑多了。
7. 加缓冲区,消灭空白闪现
快速滚动时空白行还没渲染出来,用户就会看到白条。在上下各多渲染 5 行(缓冲区),新行提前就位。
// 在 update 中调整 start/end 的计算
const buffer = 5
let start = Math.floor(scrollTop / rowHeight) - buffer
let end = Math.ceil((scrollTop + visibleH) / rowHeight) - 1 + buffer
start = Math.max(0, start)
end = Math.min(total - 1, end)
更新后的 update 函数:
function update() {
const scrollTop = container.scrollTop
const visibleH = container.clientHeight
const total = totalCount.value
let start = Math.floor(scrollTop / rowHeight) - buffer
let end = Math.ceil((scrollTop + visibleH) / rowHeight) - 1 + buffer
start = Math.max(0, start)
end = Math.min(total - 1, end)
table.style.paddingTop = `${start * rowHeight}px`
table.style.paddingBottom = `${(total - end - 1) * rowHeight}px`
onRangeChange(start, end)
}
8. 监听 totalCount,防止滚动条跳动
当数据总量从 10 万条变成 100 条时,如果不去立刻更新 padding,滚动条会保持原来巨大的高度,然后突然塌陷,体验极差。需要让指令内部 watch 总量变化,立刻重新计算。
引入 watch:
import { watch } from 'vue'
// 在 mounted 中监听
watch(totalCount, () => update())
现在数据总量变了,滚动条立刻正确响应。
9. 监听容器尺寸变化:ResizeObserver
用户拉大窗口或者收起侧边栏,表格高度会变。如果不更新 clientHeight,可见行数就不准了。用 ResizeObserver 解决。
const resizeObserver = new ResizeObserver(() => {
if (container.clientHeight === 0) return // 被隐藏时跳过
update()
})
resizeObserver.observe(container)
10. 外部触发滚动定位:scrollToIndex
业务里经常需要“跳到第 500 行”。增加一个 scrollToIndex 参数(一个 Ref),指令 watch 它,变化时主动设置 scrollTop。
function scrollToRow(index) {
const targetTop = index * rowHeight
const maxTop = container.scrollHeight - container.clientHeight
container.scrollTop = Math.min(targetTop, Math.max(0, maxTop))
update()
}
// 监听 scrollToIndex
let stopScrollWatch = null
if (scrollToIndex) {
stopScrollWatch = watch(scrollToIndex, (idx) => {
if (idx != null && idx >= 0) scrollToRow(idx)
})
}
程序化设置 scrollTop 会触发 scroll 事件,为了避免这次事件再次进入普通的节流更新,可以用一个标记 isProgrammaticScroll 来跳过。
11. 别忘了清理:unmounted
自定义指令里创建的监听器、观察者和 watch,Vue 不会自动回收,必须手动清理,否则会内存泄漏。
把所有要清理的东西收集起来:
el._vTableVirtualCleanup = () => {
container.removeEventListener('scroll', onScroll)
resizeObserver.disconnect()
stopTotalWatch()
if (stopScrollWatch) stopScrollWatch()
table.style.paddingTop = ''
table.style.paddingBottom = ''
}
在 unmounted 中调用它。
12. 汇总:最终完整代码(带 JSDoc)
经过以上一步步添加,最终的指令代码如下:
// directives/vElTableVirtual.js
import { watch } from 'vue'
/**
* el-table 虚拟滚动指令
*
* 只渲染可视区域及缓冲区的数据,通过 padding 撑开滚动条,大幅提升大数据量下的渲染性能。
*
* @example
* <el-table
* v-el-table-virtual="virtualConfig"
* :data="visibleData"
* height="400px"
* >
* ...
* </el-table>
*
* @param {Object} config - 指令配置对象
* @param {number} config.rowHeight - 每行固定高度(px),需与实际 CSS 行高一致
* @param {import('vue').Ref<number>} config.totalCount - 数据总条数的响应式引用
* @param {number} [config.buffer=5] - 可视区域上下方各缓冲的行数
* @param {(start: number, end: number) => void} config.onRangeChange - 可见范围变化时的回调,start/end 为索引(含)
* @param {import('vue').Ref<number>} [config.scrollToIndex] - 当该引用值变化时,自动滚动到指定行(索引从0开始)
*/
export const vElTableVirtual = {
mounted(el, binding) {
// ---------- 参数解构 ----------
const {
rowHeight,
totalCount,
buffer = 5,
onRangeChange,
scrollToIndex
} = binding.value
// ---------- 获取 DOM ----------
const container = el.querySelector('.el-scrollbar__wrap')
if (!container) {
console.warn('[v-el-table-virtual] 未找到 .el-scrollbar__wrap,请确保指令绑定在 el-table 上')
return
}
const table = container.querySelector('table')
if (!table) return
// ---------- 内部状态 ----------
/** 节流锁,防止同一帧内多次执行更新 */
let ticking = false
/** 标记是否为程序触发的滚动,用于跳过 scroll 事件回调 */
let isProgrammaticScroll = false
/**
* 核心更新函数
* 计算当前可视范围、设置 padding、触发回调
*/
function update() {
const scrollTop = container.scrollTop
const visibleH = container.clientHeight
const total = totalCount.value
// 容器不可见或无数据时清空显示
if (visibleH === 0 || total === 0) {
onRangeChange(0, 0)
table.style.paddingTop = ''
table.style.paddingBottom = ''
return
}
let start = Math.floor(scrollTop / rowHeight) - buffer
let end = Math.ceil((scrollTop + visibleH) / rowHeight) - 1 + buffer
start = Math.max(0, start)
end = Math.min(total - 1, end)
// 极少数情况下可能出现 start > end,进行矫正
if (start > end) {
start = 0
end = Math.min(total - 1, 0)
}
table.style.paddingTop = `${start * rowHeight}px`
table.style.paddingBottom = `${(total - end - 1) * rowHeight}px`
onRangeChange(start, end)
}
/**
* 请求更新(带 requestAnimationFrame 节流)
* 确保每帧最多执行一次 update
*/
function requestUpdate() {
if (ticking) return
requestAnimationFrame(() => {
update()
ticking = false
})
ticking = true
}
/**
* scroll 事件处理
* 忽略程序触发的滚动,避免重复计算
*/
function onScroll() {
if (isProgrammaticScroll) return
requestUpdate()
}
container.addEventListener('scroll', onScroll, { passive: true })
/**
* 程序化滚动到指定行
* @param {number} index - 目标行索引(从0开始)
*/
function scrollToRow(index) {
if (index < 0 || index >= totalCount.value) return
const targetTop = index * rowHeight
const maxTop = container.scrollHeight - container.clientHeight
container.scrollTop = Math.min(targetTop, Math.max(0, maxTop))
// 设置标记,避免本次程序滚动触发 onScroll 中的重复更新
isProgrammaticScroll = true
requestAnimationFrame(() => {
update()
isProgrammaticScroll = false
})
}
/**
* 监听数据总量变化
* 总量变化时立刻更新 padding 和可视范围,避免滚动条跳动
*/
const stopTotalWatch = watch(totalCount, () => {
update()
})
/**
* 监听容器尺寸变化(窗口缩放、侧边栏收起等)
* 只在容器可见时更新
*/
const resizeObserver = new ResizeObserver(() => {
if (container.clientHeight === 0) return
update()
})
resizeObserver.observe(container)
/**
* 监听外部 scrollToIndex 变化
* 当外部修改该 ref 的值时,自动滚动到目标行
*/
let stopScrollWatch = null
if (scrollToIndex) {
stopScrollWatch = watch(scrollToIndex, (newIndex) => {
if (newIndex != null && newIndex >= 0) {
scrollToRow(newIndex)
}
})
}
/**
* 收集所有需要清理的资源
* 在组件卸载时统一销毁,避免内存泄漏
*/
el._vTableVirtualCleanup = () => {
container.removeEventListener('scroll', onScroll)
resizeObserver.disconnect()
stopTotalWatch()
if (stopScrollWatch) stopScrollWatch()
table.style.paddingTop = ''
table.style.paddingBottom = ''
}
},
/**
* 指令卸载时清理所有副作用
*/
unmounted(el) {
if (el._vTableVirtualCleanup) {
el._vTableVirtualCleanup()
delete el._vTableVirtualCleanup
}
}
}
13. 使用方式(配置对象务必在 script 中构建)
在模板里直接写 v-el-table-virtual="{ totalCount: totalCountRef }" 会导致 ref 被解包成值,指令内部 watch 不到变化。正确的用法是把配置对象在 <script setup> 里组好:
<template>
<el-table
v-el-table-virtual="virtualConfig"
:data="visibleData"
height="400px"
border
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="姓名" />
</el-table>
<el-button @click="scrollTo500">滚动到第 500 行</el-button>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { vElTableVirtual } from './directives/vElTableVirtual'
const allData = ref(
Array.from({ length: 100000 }, (_, i) => ({
id: i + 1,
name: `用户${i + 1}`
}))
)
const range = reactive({ start: 0, end: 0 })
const visibleData = computed(() => allData.value.slice(range.start, range.end + 1))
const totalCountRef = computed(() => allData.value.length)
const scrollToRef = ref(null)
function handleRangeChange(start, end) {
range.start = start
range.end = end
}
const virtualConfig = {
rowHeight: 48,
totalCount: totalCountRef,
onRangeChange: handleRangeChange,
scrollToIndex: scrollToRef
}
function scrollTo500() {
scrollToRef.value = 499
}
</script>
14. 注意事项
- 行高必须固定,且
rowHeight要与实际 CSS 行高严格一致(默认约 48px,size="small"时约 36px)。 - 表格必须设置
height属性,否则不会有滚动容器,虚拟滚动无法生效。 totalCount必须是响应式引用(Ref<number>),让指令能 watch 到变化并立即更新 padding,避免滚动条跳动。- 配置对象务必在
script中构建好再传入模板,不要直接在模板里写对象字面量,否则ref会被解包失去响应性。 - 表格被
v-show隐藏时不受影响,ResizeObserver已处理clientHeight === 0的情况。