el-table 虚拟滚动指令:从零构建到生产可用

452 阅读7分钟

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 = 960clientHeight = 480,那么应当显示第 20 行到第 30 行。我们只要让表格的数据只包含这 10 条,性能立刻起飞。

4. 滚动条怎么办?

如果只给表格 10 条数据,滚动条会短得像只有 10 行数据,不但位置错误,而且滚到 10 行就到底了。

关键技巧:我们给 <table> 元素设置 padding-toppadding-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. 注意事项

  1. 行高必须固定,且 rowHeight 要与实际 CSS 行高严格一致(默认约 48px,size="small" 时约 36px)。
  2. 表格必须设置 height 属性,否则不会有滚动容器,虚拟滚动无法生效。
  3. totalCount 必须是响应式引用(Ref<number>),让指令能 watch 到变化并立即更新 padding,避免滚动条跳动。
  4. 配置对象务必在 script 中构建好再传入模板,不要直接在模板里写对象字面量,否则 ref 会被解包失去响应性。
  5. 表格被 v-show 隐藏时不受影响,ResizeObserver 已处理 clientHeight === 0 的情况。