前端虚拟滚动列表 vue虚拟列表

avatar
作者
猴君
阅读量:0

文章目录

方法一利用浏览器原生api去实现,可以实现不等高的列表虚拟滚动,intersectionObserver 多用于图片懒加载,虚拟滚动列表
方法二通过监听滚动条的位置,去计算显示的内容,这里需要列表等高,当然不等高也可以计算,稍微改改


前端虚拟滚动列表(方法一:利用IntersectionObserver api 简单)

  • IntersectionObserver可以用来自动监听元素是否进入了设备的可视区域之内,而不需要频繁的计算来做这个判断。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器"
    IntersectionObserver 方案多用于图片懒加载或者列表虚拟滚动

IntersectionObserver 是浏览器原生提供的构造函数,接受两个参数: callback:可见性发现变化时的回调函数 option:配置对象(可选)。
构造函数的返回值是一个观察器实例。实例一共有4个方法:

  • observe:开始监听特定元素

  • unobserve:停止监听特定元素

  • disconnect:关闭监听工作

  • takeRecords:返回所有观察目标的对象数组

  • callback 参数
    目标元素的可见性变化时,就会调用观察器的回调函数callback。
    callback一般会触发两次。一次是目标元素刚刚进入视口,另一次是完全离开视口。

const io = new IntersectionObserver((changes, observer) => {   console.log(changes);   console.log(observer); });  
  • options
  1. threshold: 决定了什么时候触发回调函数。它是一个数组,每个成员都是一个门槛值,默认为[0],即交叉比例(intersectionRatio)达到0时触发回调函数。用户可以自定义这个数组。比如,[0, 0.25, 0.5, 0.75, 1]就表示当目标元素 0%、25%、50%、75%、100% 可见时,会触发回调函数。
  2. root: 用于观察的根元素,默认是浏览器的视口,也可以指定具体元素,指定元素的时候用于观察的元素必须是指定元素的子元素
  3. rootMargin: 用来扩大或者缩小视窗的的大小,使用css的定义方法,10px 10px 30px 20px表示top、right、bottom 和 left的值
    ————————————————

这里是后面补充的简单还原了下面方法二的例子,重点在60行,从哪儿看就可以

