
做监控大屏那阵子接了一个需求页面右侧要放一个实时告警列表数据从 WebSocket 推过来每隔几秒就加一条页面还不能卡。团队第一反应是用 setInterval 配合 scrollTop 去滚动试了两天发现掉帧、不平滑列表一更新就闪。后来我把 vue2 项目里这个 el-table 列表改成纯 CSS 方式的自动滚动利用 transform 动画驱动整个表格容器无缝循环几十行代码解决问题。这篇文章就把这套 vue2 el-table CSS 列表自动滚动的完整思路、代码、参数计算和踩坑记录都整理出来给正在做看板、数据监控、排队叫号界面的朋友参考。先说结论最终效果非常顺CPU 占用也低JS 里只需要维护数据本身不用做任何滚动相关逻辑。如果你现在也在用 el-table 做滚动列表并且被 JS 定时器方案的卡顿和闪烁折磨过可以试试这个思路。1. 为什么优先选 CSS 而不是 JS 定时器1.1 需求场景拆解这类自动滚动列表最常见的应用场景是数据大屏、监控告警、订单流水和排队叫号。界面要求就三条列表自动向上滚、不停顿、看起来没尽头。用户不会去操作滚动条滚动是“背景氛围”的一部分真正有价值的是列表里的实时数据。这个场景有个容易被忽略的点列表数据往往是高频变化的。如果滚动逻辑依赖 JS 定时器去修改 scrollTop那么每次数据更新触发 Vue 重新渲染时滚动位置非常容易被重置视觉上就是“闪一下然后从头开始”。另外频繁操作 scrollTop 会强制浏览器重新计算布局在大屏这种一块区域塞了十几个动画的页面上尤其明显掉帧几乎是必然的。CSS 动画不一样。它跑在浏览器的合成线程上动画过程不经过 JavaScript也不需要在每一帧去改 DOM 属性。对前端来说就是把“滚动”这件事丢给浏览器自己专心处理数据更新。1.2 CSS 动画和 scrollTop 定时器方案对比我自己在项目里做过一个简单的对比这里直接列成表格都是同一台机器同一份数据的实测结果。方案实现方式流畅度代码量数据更新影响JS 定时器 scrollTopsetInterval 里改 DOM 滚动距离低容易掉帧少但逻辑绕更新数据时滚动位置闪跳第三方滚动库better-scroll 等插件中高需要适配 el-table 的 DOM 结构依赖插件刷新方法CSS transform 动画双份表格 keyframes 平移高合成器线程执行只需 CSS 和两行模板数据更新不影响动画位置从上往下看CSS 方案不管是流畅度还是稳定性都明显更合适。而且它不用依赖额外插件对大屏这种需要严格控制包体积的项目很友好。1.3 为什么用 translateY 而不是 top、margin-top很多人写 CSS 动画喜欢用top: -500px或margin-top: -500px但这俩属性改变时会触发浏览器重排和重绘。重排是要计算布局的消息量一大帧率立刻掉下来。transform: translateY()走的是合成器不会改变元素在文档流里的位置不会影响别的兄弟节点浏览器只需要把已经画好的图层移动一下。可以这样理解top/margin是让元素“真实地搬家”搬家要重新规划家具摆放transform是“给元素套上一块透明板子然后平移板子”家具不用动。实战中我还加了will-change: transform让浏览器提前知道这块内容会经常做变换单独开一个合成层。细节不大但对长列表的平滑度很有帮助。2. 无缝滚动的设计原理双份数据循环2.1 两份内容组成一个无限循环CSS 动画本身不会“无限循环内容”它只能把元素从 A 点平移到 B 点再重置。要做到视觉上永远滚动最经典的办法就是放两份一样的内容然后让动画只在 0 和 50% 之间来回。打个比方这就跟机场的传送带一样你站在传送带中间看到的行李永远是连续过来的因为传送带本身是闭合的。我们的列表也要做成一个“闭合的传送带”第一份列表的结尾后面紧跟着第二份列表的开头而第二份列表的内容和第一份完全一样。动画结束时第二份列表正好替代第一份列表的位置用户察觉不到任何跳动然后下一轮动画就顺理成章地继续。用公式表达就是外层滚动容器的高度固定为 H内层两份表格的总高度为 2H动画从translateY(0)到translateY(-50%)。这里的 -50% 不是视口的一半而是内层容器总高度的一半也就是一份表格的高度。因为两份表格完全相同滚动到 50% 时第二份表格出现在第一份表格原来的位置视觉上无缝衔接。2.2 内层结构怎么摆在 vue2 的模板里最朴素的做法就是写两个 el-table内容和列配置完全一样然后套在一个外层容器里。template div classscroll-box div classscroll-box__track el-table classauto-table :datalist :show-headerfalse sizesmall :row-style{ height: 40px } el-table-column proprank label排名 width60 / el-table-column propname label名称 min-width120 / el-table-column propscore label数值 width90 / /el-table el-table classauto-table auto-table--clone :datalist :show-headerfalse sizesmall :row-style{ height: 40px } el-table-column proprank label排名 width60 / el-table-column propname label名称 min-width120 / el-table-column propscore label数值 width90 / /el-table /div /div /template这个模板有两个细节第一个是:show-headerfalse因为两块表格都有表头的话滚动时会看到两个表头交替出现非常奇怪。推荐隐藏表头如果需要表头就单独放在外层固定住后面我会专门讲。第二个是两份表格的row-style必须一致保证每一份表格的高度完全相等这样 -50% 才能正好切在一份表格的边界上。如果两份表格高度不等滚动到一半的位置就会错位。常见原因是第二个表格在 CSS 里被加上了多余的 margin 或 padding两个.el-table之间出现了间隙。处理办法是明确清除.scroll-box__track .el-table { margin: 0 !important; }2.3 固定表头的最简单做法刚才隐藏表头是为了滚动连贯但很多业务确实需要表头。我用的做法是写一个假表头放在滚动区域外面跟滚动区域垂直对齐。template div classboard-table div classboard-table__head span classcol-rank排名/span span classcol-name名称/span span classcol-score数值/span /div div classscroll-box div classscroll-box__track !-- 两份 el-table列配置和表头宽度保持一致 -- /div /div /div /template假表头用 CSS 的 flex 布局每一列的宽度跟 el-table 的列宽保持一致。可能有人觉得这样维护麻烦但实际一行数据就是一列文字大屏列表根本不需要多复杂的表头最多加个排序图标。要是列特别多还可以直接用 CSS 的 grid 布局把表头和表格 body 的列宽统一成相同的grid-template-columns这样宽度永远同步不会错位。3. vue2 el-table 的完整实现步骤3.1 第一步固定外层高度并裁剪内容滚动列表的外层必须是一个固定高度的容器否则内层内容多高它就会撑多高根本看不到滚动效果。.scroll-box { width: 400px; height: 360px; overflow: hidden; border: 1px solid #dcdfe6; border-radius: 4px; background: #fff; }这里overflow: hidden是关键。内层两份表格即使高度超过 360px也不会撑破容器只有容器内的可视区域能展示内容。那具体要多少行数据才能铺满 360px 呢如果每行固定 40px至少需要 9 行。实际我一般会留一倍余量至少要 18 行左右这样单份表格高度远超容器高度滚动过程不会出现空白区域。3.2 第二步内层轨道铺设和动画定义内层轨道是真正发生动画的元素它包含两份表格。.scroll-box__track { display: flex; flex-direction: column; animation: listScroll 12s linear infinite; will-change: transform; } keyframes listScroll { 0% { transform: translateY(0); } 100% { transform: translateY(-50%); } }display: flex配合flex-direction: column是让两份表格在垂直方向紧密排列不会因为 inline 间隙或者 flex 默认换行产生空隙。flex-shrink: 0一定要给到表格上否则弹性布局在高度不足时可能压缩表格内容.scroll-box__track .el-table { flex-shrink: 0; margin: 0 !important; }动画时长12s是我根据单份表格高度算出来的不是随便拍脑袋。假设单份表格高度为 480px希望每秒滚动 40px那么duration 480 / 40 12s。速度太慢会显得死气沉沉太快则看不清楚内容大屏场景通常 30~50px/s 比较舒服。3.3 第三步处理 el-table 自带的样式el-table 默认有比较多的自带样式包括单元格边框、表格底部的伪元素、固定的单元格 padding。对于滚动列表我是这样处理的.auto-table { width: 100%; } .auto-table::before, .auto-table::after { display: none; } .auto-table .el-table__cell { padding: 6px 12px; }::before和::after是 el-table 用来画底部边框和圆角遮罩的伪元素在双表格无缝滚动场景里必须去掉不然两份表格交界处会出现一条莫名其妙的横线。问题来了这些都是 el-table 内部节点如果博文的项目里用的是style scoped直接写.auto-table .el-table__cell可能穿透不进去。vue2 的标准做法是用/deep/或::v-deep。但这里有个高频坑就是::v-deep在 vue2 里偶尔不生效甚至编译报错。原因后面专门聊先说最稳的写法.scroll-box /deep/ .auto-table .el-table__cell { padding: 6px 12px; }如果不喜欢/deep/也可以把上面的全局样式代码放在不带scoped的style标签里用类名前缀手动隔离比如统一叫.auto-scroll-list-xxx。这种方法从源头避开穿透问题适合团队里有人对 deep 选择器过敏的情况。3.4 第四步动态数据时的行高对齐很多 el-table 组件在数据更新后单元格内容长短不一可能造成行高不统一。CSS 方案对行高非常敏感因为无缝循环的切割点必须精确。我通常在表格上直接固定行高而不是依赖内容撑开高度。两个方式二选一即可在模板里给row-style设置固定 height。在 CSS 里通过设置单元格 line-height 和 padding 来固定行高。推荐第二种因为row-style是内联样式每次数据更新都会重新绑定。CSS 方式更稳定.scroll-box /deep/ .auto-table .el-table__cell { height: 40px; line-height: 28px; padding: 6px 12px; }单元格高度 padding(66) line-height(28) 40px。列内容一旦超长可以用ellipsis省略千万不能让内容换行。换行后行高变化无缝循环的切割点就不准了。4. 高频踩坑与排查速查表4.1 滚动不丝滑肉眼可见一顿一顿可能原因有三。第一个动画元素上没有加will-change: transform导致浏览器在每一帧都在判断要不要重新合成第二个表格行里的单元格内容太复杂比如里面有图片、有大面积阴影的 hover 效果拖慢了合成层的渲染第三个动画过程中触发了 el-table 的 resize 逻辑。我的经验是给滚动轨道加上will-change: transform并且把表格里的图片统一换成图标或纯色块。如果还卡检查 el-table 的width是否设了 100%同时列的总宽度不要超过容器宽度否则会触发横向滚动条和竖向滚动条叠加渲染复杂度直接翻倍。4.2 动画结束后明显跳了一下这个问题最让人头疼十有八九是两份表格高度不一致。排查步骤非常机械但有效打开浏览器开发者工具选中第一个.el-table看它的高度。再选中第二个.el-table看高度。两个高度不相等就去看第二个表格里单元格 padding、边框、字体是否被全局样式覆盖了。只要两个表格高度完全一致translateY(-50%)就一定落在边界上不会跳。另外一个隐蔽原因是两个表格之间的 margin 没有清干净哪怕只有 1px循环点也会错开 1px视觉上就是每滚一轮轻抖一下。4.3 数据更新后动画卡住或者位置错乱我在项目中遇到过一个问题实时数据推送很快Vue 把list更新后两份表格的内容同时变化但动画没有重新计算新的高度和切割点。比如原来单份表格高 480px动画切点应该还在 480px数据突然变成 20 行单份表格高度变成 800px动画切点还是 480px那第二份表格就会错位。解决方案是监听表格数据长度变化在数据进入表格后强制重启动画forceRestart() { const track this.$refs.track; track.style.animation none; void track.offsetWidth; track.style.animation ; }void track.offsetWidth这个操作是为了强制浏览器重绘让 animation 属性重新生效。这个方法在 Vue 的$nextTick回调里调一次watch: { list: { handler() { this.$nextTick(() { this.forceRestart(); }); }, deep: true } }4.4 el-table 自带滚动条造成宽度偏移el-table 在列总宽度超过容器宽度时会自动给表格内部加一个横向滚动条纵向内容过长时也会出现竖向滚动条。滚动条会占掉一部分容器宽度导致表头和假表头对不齐甚至两份表格宽度看起来差了十几像素。最直接的办法是让列配置刚好适配容器宽度不触发内部滚动条。如果实在无法避免就用 CSS 把滚动条做成透明隐藏.scroll-box::-webkit-scrollbar { width: 0; height: 0; }但要注意隐藏滚动条后用户无法拖动也没法滚到横向溢出内容。所以在做看板表格时我通常会把 el-table 每一列的宽度都严格控制好宁可少放一列也不让它横向撑开。4.5 ::v-deep 在 vue2 中不生效的兼容方案::v-deep是 Vue 3 风格的选择器写法vue2 项目虽然很多版本能识别但遇到 vue-loader 版本偏低、sass 编译模式不同的时候就是不生效。我见过最典型的情况是.scroll-box ::v-deep .el-table编译出来后变成了.scroll-box [v-data-xxx] .el-table但实际匹配不到。三个替代方案按推荐优先级排序方案一使用/deep/我实测在大部分 vue2 项目里都能穿透。方案二使用原生 CSS适合没装 Sass 的纯 CSS 项目。方案三不用 scoped 样式单独维护一个非 scoped 的style块通过固定前缀避免污染。如果是新建的 vue2 项目我建议直接选方案三省心。因为大屏项目基本是单页、独占式的全局样式污染问题没那么严重。下面给一份常见问题速查表方便你遇到问题直接对应。现象原因解决办法滚动一顿一顿动画元素缺少合成层加will-change: transform每轮循环跳一下两份表格高度不一致严格对比两个表格高度清除 margin表头、列宽对不齐el-table 内部横向滚动条占位合理配置列宽隐藏滚动条数据更新后错位动画未重新计算高度数据变化后强制重启动画样式穿透无效vue2 中 ::v-deep 兼容问题改用/deep/或非 scoped 样式列表少于容器高度单份内容小于滚动区域禁止动画或补充空数据行5. 进阶玩法悬停暂停、表头固定与性能优化5.1 鼠标悬停时暂停滚动用户可能想把鼠标移上去仔细看某一行数据尤其监控告警里经常有超长信息。暂停滚动其实非常轻松利用 CSS 的animation-play-state就能实现.scroll-box:hover .scroll-box__track { animation-play-state: paused; }鼠标移出后自动恢复。这个交互在 HTML 层面是零成本不需要监听鼠标事件。如果需求是在点击某一行后暂停才需要额外写 JS给轨道动态添加一个 class。5.2 通过 CSS 变量动态调整滚动速度同样是 12 秒的动画不同数据量下观感完全不同。数据多的时候希望滚快一点数据少的时候希望滚慢一点。我习惯把动画时长绑定成 CSS 变量在 Vue 里动态设置。div classscroll-box__track :style{ animationDuration: scrollDuration s } /divcomputed: { scrollDuration() { const speed 40; const height this.list.length * 40; return Math.max(height / speed, 6); } }这样每次数据量变化时不需要改动 CSS只更新scrollDuration就可以。注意如果数据量从 10 行变成 100 行单纯改 duration 还不够最好配合前面说的forceRestart一起用。5.3 表头固定且真正无缝的方案前面假表头的方案能解决 90% 的场景但如果你必须用 el-table 的排序功能或者列非常多假表头维护起来太痛苦那就要动 el-table 内部 DOM。思路是只滚动.el-table__body-wrapper内部的表格体不动表头。实现方式是在 mounted 之后克隆第一个表格主体 table插到 body-wrapper 末尾。this.$nextTick(() { const wrapper this.$refs.table.$el.querySelector(.el-table__body-wrapper); const table wrapper.querySelector(table); const clone table.cloneNode(true); clone.setAttribute(aria-hidden, true); wrapper.appendChild(clone); wrapper.classList.add(auto-scroll-body); });然后给 body-wrapper 里的两个 table 做横向平铺再让整个 wrapper 走 CSS 动画。这个方案对 el-table 内部结构的要求比较高只适合熟练工新手不推荐一上来就搞很容易因为 el-table 的版本差异翻车。5.4 性能优化限制数据量而非无脑滚动CSS 动画本身非常省资源但 el-table 的 DOM 结构比较重一张表格几十行渲染出来DOM 节点数量已经很可观何况我们还渲染两份。所以真正需要优化的不是动画而是数据量。我做监控看板时后端推过来的告警数据如果超过 50 条前端会截断只保留前 30 条而不是无限堆积。因为再看板场景用户要的是“当前重要的”数据不是“历史上所有”数据。另外单元格里的内容尽量用纯文本。一旦加图片、操作按钮、进度条每一帧合成层的重绘开销都会翻倍掉帧概率极高。想放图表就改成 svg 或者 CSS 图标不要用大图。5.5 空数据和少量数据的兜底处理最少显示多少行才适合自动滚动根据容器高度来定。如果 360px 高的容器每行 40px少于 9 行就无法铺满滚动到一半会露出空地。这种情况建议直接禁止动画静默展示别硬滚。div classscroll-box__track :class{ is-scrolling: list.length minRows } /div.scroll-box__track.is-scrolling { animation: listScroll 12s linear infinite; }没有动画时列表正常显示用户也可以自己手动阅读。逻辑简洁界面也不尴尬。这几次踩坑之后我个人对“列表自动滚动”这类需求的心态也变了。CSS 方案不是银弹但它是这个场景下性价比最高的一种做法不依赖定时器不依赖 DOM 滚动位置只靠一份双列表结构和一段关键帧动画。如果你正好在改 vue2 老项目的滚动表格建议先把两份表格配平、确认容器高度再动动画参数。步骤不多但每一步都值得认真测一遍。后续如果项目里要加“滚动到指定行”或者“点击暂停定位”可以直接在这个 CSS 方案基础上叠加少量 JS不用推翻重来。