长数据列表虚拟滚动

长数据列表虚拟滚动

  • 光谱数据包含大量列表项,纯渲染的话,对于浏览器性能将会是个极大的挑战,会造成滚动卡顿,整体体验非常不好,主要有以下问题:
  • 页面等待时间极长,用户体验差
  • CPU计算能力不够,滑动会卡顿
  • GPU渲染能力不够,页面会跳屏
  • RAM内存容量不够,浏览器崩溃
  • 传统的懒加载方法,随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大 核心思想

在处理用户滚动时,只改变列表在可视区域的渲染部分
由于光谱数据列表不包含图片之类的可变内容,故采用定高虚拟列表实现海量数据在同一页面中的高效渲染

使用express + Mock.js模拟光谱数据列表API接口

  • 创建mymock本地服务器
js 复制代码
// 使用mock构建本地服务器输出数据结果
const express = require('express');
const Mock = require("mockjs")
const app = express()

// 根据输入的参数num生成num条模拟的数据列表
function generatorList(num){
  return Mock.mock({
    [`list|${num}`]: [
      {
        // 属性 id 是一个自增数,起始值为 1,每次增 1
        "id|+1": 1,
        // 模拟标题,中文字符串长度为15到25
        title: "@ctitle(15,25)",
        // 模拟图片索引,自然数从0到15
        image: "@natural(0,15)",
        reads: "@natural(0,99999)",
        from: "@ctitle(3,10)",
        date: "@date('yyyy-MM-dd')",
      },
    ],
  });
}
// 允许跨域请求返回数据
app.all('*',function(req,res,next){
  res.header("Access-Control-Allow-Origin","*")
  res.header("Access-Control-Allow-Methods", "PUT,GET,POST,DELETE.OPTIONS");
  res.header("Access-Control-Allow-Headers", "X-Ruquest-With");
  res.header("Access-Control-Allow-Headers", "Content-Type");
  next()
})

app.get('/data',function(req,res){
  const {num } = req.query
  return res.send(generatorList(num))
})

const server = app.listen(4000,()=>{
  console.log('本地mock服务启动,接口地址为:http://localhost:4000/data?num=请求列表的数量');
})
  • 获取到模拟的数据

搭建基本架构

js 复制代码
<template>
  <el-scrollbar height="800px">
    <el-card>
      <el-table :data="tableList" border style="width: 100%">
        <!-- 索引 -->
        <el-table-column prop="id" label="id"> </el-table-column>
        <el-table-column prop="title" label="title"> </el-table-column>
        <el-table-column prop="image" label="image"> </el-table-column>
        <el-table-column prop="reads" label="reads"> </el-table-column>
        <el-table-column prop="from" label="from"> </el-table-column>
        <el-table-column prop="date" label="date"> </el-table-column>
      </el-table>
    </el-card>
  </el-scrollbar>
</template>

<script setup>
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { ref } from 'vue'
const tableList = ref([])
const num = ref(20)
const isRequestStatus = ref(true)
const getMockData = (num) => {
  isRequestStatus.value = true
  // ElMessage('正在请求中。。。')
  axios
    .get('http://localhost:4000/data?num=' + num.value)
    .then((res) => {
      console.log(res.data.list)
      tableList.value = res.data.list
      isRequestStatus.value = false
      // ElMessage('请求结束')
    })
    .catch((err) => {
      window.console.log(err)
      ElMessage('网络出错')
    })
}
getMockData(num)
</script>

计算容器最大容积数量

  • 简单来说,就是我们必须要知道在可视区域内最多能够容纳多少个列表项,这是我们在截取内容数据渲染到页面之前关键的步骤之一
  • 获取滚动区域的高度值srollHeight(800px-20)
  • 获取单条列表的数据高度oneHeight(48px)
  • 获取可渲染的列表个数: ~~(srollHeight/oneHeight):~~代替Math.floor
js 复制代码
// 获取可渲染的列表项数:计算容器的最大容积
// 表格可视区的高度
const srollHeight = ref(800)
// 单个列表项的高度
const oneHeight = ref(48)
const cotainSize = ref(0)
const getContainSize = () => {
  cotainSize.value = ~~(srollHeight.value / oneHeight.value)
}

监听滚动事件动态截取数据

  • 设置滚动条滚动事件handelScroll
js 复制代码
 <el-scrollbar ref="scrollbarRef" height="800px" @scroll="handelScroll">
  • 计算当前滚动的第一个列表元素的索引
JavaScript 复制代码
const startIndex = ref(0)
const handelScroll = ({ scrollTop }) => {
  startIndex.value = ~~(scrollTop / oneHeight.value)
}
  • 通过计算属性获取列表最后一个元素的索引,以及动态显示的数组列表
js 复制代码
// 通过计算属性获取列表最后一个元素的索引
const getEndIndex = computed(() => {
  let endIndex = startIndex.value + containSize.value
  if (!tableList.value[endIndex]) {
    endIndex = tableList.value.length - 1
  }
  return endIndex
})
// 定义可以动态显示的数组列表元素
const showDataList = computed(() => {
  console.log(startIndex.value, getEndIndex.value)
  return tableList.value.slice(startIndex.value, getEndIndex.value)
})
  • 更改渲染的数据