<template>   <div class="big-box">     <div class="download-box txt" id="scrollable-div">       <div v-for="(item, index) in props.seqText" :key="index" class="line-box">         <template v-if="index === 0 && start === 0">           <div :class="{ 'text-title': props.collapsed, 'text-title-samll': !props.collapsed }">             {{ item }}           </div>         </template>         <template v-else>           <div :class="{ 'text-number': props.collapsed, 'text-number-samll': !props.collapsed }">             {{ calLine(item, index + start) }}           </div>           <div             :class="{ 'text-box': props.collapsed, 'text-box-samll': !props.collapsed }"             :data="item"           >             ''           </div>           <div :class="{ 'text-number2': props.collapsed, 'text-number2-samll': !props.collapsed }">             {{ endRow(item, index + start) }}           </div>         </template>       </div>     </div>   </div>    <SearchBox :againFind="againFind" /> </template>  <script lang="ts" setup> import { watch, onMounted, PropType, reactive, ref } from 'vue';  import SearchBox from '/@/components/SearchBox/index.vue'; import { message } from 'ant-design-vue';  const props = defineProps({   collapsed: {     type: Boolean,     default: true,   },   seqText: {     type: Array as PropType<string[]>,     default: [''],   }, }); let width = 100; const geneTexts: Array<string> = []; const data = reactive({   geneTexts, });  const calLine = (item: any, index: number) => {   return width * (index - 1) + 1; }; const endRow = (item: any, index: number) => {   return width * index; };  //  这里是核心要点 const io = new IntersectionObserver(   (entries) => {     console.log(entries);     for (const entry of entries) {       if (entry.isIntersecting) {         const elTxt = entry.target;         // console.log(elTxt.getAttribute('data'));         elTxt.innerHTML = elTxt.getAttribute('data');         io.unobserve(elTxt);       }     }   },   {     root: document.getElementById('scrollable-div'),     // rootMargin: 0,     threshold: 0.5,   }, ); setTimeout(() => {   const elList = document.querySelectorAll('.text-box');   console.log(elList);   elList.forEach((element) => {     io.observe(element);   }); }, 1000);  const againFind = ref(1);  let start = ref(0); </script>  <style lang="less" scoped> // @import '/@/assets/styles/views/medaka.less'; .big-box {   background: #282c34;   padding: 30px 20px;   height: 870px; } .download-box {   width: 100%;   // padding: 0px 20px;   // outline: 1px solid rgb(17, 0, 255);   overflow: hidden;    .line-box {     .flex-type(flex-start);     height: 30px;   }    &.txt {     background: #282c34;     color: #fff;     height: 810px;     overflow: auto;      .el-row {       display: flex;       align-items: center;       margin-bottom: 10px;       margin: auto;       font-size: 22px;     }   } }  @media screen and (min-width: 1842px) {   .text-box-samll {     letter-spacing: 1.5px;     font-size: 15px;   }    .text-number-samll {     min-width: 60px;     font-size: 15px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 60px;     font-size: 15px;   }    .text-title-samll {     font-size: 15px;   }    .text-box {     font-size: 22px;     // letter-spacing: 3px;   }    .text-number {     min-width: 100px;     font-size: 22px;   }    .text-number2 {     margin-left: 20px;     min-width: 100px;     font-size: 22px;   }    .text-title {     font-size: 22px;   } } @media screen and (min-width: 1600px) and (max-width: 1841px) {   .text-box-samll {     font-size: 15px;   }    .text-number-samll {     min-width: 40px;     font-size: 15px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 40px;     font-size: 15px;   }    .text-title-samll {     font-size: 15px;   }    .text-box {     font-size: 20px;     // letter-spacing: 1.2px;   }    .text-number {     min-width: 60px;     font-size: 20px;   }    .text-number2 {     margin-left: 20px;     min-width: 60px;     font-size: 20px;   }    .text-title {     font-size: 20px;   } }  @media screen and (min-width: 1443px) and (max-width: 1599px) {   .text-box-samll {     font-size: 13px;   }    .text-number-samll {     min-width: 40px;     font-size: 13px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 40px;     font-size: 13px;   }    .text-title-samll {     font-size: 13px;   }    .text-box {     font-size: 18px;     // letter-spacing: 1.2px;   }    .text-number {     min-width: 60px;     font-size: 15px;   }    .text-number2 {     margin-left: 20px;     min-width: 60px;     font-size: 18px;   }    .text-title {     font-size: 18px;   } }  @media screen and (max-width: 1442px) {   .text-box-samll {     font-size: 11px;   }    .text-number-samll {     min-width: 40px;     font-size: 11px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 40px;     font-size: 11px;   }    .text-title-samll {     font-size: 11px;   }    .text-box {     font-size: 16px;     // letter-spacing: 1.2px;   }    .text-number {     min-width: 60px;     font-size: 15px;   }    .text-number2 {     margin-left: 20px;     min-width: 60px;     font-size: 16px;   }    .text-title {     font-size: 16px;   } } </style>  

前端虚拟滚动列表(方法二:监听滚动计算 麻烦)

在大型的企业级项目中经常要渲染大量的数据,这种长列表是一个很普遍的场景,当列表内容越来越多就会导致页面滑动卡顿、白屏、数据渲染较慢的问题;大数据量列表性能优化,减少真实dom的渲染


看图:绿色是显示区域,绿色和蓝色中间属于预加载:解决滚动闪屏问题;大致了解了流程在往下看;
在这里插入图片描述

实现效果:

先说一下你看到这么多真实dom节点是因为做了预加载,减少滚动闪屏现象,这里写了300行,可以根据实际情况进行截取
在这里插入图片描述

实现思路:

