上周针对已经完成静态页面的大屏进行数据对接,数据对接完毕后,发现整个页面变得非常卡顿。针对性地排查一番,揪出了"元凶"------针对长列表展示的无限滚动功能,数据量暴增到8000多条,导致页面性能拉跨。
旧的写法
无线滚动列表的原本思路是通过纯 CSS(animation
)实现。具体做法是将要展示的数据复制两份,形成 [...list, ...list]
的结构。然后,将 animation
动画作用在包裹 [...list, ...list]
列表的容器上。当列表容器滚动至一半时,瞬间将动画切换回原点。
Vue
methods: {
init() {
const deptId = this.componyInfo.id;
getQydwInfo({ deptId }).then((res) => {
if (res.code == 200) {
console.log(res);
const data = res.data;
this.qyInfoList = [...data, ...data]; //获取到的数据
}
});
},
},
<style scoped lang="scss">
.table-content .table-body {
animation: scrolltop linear infinite;
}
.table-content .table-body:hover {
animation-play-state: paused;
}
@keyframes scrolltop {
0% {
transform: translateY(0%);
}
100% {
transform: translateY(-50%);
}
}
</style>
滚动列表必须创建两份。当列表滚动完自身高度的一半之后,瞬间切换至起点,从而起到"欺骗"视觉感官的效果。
方案一
页面卡顿本质上是由于 DOM 创建过多导致的,因此很自然地想到了使用虚拟滚动来优化。这里使用的是 vue-virtual-scroller
库。
使用虚拟滚动后,DOM 列表数量大幅减少,但列表仍需要能够滚动起来。通过操作滚动列表容器的 scroller.$el.scrollTop += 1.3
来实现滚动效果,并通过递归调用来持续滚动。在递归中,判断 scroller.$el.scrollTop >= this.totalSize / 2
,当满足条件时,将 scroller.$el.scrollTop = 0
重置为零,从而实现无缝滚动的效果。
需要注意的是,在屏幕隐藏或滚动组件卸载时,必须清除定时器,以避免不必要的性能开销和潜在的错误。
xml
<template>
<div class="table-content">
<div class="rankList" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<!-- <div class="table-body" ref="body"> -->
<RecycleScroller
class="table-body"
:items="qyInfoList"
:item-size="itemSize"
ref="scroller"
key-field="id"
>
<template v-slot="{ item }">
<div class="table-item">
<div class="name">
<i class="name-icon"></i>
<span class="name-val">{{ item.sydmc }}</span>
</div>
<span class="cphm">{{ item.cph }}</span>
<span class="zl">{{ item.ljzl + ' kg' }}</span>
<span class="time">{{ parseTime(item.jcsj, '{h}:{i}') }}</span>
</div>
</template>
</RecycleScroller>
<!-- </div> -->
</div>
</div>
</template>
<script>
import { getQydwInfo } from '@/api/phVisualCockpit/rightAside';
export default {
data() {
return {
qyInfoList: [], //清运信息列表
itemSize: 32, //每个列表项的高度
infinityTimer: null, //无线滚动的定时器
lockInfinity: false, //是否锁定无限滚动
};
},
computed: {
qyInfoLength() {
return this.qyInfoList.length;
},
totalSize() {
return this.qyInfoLength * this.itemSize; // 总高度 = 列表项数量 × 每个列表项的高度
},
},
mounted() {
this.init();
this.lockInfinity = true;
},
beforeDestroy() {
this.lockInfinity = false;
this.closeInfinityYScroll();
},
inject: ['componyInfo'],
methods: {
init() {
const deptId = this.componyInfo.id;
getQydwInfo({ deptId }).then(async (res) => {
if (res.code == 200) {
console.log(res);
const data = res.data;
this.qyInfoList = [...data, ...data];
// this.initAnimation();
await this.$nextTick();
this.InfinityYScroll(); //开启无限滚动
}
});
// this.initAnimation();
},
initAnimation() {
let body = this.$refs.body;
body.style.animationDuration = this.qyInfoLength + 's';
},
// 无限滚动
InfinityYScroll() {
const scroller = this.$refs.scroller;
scroller.$el.scrollTop += 1.3; //HACK 小于1.3滚动条居然不动,这里需要注意
if (scroller.$el.scrollTop >= this.totalSize / 2) {
scroller.$el.scrollTop = 0;
}
// 避免定时器回调没有取消setTimeout,导致递归没有被取消
if (this.lockInfinity) {
this.infinityTimer = setTimeout(this.InfinityYScroll, 18);
}
},
closeInfinityYScroll() {
clearTimeout(this.infinityTimer);
},
// 处理鼠标移入事件
handleMouseEnter() {
this.closeInfinityYScroll();
},
// 处理鼠标移出事件
handleMouseLeave() {
this.InfinityYScroll();
},
},
};
</script>
方案二
原本旧的方案使用 CSS 动画实现无限滚动,需要创建两份数据,用于在切换的刹那间"欺骗视觉"感官。然而,我的列表数据量本身就很大,为了实现这个动画,列表数据反而"增加了一倍",给内存造成了更大的压力。因此,使其只显示部分数据,并根据滚动距离或时间动态更新显示的列表内容。
在本文中,采用的是通过距离判断的方法,具体是通过 Math.floor(trsY / listItem.offsetHeight)
来计算滚动部分的长度,从而判断需要更新的元素。不过,我使用 window.getComputedStyle
来获取滚动的距离,这会导致"重绘和重排",对性能有一定影响。我更推荐通过计算时间的方式来更新元素。
ini
import React, { useState, useRef, useEffect } from "react";
export default function InfiniteScroll() {
const MAX_CONTAINER_COUNT = 20; //容器最多存储的元素数量
const [list, setList] = useState(
new Array(1000).fill(0).map((_, i) => i + 1)
);
const [renderList, setRenderList] = useState(
list.slice(0, MAX_CONTAINER_COUNT)
); //设置元素渲染的数量
const [updateIndex, setUpdateIndex] = useState(MAX_CONTAINER_COUNT); //负责从数据源头获取索引
const scroller = useRef<HTMLDivElement>(null);
useEffect(() => {
let duration = (MAX_CONTAINER_COUNT / 2) * 1000;
startInfiniteScroll(scroller.current!, duration);
requestAnimationFrame(() => updateRenderList(updateIndex,renderList));
}, []);
// 开启无限滚动
const startInfiniteScroll = (
elm: HTMLDivElement,
speed: number
): Animation | null => {
// 开始动画
// 记录间隔时间
let animation = elm.animate(
[{ transform: "translateY(-0%)" }, { transform: "translateY(-50%)" }],
{
duration: speed, // ms
easing: "linear", // 使用线性缓动函数
iterations: Infinity, // 动画无限循环
}
);
return animation;
};
const updateRenderList = (index: number,renderList:number[]) => {
let trsY = Math.abs(getTranslateY(scroller.current!));
let newIndex = Math.floor(trsY / 20);
if (newIndex !== index % MAX_CONTAINER_COUNT) {
let tepRenderList = [...renderList];
tepRenderList[index % MAX_CONTAINER_COUNT] = list[index];
setRenderList(tepRenderList); //修改状态
setUpdateIndex(index + 1); //修改状态
setTimeout(() => updateRenderList(index + 1,tepRenderList), 18);
}else{
setTimeout(() => updateRenderList(index,renderList), 18);
}
};
return (
<div
style={{
height: "200px",
width: "200px",
backgroundColor: "#bfa",
textAlign: "center",
overflow: "hidden",
}}
>
<div ref={scroller} className="list-Wrap">
{renderList.map((i) => {
return (
<div style={{ height: "20px", textAlign: "center" }} key={i}>
{i}
</div>
);
})}
{renderList.map((i) => {
return (
<div style={{ height: "20px", textAlign: "center" }} key={`${i}_`}>
{i}
</div>
);
})}
</div>
</div>
);
}
function getTranslateY(element: HTMLElement): number {
const style = window.getComputedStyle(element);
const transform = style.transform || style.webkitTransform;
if (!transform || transform === "none") {
return 0;
}
// 解析 transform 矩阵,获取 translateY 值
const matrix = transform.match(/matrix((.+))/);
if (matrix) {
const values = matrix[1].split(", ");
return parseFloat(values[5] || "0");
}
return 0;
}
总结
- 动态更新滚动列表,相对于虚拟滚动更加简洁。不过,虚拟滚动方案支持处理不定高列表,动态更新滚动列表则不支持。