js 复制代码
<el-table :data="showDataList" border style="width: 100%">
  • 此时重新刷新页面,可以从元素布局中得知初始渲染只渲染了16条而非20条(全部数据)

动态设置上下空白占位

思考:我们设置了根据容器滚动位移动态截取ShowDataList数据,现在我们滚动一下发现滚动2条列表数据后,就无法滚动了,这个原因是什么呢?
问题在容器滚动过程中,因为动态移除、添加数据节点丢失,进而强制清除了顶部列表元素DOM节点,导致滚动条定位向上移位一个列表元素高度,进而出现了死循环

解决方法

  • 根据startIndexendIndex的位置,使用计算属性,动态的计算并设置上下空白填充的高度样式blankFillStyle ,使用padding或者margin进行空白占位都是可以的
  • 定义上空白的高度以及下空白填充的高度
js 复制代码
// 定义上空白的高度以及下空白填充的高度
const topBlankFill = computed(() => startIndex.value * oneHeight.value)
const bottomBlankFill = computed(() => (tableList.value.length - getEndIndex.value) * oneHeight.value)
  • 设置空白区域,通过一个DIV盒子包裹住列表区域,设置好padding样式,此时可实现基础的虚拟列表效果,滑动闪动问题消失
  • 简化写法,使用一个计算属性定义空白填充的样式
js 复制代码
const blankFillStyle = computed(() => {
  return {
    paddingTop: startIndex.value * oneHeight.value + 'px',
    paddingBottom:
      (tableList.value.length - getEndIndex.value) * oneHeight.value + 'px'
  }
})

[优化]下拉置地自动请求加载数据

  • 首先判断滚动条触底的条件,触底后追加请求新的数据
js 复制代码
const handelScroll = ({ scrollTop }) => {
  startIndex.value = ~~(scrollTop / oneHeight.value)
  if (startIndex.value + containSize.value > tableList.value.length - 1) {
    console.log('滚动条到底了')
    //追加请求新的数据
  }
}
  • 改写getMockData方法为getNewList,原方法会直接覆盖掉初始请求数据,需要改写成异步函数
js 复制代码
const getNewList = (num) => {
    ...
  return axios
    ...
    .then((res) => {
      return res.data.list
    })
    ...
}

// 初始时异步获取列表数据
const initTableList = async (num) => {
  const newList = await getNewList(num)
  if (!newList) return
  tableList.value = newList
}
onMounted(() => {
  initTableList(num)
  getContainSize()
})
  • 同理,追加数据也为异步【追加数据失败 原因待找】
js 复制代码
const handelScroll = async ({ scrollTop }) => {
  startIndex.value = ~~(scrollTop / oneHeight.value)
  if (startIndex.value + containSize.value > tableList.value.length - 1) {
    // console.log('滚动条到底了')
    // 追加请求新的数据
    const addList = await getNewList(20)
    if (!addList) return
    tableList.value = [...tableList.value, ...addList]
  }
}

此处将固定请求数进行更改即可追加数据 const addList = await getNewList(num)

  • 出现问题,数据还没请求过来,需要判断滚动状态,如果仍然还在请求数据,就不用追加新数据
js 复制代码
if (startIndex.value + containSize.value > tableList.value.length - 1 && !isRequestStatus.value) {
    ...
}
  • 再进行优化,如果滚动条滚动,但是范围仅在单个列表中细微滚动的话,不需要进行任何操作,设置当前滚动的顶部索引currentIndex
js 复制代码
const handelScroll = async ({ scrollTop }) => {
  const currentIndex = ~~(scrollTop / oneHeight.value)
  if (startIndex.value === currentIndex) return
  startIndex.value = currentIndex
  ...
  }

[优化]滚动事件截流定时器优化

思考:监听滚动事件触发对于函数方法的频率是极高的,该如何做好页面节流优化呢?
在滚动事件中打印日志发现触发事件频率很高

解决方法

  • 记录当前滚动有效的状态isScrollStatus
js 复制代码
// 记录当前滚动有效的状态
const isScrollStatus = ref(true)
  • 若状态有效,才进行滚动事件执行,并且设置一个定时器,1s后改变滚动状态,即1s内才触发滚动事件
js 复制代码
const handelScroll = async ({ scrollTop }) => {
if (isScrollStatus.value) {
    isScrollStatus.value = false
    // 设置一个定时器,1s钟后,才允许进行下一次的scroll滚动事件行为
    const timer = setTimeout(() => {
      isScrollStatus.value = true
      // 清空定时器,为了避免定时器冗余
      clearTimeout(timer)
    }, 1000)
    const currentIndex = ~~(scrollTop / oneHeight.value)
     ...
    }
  }
  • 将设置数据的相关任务,即滚动事件的具体行为包装成函数