虚拟列表滚动大致思路:两个div容器

  外层:外部容器用来固定列表容器的高度,同时生成滚动条    内层:内部容器用来装元素,高度是所有元素高度的和    外层容器鼠标滚动事件  dom.scrollTop 获取滚动条的位置    根据每行列表的高以及当前滚动条的位置,利用slice() 去截取当前需要显示的内容    重点:滚动条的高度是有内层容器的paddingBottom 和 paddingTop 属性顶起来了,确保滚动条位置的准确性    这里鼠标上下滚动会出现闪屏问题:解决方案如下:        方案一:  预加载:                      向下预加载:                         比如div滚动区域显示30行,就预加载 300行( 即这里 slice(startIndex,startIndex + 300) ),                      向上预加载:                         在滚动监听事件函数中(computeRow)判断inner的paddingTop和paddingBottom即可                      当然这里的download-box的padding有30px像素,在加一个div,overflow:hidded就解决了        方案二:缩小滚动范围或者节流时间缩短,这里写的500ms 

具体代码

  <template>     <div class="enn">       <div class="download-box txt" id="scrollable-div" @scroll="handleScroll">         <div id="inner">           <div v-for="(item, index) in data2" :key="index" class="line-box">             <div :class="{ 'text-box': props.collapsed, 'text-box-samll': !props.collapsed }">               {{ item }}             </div>           </div>         </div>       </div>     </div>   </template>    <script lang="ts" setup>   import { onMounted, PropType, ref } from 'vue';    import { useText } from './hooks/useText';    const props = defineProps({     baseData: {       type: Object as PropType<{         taskId: string;         barcodeName: string;       }>,       default: {},     },     collapsed: {       type: Boolean,       default: true,     },     type: {       type: Boolean,       default: false,     },   });    const { data } = useText(props.type);    //  这里大数据量数组是  data.geneTexts    /**    * 虚拟列表滚动大致思路:两个div容器    *    *    外层:外部容器用来固定列表容器的高度,同时生成滚动条    *    *    内层:内部容器用来装元素,高度是所有元素高度的和    *    *    外层容器鼠标滚动事件  dom.scrollTop 获取滚动条的位置    *    *    根据每行列表的高以及当前滚动条的位置,利用slice() 去截取当前需要显示的内容    *    *    重点:滚动条的高度是有内层容器的paddingBottom 和 paddingTop 属性顶起来了,确保滚动条位置的准确性    *    *    这里鼠标上下滚动会出现闪屏问题:解决方案如下:    *    *        方案一:  预加载:    *    *                      向下预加载:    *                          比如div滚动区域显示30行,就预加载 300行( 即这里 slice(startIndex,startIndex + 300) )*    *                      向上预加载:    *                          在滚动监听事件函数中(computeRow)判断inner的paddingTop和paddingBottom即可    *    *                      当然这里的download-box的padding有30px像素,在加一个div,overflow:hidded就解决了    *    *        方案二:缩小滚动范围或者节流时间缩短,这里写的500ms    *    *    */    let timer_throttle: any;   const throttle = (func: Function, wait?: number) => {     wait = wait || 500;     if (!timer_throttle) {       timer_throttle = setTimeout(() => {         func.apply(this);         timer_throttle = null;       }, wait);     }   };    // 鼠标滚动事件   const handleScroll = (event: any) => throttle(computeRow, 100);   // 计算当前显示tab   const computeRow = () => {     // console.log('距离顶部距离', window.scrollY, geneTexts);      let scrollableDiv = document.getElementById('scrollable-div');     let topPosition = scrollableDiv.scrollTop;     let leftPosition = scrollableDiv.scrollLeft;     console.log('垂直滚动位置:', topPosition, '水平滚动位置:', leftPosition);      const startIndex = Math.max(0, Math.floor(topPosition / 30));         const endIndex = startIndex + 300;     data2.value = data.geneTexts.slice(startIndex, endIndex);      let inner = document.getElementById('inner');     if (topPosition < 2700) {       // 向上预计加载,这里判断了三个高度,可以多判断几个,增加流畅度       inner.style.paddingTop = topPosition + 'px';       inner.style.paddingBottom = (data.geneTexts.length + 2) * 30 - topPosition + 'px';     } else if (topPosition + data2.value.length * 30 >= data.geneTexts.length * 30) {       // 这里 9000 是 内层div的高度 30 * 300   理解div高度是 padding+div内容高度       inner.style.paddingTop = topPosition - 900 + 'px'; //900 是div的高度       inner.style.paddingBottom = 0 + 'px';     } else {       inner.style.paddingTop = topPosition - 2700 + 'px';       inner.style.paddingBottom = (data.geneTexts.length + 2) * 30 + 2700 - topPosition + 'px';     }   };   const data2 = ref([]);   const init = () => {     data2.value = data.geneTexts.slice(0, 300);     let inner = document.getElementById('inner');     inner.style.paddingTop = 0 + 'px';     inner.style.paddingBottom = (data.geneTexts.length + 2) * 30 - 900 + 'px';   };   </script>    <style lang="less" scoped>   .button-box {     margin-bottom: 25px;     .flex-type(flex-end);      :deep(.ant-btn) {       margin-left: 10px;     }   }   .enn {     background: #282c34;     outline: 1px solid red;     padding: 30px 20px;     height: 960px;   }   .download-box {     width: 100%;     // padding: 30px 20px;     outline: 1px solid rgb(17, 0, 255);     background-color: #fff;     overflow: hidden;      .line-box {       .flex-type(flex-start);       height: 30px;     }      &.txt {       background: #282c34;       color: #fff;       height: 900px;       overflow: auto;     }   }   </style>  

