接手一个领导驾驶舱项目时,遇到一个很常见的需求:战报列表要自动向上滚动,数据一屏放不下就轮播。技术栈是vue3 + ts + element-plus,列表组件自然选了el-table。问题来了——el-table本身没有自动滚动能力,网上搜到的“列表滚动”方案大多是给纯div用的CSS动画,直接搬到el-table上各种水土不服。后来我封装了一个AutoScrollTable组件,花了小半天把思路理顺,这里把完整方案、代码和踩过的坑一并写下来,给同样在做大屏或者后台管理系统的朋友做个参考。
这个需求听着简单,实际牵扯到滚动原理、el-table内部DOM结构、TS类型设计、组件封装边界几个层面。如果你只是想要一个能跑的组件,可以直接跳到第4节抄代码;如果你想搞清楚为什么这么设计、遇到问题怎么排查,就把全文过一遍,后面小节里写的都是实测中真实遇到的问题。
1. 大屏列表滚动:为什么el-table原生方案走不通
1.1 所谓“列表滚动”,先分清是哪一种需求
做开发最忌讳一上来就写代码,先搞清楚你要的是哪个“滚动”。
我总结实际项目里常见的列表滚动大概有三类:
- 自动轮播:数据定时向上滚动,循环播放,多用于大屏、领导驾驶舱、广告机、战报屏。
- 手动滚轮:普通表格内容超出可视区,用户自己用鼠标滚轮或拖滚动条浏览。
- 高亮跟踪滚动:类似股票行情,当前行高亮并自动跟随滚动。
el-table原生只支持第二类。第一类和第三类都得自己造轮子。本文要解决的是第一类“自动轮播”,这也是大屏项目里最高频的需求。
第三类高亮跟踪滚动逻辑上其实是第一类的变体,核心的滚动驱动机制完全一样,只是在滚动过程中额外维护一个当前高亮行索引。等你看完文中的useAutoScroll,改成高亮跟踪版只需要再加一个watch。
1.2 CSS transform 整块位移方案的三个硬伤
很多人第一反应是用CSS动画:
@keyframes scrollUp { from { transform: translateY(0); } to { transform: translateY(-100%); } }这个方案在纯div渲染的列表里确实可行,网上也搜得到一堆demo。但搬到el-table上基本就废了,原因有三个:
第一个硬伤是el-table的表头和表体在DOM结构上是分离的。el-table内部结构大致是:外层.el-table,里面一个.el-table__header-wrapper管表头,另一个.el-table__body-wrapper管表体。如果你对整个el-table做transform动画,表头会跟着一起滚走,视觉上完全不对;如果只对body-wrapper做transform,又会和el-table自身的overflow滚动机制互相干扰。
第二个硬伤是行高不可控。el-table的单元格内容可能换行,表格行高是动态计算的。CSS动画的keyframes只能按百分比或者固定像素值位移,你根本不知道“一行”到底多高。translateY(-100%)表示整体滚动一个内容高度,但你要的是一行一行往上滚动,这两件事对不上。
第三个硬伤是交互缺失。纯CSS动画一旦跑起来,很难精细控制“鼠标悬停暂停”“数据更新后复位”“滚动到底部无缝衔接”这类交互需求。虽然可以用animation-play-state控制暂停,但想要在运行中动态改变速度、重置位置,就得去操作动画本身,维护成本很高。
所以在实际项目里我直接放弃了CSS方案,转向JS驱动scrollTop的路线。这个方向从一开始就是对的,后面所有问题都出在实现细节上。
1.3 可靠路径:直接驱动 scrollTop 的像素引擎
scrollTop方案理解起来很简单:el-table设置了固定高度之后,表体会出现一个可滚动的容器,我们只要每隔一段时间把该容器的scrollTop往上加一点,视觉上就形成了平滑向上滚动。
这个方案的好处是:
- 不关心行高是多少,只按像素驱动,容器本身的滚动机制会处理跨行位移。
- 可以随时暂停、恢复、重置、变速,完全由JS控制。
- 深色主题适配、自定义滚动条宽度这些都容易做,因为本质就是操作一个普通滚动DOM。
核心公式只有一行:
scrollEl.scrollTop += speedPerSecond * deltaTimeSeconds把滚动速度定义成“每秒滚动多少像素”,然后每帧根据真实时间间隔计算本帧要滚动的像素数。这里有一个关键点:不能每帧固定加一个值,因为不同显示器刷新率不同,每帧间隔不一样,固定加值会导致速度忽快忽慢。必须用deltaTime换算。
这个“像素引擎”就是我们后面封装useAutoScroll的底层逻辑,先记着这个公式,后面会反复用到。
2. 封装前必须想清楚的:滚动容器、高度策略、数据范围
2.1 滚动容器到底藏在 el-table 的哪个DOM层
写代码前要先弄清楚一件事:el-table设置了固定高度后,真正产生滚动条、scrollTop可以变化的那个DOM元素到底是谁。
我的经验是直接看渲染后的DOM树。el-table内部用的是Element Plus的ElScrollbar组件,滚动容器是.el-scrollbar__wrap这个div。scrollTop就是加在这个元素上。
获取它的方式:
import type { TableInstance } from 'element-plus' const tableRef = ref<TableInstance | null>(null) const getScrollWrap = (): HTMLElement | null => { const tableEl = tableRef.value?.$el return tableEl?.querySelector('.el-scrollbar__wrap') ?? null }这里注意两点。
第一,tableRef.value.$el拿到的就是el-table组件的根DOM节点。可以直接在根节点上querySelector,不需要再往上找父级。
第二,如果表格配置了多级表头或者固定列,DOM里可能会出现多个.el-scrollbar__wrap。这种情况下建议用querySelectorAll遍历一次,挑出包含表格body内容的那个。我实际项目里的表格没有固定列,所以取第一个就够用。如果你有固定列,最好手动验证一下。
还有一个容易踩的坑:el-table在数据为空时,滚动容器可能压根不渲染,所以getScrollWrap()返回null。这个就要求所有使用滚动容器的逻辑都做空值判断,后面Hook设计里也会体现。
2.2 height、max-height、auto 三选一,以及容器自适应
要让el-table出现内部滚动区域,必须给它设置高度。Element Plus的height属性接收三种值:
- height:固定高度,超出高度时表体内部滚动。
- max-height:表格先按内容自适应撑开,超过最大高度后才出现滚动区。
- auto:完全自适应,不主动产生滚动。
自动轮播场景必须用固定height。原因很直接:如果表格高度跟着内容撑开,内容比一屏短时就不会有滚动容器,内容比一屏长时高度又超出卡片,视觉上非常乱。只有高度固定,表格才会稳定地出现一个滚动区。
但“固定高度”不等于写死。我在项目里的做法是:外部容器控制高度,el-table的height绑定一个响应式变量,用ResizeObserver监听容器尺寸变化,动态把容器高度塞给表格。
const wrapperRef = ref<HTMLElement | null>(null) const tableHeight = ref(300) let resizeObserver: ResizeObserver | null = null onMounted(() => { if (wrapperRef.value) { tableHeight.value = wrapperRef.value.clientHeight resizeObserver = new ResizeObserver((entries) => { tableHeight.value = entries[0].contentRect.height }) resizeObserver.observe(wrapperRef.value) } }) onBeforeUnmount(() => { resizeObserver?.disconnect() })为什么要用ResizeObserver而不是window.resize?因为大屏项目的卡片经常有拖拽调整大小的功能,容器尺寸变化不一定会触发window的resize事件。ResizeObserver直接观察容器本身,任何尺寸变化都能感知到,而且不会因为页面里其他元素尺寸变化产生误触发。
2.3 数据量不够时学会“不滚动”
这也是封装时容易忽视的点。数据只有两三行,可视区能放十行,这时候还启动滚动就是空转——滚动容器根本没有滚动空间,scrollHeight等于clientHeight,怎么加scrollTop都没反应。
判断能否滚动的条件就是一条:
scrollEl.scrollHeight > scrollEl.clientHeight这个判断必须放在几个关键时机:组件挂载后、数据更新后、容器Resize后。因为这几个时机都可能影响滚动空间的变化。
我习惯把这个判断封装成一个函数,所有需要启动滚动的地方都先调它:
const canScroll = (el: HTMLElement | null): boolean => { return !!el && el.scrollHeight > el.clientHeight }组件里watch数据变化后,先等DOM渲染完,再判断能不能滚动;不能滚动就保持静止,能滚动才把scrollTop拉到0并启动动画。这个逻辑看似简单,实际踩坑不少,后面第5节会详细讲。
3. TS + Composition API 实现 useAutoScroll 控制器
3.1 Hook 的输入输出类型设计
组件要可复用,核心逻辑就应该抽成Hook。我设计了这样一个useAutoScroll:
import { onBeforeUnmount, ref, watch, type Ref } from 'vue' export interface UseAutoScrollOptions { target: () => HTMLElement | null speed?: number autoStart?: boolean disabled?: Ref<boolean> } export function useAutoScroll(options: UseAutoScrollOptions) { const { target, speed = 60, autoStart = true, disabled } = options const isActive = ref(false) const isPaused = ref(false) let rafId: number | null = null let lastTime = 0 let element: HTMLElement | null = null // ... 核心逻辑 }设计上有一个容易被忽略的点:target是一个函数而不是一个Ref。原因是滚动容器的DOM常常在组件挂载后才存在,如果传入Ref<HTMLElement | null>,value可能是null;传函数的话,每次取值都是动态获取最新结果,容错性更好。
返回的控制器提供四件事:isActive(是否正在滚动)、isPaused(是否处于暂停态)、以及start、stop、pause、resume、reset这几个方法。调用方不需要知道内部是requestAnimationFrame还是setInterval,这正是Hook封装的意义。
TypeScript方面这里有个小提醒:disabled参数设计成Ref<boolean>,而不是普通boolean,是为了让Hook在内部能响应式地watch它。这样组件里就可以直接传一个computed(() => props.data.length === 0),数据为空自动暂停,数据来了自动恢复,不用在外部额外写watch逻辑。
3.2 为什么计数器要选 requestAnimationFrame 而不是 setInterval
滚动驱动的核心是“每隔一段时间加一点scrollTop”,实现方式有两个:setInterval和requestAnimationFrame。我在初版用的是setInterval,后来发现两个明显问题。
第一个问题是掉帧和卡顿。setInterval的第二个参数最小间隔是4ms,但它不代表精确执行间隔,主线程忙的时候回调会被推迟,于是出现“一段时间没滚、突然滚一下”的现象,视觉上就是一卡一卡的。
第二个问题是浪费性能。setInterval在页面不可见时依然会执行回调。大屏项目通常常驻展示,如果切换到别的标签页或者缩到后台,setInterval还在空转,白白消耗CPU。
requestAnimationFrame完美规避这两个问题:浏览器会在下一次重绘前回调,滚动天然和帧率同步;页面切到后台,rAF自动暂停,回来才继续,不需要你额外处理“页面可见性”的逻辑。
使用rAF实现滚动核心逻辑:
const scrollTick = (timestamp: number) => { const el = target() if (!el) return if (lastTime === 0) { lastTime = timestamp rafId = requestAnimationFrame(scrollTick) return } const delta = (timestamp - lastTime) / 1000 if (delta > 0.1) { // 页面从后台切回来,delta会非常大,直接跳过这一帧,避免瞬间跳几百像素 lastTime = timestamp rafId = requestAnimationFrame(scrollTick) return } el.scrollTop += speed * delta lastTime = timestamp rafId = requestAnimationFrame(scrollTick) }这里delta > 0.1的判断是我实测后加上去的。浏览器切后台再回来,timestamp会突然跳变,如果不拦截,一次滚动就会跳几十甚至几百像素,用户体验很糟糕。超过100ms的间隔直接丢弃这一帧,从这一帧开始重新计算delta,滚动就平滑了。
3.3 暂停、恢复、重置、销毁的完整生命周期
自动滚动不是一个孤立的死循环,它必须能响应各种外部状态变化。完整的生命周期包括:
const start = () => { if (isActive.value || isPaused.value) return const el = target() if (!el || el.scrollHeight <= el.clientHeight) return isActive.value = true lastTime = 0 rafId = requestAnimationFrame(scrollTick) } const stop = () => { isActive.value = false if (rafId !== null) { cancelAnimationFrame(rafId) rafId = null } lastTime = 0 } const pause = () => { if (!isActive.value) return isPaused.value = true if (rafId !== null) { cancelAnimationFrame(rafId) rafId = null } } const resume = () => { isPaused.value = false if (!isActive.value) { start() } else { lastTime = 0 rafId = requestAnimationFrame(scrollTick) } } const reset = () => { const el = target() if (el) el.scrollTop = 0 resume() }再强调一下组件卸载时的清理:
onBeforeUnmount(stop)如果不取消rAF,组件销毁后回调依然在跑,target()取到的是null,虽然不会报错,但会一直空转,属于典型的内存泄漏。这个必须在Hook内部处理好,不能指望调用方记得清理。
watch(disabled)的逻辑也补上:
watch( () => disabled?.value, (val) => { if (val) { pause() } else { resume() } } )这样外部的组件层就非常省事了:数据为空自动停,数据来了自动滚。
4. AutoScrollTable 组件完整落地:三块代码直接抄
4.1 Props 和事件设计
Hook封装好之后,组件层就只是搭积木了。先定义Props:
<script setup lang="ts"> interface AutoScrollTableProps { data: Record<string, any>[] speed?: number autoStart?: boolean rowKey?: string loop?: boolean } withDefaults(defineProps<AutoScrollTableProps>(), { speed: 60, autoStart: true, rowKey: 'id', loop: true, }) </script>四个props的定位:
- data:表格数据源,必须传。
- speed:滚动速度,单位是像素/秒。默认60,大屏上观感比较舒服,属于慢速滚动。
- rowKey:透传给el-table的row-key,优化渲染性能,如果数据有唯一id一定传。
- loop:要不要无缝循环,默认true。原理是把数据复制一份拼在后面,滚动到底部后瞬间归零,因为内容相同,视觉上看不出跳变。
事件方面,el-table的row-click、row-dblclick这类事件应该原样透传出去,不要在组件里吞掉。因为loop: true时虽然DOM里渲染了两份数据,但displayData是用[...data, ...data]浅拷贝出来的,对象引用没变,所以事件回调里拿到的row就是原始数据对象,不需要额外做索引映射。
4.2 模板与脚本实现
模板部分:
<template> <div ref="wrapperRef" class="auto-scroll-table"> <el-table ref="tableRef" :data="displayData" :height="tableHeight" :row-key="rowKey" :border="border" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" v-bind="$attrs" > <slot /> </el-table> </div> </template>脚本部分:
<script setup lang="ts"> import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import type { TableInstance } from 'element-plus' import { useAutoScroll } from './useAutoScroll' interface AutoScrollTableProps { data: Record<string, any>[] speed?: number autoStart?: boolean rowKey?: string loop?: boolean border?: boolean } const props = withDefaults(defineProps<AutoScrollTableProps>(), { speed: 60, autoStart: true, rowKey: 'id', loop: true, border: false, }) const wrapperRef = ref<HTMLElement | null>(null) const tableRef = ref<TableInstance | null>(null) const tableHeight = ref(300) const displayData = computed(() => props.loop ? [...props.data, ...props.data] : props.data ) const getScrollWrap = (): HTMLElement | null => { const tableEl = tableRef.value?.$el return tableEl?.querySelector('.el-scrollbar__wrap') ?? null } const autoScroll = useAutoScroll({ target: getScrollWrap, speed: props.speed, autoStart: props.autoStart, }) let resizeObserver: ResizeObserver | null = null onMounted(async () => { if (wrapperRef.value) { tableHeight.value = wrapperRef.value.clientHeight resizeObserver = new ResizeObserver((entries) => { tableHeight.value = entries[0].contentRect.height }) resizeObserver.observe(wrapperRef.value) } await nextTick() autoScroll.start() }) onBeforeUnmount(() => { resizeObserver?.disconnect() }) watch( () => props.data, async () => { await nextTick() autoScroll.reset() } ) const handleMouseEnter = () => { autoScroll.pause() } const handleMouseLeave = () => { autoScroll.resume() } </script>这段代码有几点需要特别说明。
第一,useAutoScroll必须在setup顶层调用,不能在onMounted里面调,否则响应式上下文会丢失。但因为滚动容器的DOM要等挂载后才存在,所以autoStart: props.autoStart这种写法其实还是有点早了——挂载前getScrollWrap()返回null,Hook内部的start()会直接return。所以我在onMounted里又手动调了一次autoScroll.start(),确保DOM就绪后再真正启动。
第二,watch里监听props.data要小心。如果父组件原地修改了数组里的对象属性(而不是替换数组引用),watch默认检测不到。大屏项目刷新数据时,最好用新数组替换旧数组,例如list.value = [...newRows],这样watch才会触发。这个习惯要在使用者层面说清楚。
第三,displayData复制一份数据后,如果原始数据量是几百条,DOM渲染量会翻倍。el-table没有开启虚拟滚动时,上千行渲染性能会明显下降。我的经验是:几千行以上的数据就别用复制方案了,改成滚到底部后直接跳回顶部(把loop关掉),或者换虚拟表格方案。大屏轮播场景通常几十到几百条数据,复制方案足够。
4.3 SCSS 定制滚动条宽度与主题适配
滚动条的问题很值得单独讲。默认el-table的滚动条粗、样式丑,在大屏上非常碍眼。尤其是深色主题下,灰色滚动条贴在暗色背景里特别突兀。
我封装组件时把滚动条样式一并处理了,用SCSS写在组件里:
<style lang="scss"> .auto-scroll-table { width: 100%; height: 100%; .el-table { --el-table-text-color: var(--table-text-color, #1f2d3d); --el-table-header-text-color: var(--table-header-color, #1f2d3d); --el-table-border-color: var(--table-border-color, #ebeef5); background-color: transparent; } // 隐藏原生滚动条,保留滚动能力 // Element Plus的滚动容器内部有自己的滚动条,也可以给它瘦身 :deep(.el-scrollbar__wrap) { scrollbar-width: none; // Firefox -ms-overflow-style: none; // IE &::-webkit-scrollbar { width: 0; height: 0; } } // 如果不想彻底隐藏,可以只做宽度和颜色定制 :deep(.el-scrollbar__bar.is-vertical) { width: 4px; .el-scrollbar__thumb { background-color: rgba(144, 147, 153, 0.6); border-radius: 4px; } } } </style>这里有一个项目里真实遇到的情况:在大屏深色主题中,el-table的默认表头和表体背景都是白色,滚动起来非常突兀。我的处理方案是给主要颜色全部抽成CSS变量,外部主题统一覆盖:
.auto-scroll-table .el-table { --el-table-header-bg-color: transparent; --el-table-tr-bg-color: transparent; --el-table-row-hover-bg-color: rgba(255, 255, 255, 0.08); }另外,el-table默认的展开行、固定列都会额外产生滚动容器,如果你的表格用了固定列,auto-scroll-table组件内部的querySelector可能不会命中真正的滚动容器。这种情况建议在getScrollWrap里改成遍历所有.el-scrollbar__wrap,找到scrollHeight最大的那个再操作。我当前项目不用固定列,就没有做这层兜底,但你要留意。
5. 上线前踩过的坑和实际优化记录
5.1 数据量变化引发的滚动位置错乱
第一次联调时遇到的坑:数据刷新后,表格内容长度变了,但scrollTop还停留在旧位置。数据量从50条变成200条,scrollTop值没变,看起来滚动速度突然快了一截;数据量从200条变成10条,scrollTop甚至超出了新的滚动范围。
排查后的结论很简单:数据变更后必须重置scrollTop,再重新判断能不能滚动。所以组件里watch数据变化后调用autoScroll.reset(),reset内部先把scrollTop设为0,再resume。
这里有个细节:reset()里居然没有重新判断“能不能滚动”的逻辑。如果新数据只有三行,start内部会通过scrollHeight判断放弃启动,这没问题。但我实际遇到的情况是:数据从有到无,再从无到有,中间displayData为空导致滚动容器消失,等新数据渲染完,target()返回的滚动容器可能是一个全新的DOM节点。所以reset里面必须先在start前获取最新target,之前的rAF回调里如果还持有旧的el引用,就得处理干净。我在Hook里每次都是从target()动态取值,不缓存el,就绕开了这个问题。
5.2 悬停暂停的正确写法:别监听错了对象
这个坑很隐蔽。我初版的悬停暂停是监听在el-table内部的body区域上的,结果发现鼠标移到表头、滚动条、或者表格外的卡片空白处,滚动不恢复。
后来想明白了:应该监听在组件的最外层容器上,也就是.auto-scroll-table这个div。因为大屏场景下,用户鼠标只要进入卡片区域,基本就处于“阅读模式”,这时候应当暂停滚动;移出卡片再恢复,才符合交互直觉。
模板里我就是把@mouseenter和@mouseleave绑在外层div上的。这样还有一个好处:表格内部的hover事件不会和滚动逻辑互相干扰,不用去区分是hover在表头还是表体还是滚动条上。
如果你需要更细的交互——比如只在鼠标悬停在某个特定行时才暂停,那就需要自己监听行事件。但大多数自动轮播场景,组件级的鼠标进入/离开已经够用。
5.3 复制数据实现无缝循环的思考
loop: true的实现逻辑是:displayData = [...data, ...data],滚动容器总高度是原始数据的两倍。当滚动区域到达第一份数据末尾(即scrollTop等于原始数据的高度),视觉上正好是第二份数据接替。这时候把scrollTop瞬间设为0,因为第二份数据长得跟第一份一模一样,用户根本看不出跳变,就实现了无缝循环。
这个方法在大屏行业里用得很多,但它有个隐性成本:渲染量翻倍。如果原始数据1000行,DOM里渲染2000行,el-table本身又没有虚拟滚动,性能会吃紧。
我的建议是分档处理:
- 数据量≤500行,默认
loop: true,体验最好。 - 500到2000行,保守起见关掉loop,滚动到底部之后在顶部用CSS过渡或者直接跳回,虽然视觉上突兀一点,但性能优先。
- 2000行以上,不建议用el-table做自动轮播,应该考虑
el-table-v2虚拟滚动或者自己写渲染。
实际项目里驾驶舱的战报列表一般也就是几十到两三百条,所以这个分档足够覆盖绝大多数场景。
5.4 浏览器切后台回来的一瞬间跳帧
这是上线后真机测试发现的:页面切到别的标签页,过一分钟再切回来,列表直接往下跳了一大截,看起来非常突兀。
原因我在第3.2节已经提过:rAF切后台时暂停,切回来时callback的第一个timestamp参数据离上一帧已经过去很久,delta非常大,speed * delta算出来的滚动距离大得离谱。
解决办法就是delta大于100ms时直接丢弃这一帧,同时重置lastTime。这个阈值我调试过:100ms以内人眼基本无感,超过100ms即使不丢弃也会造成明显卡顿。按60Hz刷新率,正常帧间隔是16.7ms,所以100ms已经是很宽松的阈值了,不会误伤正常滚动。
还有一个相关的边界:页面用WebSocket推送数据,数据更新频率很高,每来一条就reset一次,会导致滚动经常从头开始。这种情况我给AutoScrollTable加了一个可选的resetOnDataChangeprop,默认true,但高频更新场景可以关掉,让滚动保持当前位置继续。这个在普通后台管理系统里用不到,但大屏项目里经常踩到,值得留一个开关。
最后再分享一个使用层面的技巧:把speed暴露到项目的配置里,建议做成后台可调的数值项。我第一次上线时不理解为什么业务方反复提“滚动太快/太慢”,后来才发现不同显示器、不同观看距离下,同样的60px/s速度体验差异很大。把速度做成配置项之后,这类反馈基本消失了,业务方自己调到位,再也没来麻烦过前端。这个组件的完整代码我已经整理进项目了,后面如果同事需要直接复制即可。核心逻辑不在代码量多少,而在于滚动容器找对、rAF的deltaTime处理对、生命周期清理做干净,这三点做到了,这个组件在绝大多数项目里都能稳定跑。