js 复制代码
// 将设置数据的相关任务,即滚动事件的具体行为包装成函数
const setDataStartIndex = async (scrollTop) => {
  const currentIndex = ~~(scrollTop / oneHeight.value)
  if (startIndex.value === currentIndex) return
  startIndex.value = currentIndex
  if (
    startIndex.value + containSize.value > tableList.value.length - 1 &&
    !isRequestStatus.value
  ) {
    // console.log('滚动条到底了')
    // 追加请求新的数据
    const addList = await getNewList(num)
    // console.log(addList)
    if (!addList) return
    tableList.value = [...tableList.value, ...addList]
  }
}

问题

此时却出现了滑动白屏现象,主要原因是之前设置的空白区域,在定时器时长还未结束就显示出来了,即定时器时长设置不合理

  • 注意到一般屏幕的响应时间是60hz,1s的话,每次屏幕闪烁时间差不多是13.3ms,至少应该高于这个值,一般设置为30ms左右能达到整体的一个稳定效果
js 复制代码
const timer = setTimeout(() => {
      isScrollStatus.value = true
      clearTimeout(timer)
    }, 30)

[优化]使用动画帧对滚动事件节流优化

使用定时器的缺点就是,它不管你界面是否渲染出来,都会计时 上述问题有没有更好使节流效果达到最优的解决方法呢?可以使用动画帧requestAnimationFrame,能够确保每一次回调前页面都已经渲染完成

  • 首先判断浏览器的兼容性,对requestAnimationFrame进行兼容性处理
js 复制代码
const handelScroll = ({ scrollTop }) => {
const requestAnimationFrame =
    window.requestAnimationFrame ||
    window.webkitrequestAnimationFrame ||
    window.mozrequestAnimationFrame ||
    window.msrequestAnimationFrame
    ...
}
  • 计算时间间隔以及时间戳,递归调用动画帧函数,确保上一帧页面已经渲染到页面上。
js 复制代码
 const fps = 30
 const interval = 1000 / 30
 let then = Date.now()
  requestAnimationFrame(() => {
    const now = Date.now()
    setDataStartIndex()
    if (now - then >= interval) {
      then = now
      requestAnimationFrame(arguments.callee)
    }
  })

问题【待解决】:

[优化]设置上下滚动缓冲消除快速滚动白屏

快速向上滑动会出现白屏问题(视频中出现了,我影响不大),电脑系统好,所以问题不大,但是要考虑其他性能问题。
出现问题的原因是,当快速滚动时,上下的空白区域会先显示,然后才会将数据填充到可视区域。而之前的方法采用都是懒加载的思想,而我们同时也要考虑预加载问题,即将上下区域的部分数据预加载。

  • 待显示的数组列表元素中重新设置开始列表的索引
js 复制代码
const showDataList = computed(() => {
  let preStartIndex = 0
  if (startIndex.value <= containSize.value) {
    preStartIndex = 0
  } else {
    preStartIndex = startIndex.value - containSize.value
  }
  return tableList.value.slice(preStartIndex, getEndIndex.value)
})
  • 同时将计算结尾属性的值更改
js 复制代码
const getEndIndex = computed(() => {
  let endIndex = startIndex.value + containSize.value * 2
  ...
  return endIndex
})
  • 更新空白区域的显示
js 复制代码
const blankFillStyle = computed(() => {
  let preStartIndex = 0
  if (startIndex.value <= containSize.value) {
    preStartIndex = 0
  } else {
    preStartIndex = startIndex.value - containSize.value
  }
  ...
})
  • 此时,去观察可以发现数据请求变多了

路由切换定位列表滚动位置

切换路由时,返回列表页面仍然是在上次浏览的位置

  • 在主页面包裹keep-alive,并没有生效
js 复制代码
<div class="role">
    <keep-alive>
      <tableList></tableList>
    </keep-alive>
  </div>
  • 利用生命周期钩子,此时需要记录当前滚动条距离顶部的位移,当激活时,将滚动条的距离赋值重新赋值,该操作为DOM操作,为了确保DOM渲染完才执行,需要在nextTick中执行
js 复制代码
const setDataStartIndex = async (scrollTop) => {
  currentScrollTop.value = scrollTop
  ...
  }
  
const scrollbarRef = ref(null)
onActivated(() => {
  nextTick(() => {
    console.log(currentScrollTop.value)
    scrollbarRef.value.setScrollTop(currentScrollTop.value)
  })
})

在路由中设置页面是否要使用缓存

js 复制代码
meta: { keepAlive: true //设置页面是否需要使用缓存 },

参考文献

Vue 移动端企业级实战 - 长列表虚拟滚动高阶插件封装_哔哩哔哩_bilibili

虚拟列表,我真的会了!!! - 掘金 (juejin.cn)

相关推荐
黄尚圈圈20 分钟前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水1 小时前
简洁之道 - React Hook Form
前端
正小安3 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch5 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光5 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   5 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   5 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web5 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常5 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇6 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器