替代方案

上面是自己写的,github上面还有好多插件可以用,但各有优劣,根据自己需求选择
如:

vue-virtual-scroller

https://github.com/Akryum/vue-virtual-scroller/tree/0f2e36248421ad69f41c9a08b8dcf7839527b8c2

vue-virt-list

vue-draggable-virtual-scroll-list

virtual-list

自己找吧,我就不一一列举了,看图

在这里插入图片描述

<template>   <br />   <div>     <Table       :columns="tableConfig.columns"       :data="tableConfig.totalData"       :loading="tableConfig.loading"       :pagination="false"     ></Table>   </div>   <br />    <div class="button-box">     <a-select       v-model:value="selection"       placeholder="请选择序列"       :options="seqOptions"       @change="         (selection:string) => handleChangeSeq(baseData.taskId, baseData.barcodeName, width, selection)       "     ></a-select>     <a-button type="primary" @click="handleClickExport()">导出所有序列</a-button>     <a-button type="primary" @click="modalConfig.visible = true">导出当前序列</a-button>   </div>   <!-- <SeqText :collapsed="props.collapsed" :seqText="data.geneTexts" /> -->   <div class="enn">     <div class="download-box txt" id="scrollable-div" @scroll="handleScroll">       <div id="inner">         <div v-for="(item, index) in data2" :key="index" class="line-box">           <template v-if="index === 0 && start === 0">             <div :class="{ 'text-title': props.collapsed, 'text-title-samll': !props.collapsed }">               {{ item }}             </div>           </template>           <template v-else>             <div :class="{ 'text-number': props.collapsed, 'text-number-samll': !props.collapsed }">               {{ calLine(item, index + start) }}             </div>             <div :class="{ 'text-box': props.collapsed, 'text-box-samll': !props.collapsed }">               {{ item }}             </div>             <div               :class="{ 'text-number2': props.collapsed, 'text-number2-samll': !props.collapsed }"             >               {{ endRow(item, index + start) }}             </div>           </template>         </div>       </div>     </div>   </div>   <br />    <a-modal     title="导出文件"     :visible="modalConfig.visible"     @ok="handleExport(data.geneTexts)"     @cancel="modalConfig.visible = false"   >     <div class="form-box">       <a-form>         <a-form-item label="自定义文件名">           <a-input v-model:value="modalConfig.name" placeholder="请输入自定义文件名"></a-input>         </a-form-item>       </a-form>     </div>   </a-modal> </template>  <script lang="ts" setup> import { defineComponent, onMounted, PropType, ref } from 'vue';  import Table from '/@/components/table/sTable.vue'; import SeqText from '/@/components/SeqText/index.vue';  import { useText, useTable } from './hooks/useText'; import { useModal } from './hooks/useModal'; import { serverAddress } from '/@/serve/index'; import { download, downloadTxt } from '/@/libs/utils/download';  const props = defineProps({   /**    * 基础数据    */   baseData: {     type: Object as PropType<{       taskId: string;       barcodeName: string;     }>,     default: {},   },   collapsed: {     type: Boolean,     default: true,   },   type: {     type: Boolean,     default: false,   }, });  let width = 100; const { taskId, barcodeName } = props.baseData; const { data, getMedaka, getAvailableSeq, handleChangeSeq, seqOptions, selection } = useText(   props.type, ); const { tableConfig, getTable } = useTable(props.type);  const VITE_APP_URL = serverAddress(); const { modalConfig, handleExport } = useModal(); const handleClickExport = () => {   let path = '';   if (props.type) {     path = VITE_APP_URL + `outputs/${taskId}/fastq_analysis/${barcodeName}/ragtag.fasta`;   } else {     path =       VITE_APP_URL + `outputs/${taskId}/fastq_analysis/${barcodeName}/${barcodeName}.final.fasta`;   }    download(path, '.fasta'); }; const calLine = (item: any, index: number) => {   return width * (index - 1) + 1; }; const endRow = (item: any, index: number) => {   return width * index; }; onMounted(() => {   getAvailableSeq(taskId, barcodeName).then(() => {     if (seqOptions.value.length > 0) {       getMedaka(taskId, barcodeName, width, seqOptions.value[0].value).then(() => init());       // getMedaka(taskId, barcodeName, width);     }   });   getTable(taskId, barcodeName); });  /**  * 虚拟列表滚动大致思路:两个div容器  *  *    外层:外部容器用来固定列表容器的高度,同时生成滚动条  *  *    内层:内部容器用来装元素,高度是所有元素高度的和  *  *    外层容器鼠标滚动事件  dom.scrollTop 获取滚动条的位置  *  *    根据每行列表的高以及当前滚动条的位置,利用slice() 去截取当前需要显示的内容  *  *    重点:滚动条的高度是有内层容器的paddingBottom 和 paddingTop 属性顶起来了,确保滚动条位置的准确性  *  *    这里鼠标上下滚动会出现闪屏问题:解决方案如下:  *  *        方案一:  预加载:  *   *                      向下预加载:  *                          比如div滚动区域显示30行,就预加载 300行( 即这里 slice(startIndex,startIndex + 300) ),  *    *                      向上预加载:  *                          在滚动监听事件函数中(computeRow)判断inner的paddingTop和paddingBottom即可  *    *                      当然这里的download-box的padding有30px像素,在加一个div,overflow:hidded就解决了  *  *        方案二:缩小滚动范围或者节流时间缩短,这里写的500ms  *   *  */  let timer_throttle: any; const throttle = (func: Function, wait?: number) => {   wait = wait || 500;   if (!timer_throttle) {     timer_throttle = setTimeout(() => {       func.apply(this);       timer_throttle = null;     }, wait);   } }; let start = ref(0); // 鼠标滚动事件 const handleScroll = (event: any) => throttle(computeRow, 100); // 计算当前显示tab const computeRow = () => {   // console.log('距离顶部距离', window.scrollY, geneTexts);    let scrollableDiv = document.getElementById('scrollable-div');   let topPosition = scrollableDiv.scrollTop;   let leftPosition = scrollableDiv.scrollLeft;   console.log('垂直滚动位置:', topPosition, '水平滚动位置:', leftPosition);    const startIndex = Math.max(0, Math.floor(topPosition / 30));   start.value = startIndex;   const endIndex = startIndex + 300;   data2.value = data.geneTexts.slice(startIndex, endIndex);    let inner = document.getElementById('inner');   if (topPosition < 2700) {     // 向上预计加载,这里判断了三个高度,可以多判断几个,增加流畅度     inner.style.paddingTop = topPosition + 'px';     inner.style.paddingBottom = (data.geneTexts.length + 2) * 30 - topPosition + 'px';   } else if (topPosition + data2.value.length * 30 >= data.geneTexts.length * 30) {     // 这里 9000 是 内层div的高度 30 * 300     inner.style.paddingTop = topPosition - 900 + 'px'; //900 是div的高度     inner.style.paddingBottom = 0 + 'px';   } else {     inner.style.paddingTop = topPosition - 2700 + 'px';     inner.style.paddingBottom = (data.geneTexts.length + 2) * 30 + 2700 - topPosition + 'px';   } }; const data2 = ref([]); const init = () => {   data2.value = data.geneTexts.slice(0, 300);   let inner = document.getElementById('inner');   inner.style.paddingTop = 0 + 'px';   inner.style.paddingBottom = (data.geneTexts.length + 2) * 30 - 900 + 'px'; }; </script>  <style lang="less" scoped> // @import '../../../../assets/styles/views/medaka.less';  .button-box {   margin-bottom: 25px;   .flex-type(flex-end);    :deep(.ant-btn) {     margin-left: 10px;   } } .enn {   background: #282c34;   outline: 1px solid red;   padding: 30px 20px;   height: 960px; } .download-box {   width: 100%;   // padding: 30px 20px;   outline: 1px solid rgb(17, 0, 255);   background-color: #fff;   overflow: hidden;    .line-box {     .flex-type(flex-start);     height: 30px;   }    &.txt {     background: #282c34;     color: #fff;     height: 900px;     overflow: auto;      .el-row {       display: flex;       align-items: center;       margin-bottom: 10px;       margin: auto;       font-size: 22px;     }   } }  .form-box {   .flex-type(center); }  :deep(.ant-select-selector) {   min-width: 120px; }  @media screen and (min-width: 1842px) {   .text-box-samll {     letter-spacing: 1.5px;     font-size: 15px;   }    .text-number-samll {     min-width: 60px;     font-size: 15px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 60px;     font-size: 15px;   }    .text-title-samll {     font-size: 15px;   }    .text-box {     font-size: 22px;     // letter-spacing: 3px;   }    .text-number {     min-width: 100px;     font-size: 22px;   }    .text-number2 {     margin-left: 20px;     min-width: 100px;     font-size: 22px;   }    .text-title {     font-size: 22px;   } }  @media screen and (min-width: 1600px) and (max-width: 1841px) {   .text-box-samll {     font-size: 15px;   }    .text-number-samll {     min-width: 40px;     font-size: 15px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 40px;     font-size: 15px;   }    .text-title-samll {     font-size: 15px;   }    .text-box {     font-size: 20px;     // letter-spacing: 1.2px;   }    .text-number {     min-width: 60px;     font-size: 15px;   }    .text-number2 {     margin-left: 20px;     min-width: 60px;     font-size: 20px;   }    .text-title {     font-size: 20px;   } }  @media screen and (min-width: 1443px) and (max-width: 1599px) {   .text-box-samll {     font-size: 13px;   }    .text-number-samll {     min-width: 40px;     font-size: 13px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 40px;     font-size: 13px;   }    .text-title-samll {     font-size: 13px;   }    .text-box {     font-size: 18px;     // letter-spacing: 1.2px;   }    .text-number {     min-width: 60px;     font-size: 15px;   }    .text-number2 {     margin-left: 20px;     min-width: 60px;     font-size: 18px;   }    .text-title {     font-size: 18px;   } }  @media screen and (max-width: 1442px) {   .text-box-samll {     font-size: 11px;   }    .text-number-samll {     min-width: 40px;     font-size: 11px;   }    .text-number2-samll {     margin-left: 20px;     min-width: 40px;     font-size: 11px;   }    .text-title-samll {     font-size: 11px;   }    .text-box {     font-size: 16px;     // letter-spacing: 1.2px;   }    .text-number {     min-width: 60px;     font-size: 15px;   }    .text-number2 {     margin-left: 20px;     min-width: 60px;     font-size: 16px;   }    .text-title {     font-size: 16px;   } } </style>  

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!