ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3+el-table自动滚动组件封装:scrollTop驱动的无缝轮播方案

Vue3+el-table自动滚动组件封装:scrollTop驱动的无缝轮播方案 接手一个领导驾驶舱项目时遇到一个很常见的需求战报列表要自动向上滚动数据一屏放不下就轮播。技术栈是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 refTableInstance | 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 refHTMLElement | 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。我设计了这样一个useAutoScrollimport { onBeforeUnmount, ref, watch, type Ref } from vue export interface UseAutoScrollOptions { target: () HTMLElement | null speed?: number autoStart?: boolean disabled?: Refboolean } 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常常在组件挂载后才存在如果传入RefHTMLElement | nullvalue可能是null传函数的话每次取值都是动态获取最新结果容错性更好。返回的控制器提供四件事isActive是否正在滚动、isPaused是否处于暂停态、以及start、stop、pause、resume、reset这几个方法。调用方不需要知道内部是requestAnimationFrame还是setInterval这正是Hook封装的意义。TypeScript方面这里有个小提醒disabled参数设计成Refboolean而不是普通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封装好之后组件层就只是搭积木了。先定义Propsscript setup langts interface AutoScrollTableProps { data: Recordstring, any[] speed?: number autoStart?: boolean rowKey?: string loop?: boolean } withDefaults(definePropsAutoScrollTableProps(), { 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 refwrapperRef classauto-scroll-table el-table reftableRef :datadisplayData :heighttableHeight :row-keyrowKey :borderborder mouseenterhandleMouseEnter mouseleavehandleMouseLeave v-bind$attrs slot / /el-table /div /template脚本部分script setup langts import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from vue import type { TableInstance } from element-plus import { useAutoScroll } from ./useAutoScroll interface AutoScrollTableProps { data: Recordstring, any[] speed?: number autoStart?: boolean rowKey?: string loop?: boolean border?: boolean } const props withDefaults(definePropsAutoScrollTableProps(), { speed: 60, autoStart: true, rowKey: id, loop: true, border: false, }) const wrapperRef refHTMLElement | null(null) const tableRef refTableInstance | 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()返回nullHook内部的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 langscss .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处理对、生命周期清理做干净这三点做到了这个组件在绝大多数项目里都能稳定跑。
返回列表