大屏"跑马灯" 长列表性能优化

上周针对已经完成静态页面的大屏进行数据对接,数据对接完毕后,发现整个页面变得非常卡顿。针对性地排查一番,揪出了"元凶"------针对长列表展示的无限滚动功能,数据量暴增到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;
}

总结

  1. 动态更新滚动列表,相对于虚拟滚动更加简洁。不过,虚拟滚动方案支持处理不定高列表,动态更新滚动列表则不支持。
相关推荐
il几秒前
Deepdive into Tanstack Query - 2.0 Query Core 概览
前端·javascript
Shawn5902 分钟前
前端时间管理实践:从时间标准化到工程化封装
前端·javascript
H5开发新纪元3 分钟前
借助 GitHub Copilot 打造一个完美的 React 消息引用系统:从设计到实现的深度剖析
前端·react.js
前端没钱3 分钟前
Electron从入门到入门
前端
zhoouAAA3 分钟前
使用css变量实现前端无刷新切换主题
前端
H5开发新纪元4 分钟前
Vue 项目中 Loading 状态管理及页面切换 Bug 分析
前端·vue.js
哟哟耶耶4 分钟前
react-09React生命周期
前端·javascript·react.js
只可远观6 分钟前
Flutter Dart 循环语句 for while do..while break、continue
开发语言·javascript·ecmascript
亚洲小炫风13 分钟前
flutter 中各种日志
前端·flutter·日志·log
Guheyunyi18 分钟前
智能照明系统:照亮智慧生活的多重价值
大数据·前端·人工智能·物联网·信息可视化·生活