注册
web

摸鱼两天,彻底拿下虚拟滚动!

总结


通过自己的实践发现,网上相传的虚拟滚动实现方案有种是行不通的(涉及浏览器机制)




  • 实现虚拟滚动,滚动元素中利用上下两个只有高度的空盒子撑开空间是不可行的


    html布局示意


    <div class="content-container">
     <div class="top-padding"></div>

     <div class="content-item"></div>
     <div class="content-item"></div>
     <div class="content-item"></div>

     <div class="bottom-padding"></div>
    </div>



  • 可行方案:


    html布局示意


    <div class="scroll-container">
     <div class="content-container">
       <div class="content-item"></div>
      ...
       <div class="content-item"></div>
     </div>
    </div>



如果您和我一样,想自己实现一下虚拟滚动,下面 实现虚拟滚动 部分 中我会尽可能保姆级详细的复现我当时写代码的所有过程(包括建项目...),适合新手(但是不能是小白,需要知道虚拟滚动是干啥的东西,因为我没有去介绍虚拟滚动)。


如果您对这玩意的实现完全没啥好奇的,可以看看 部分,我详细记录了一个关于浏览器滚动条的特点,或许对你来说有点意思。


实现虚拟滚动


下面用vue3写一个demo,并没封装多完善,也不是啥生产可用的东西,但绝对让你清晰虚拟滚动的实现思路。


项目搭建


pnpm create vite创建一个项目,项目名、包名输入virtualScrollDemo,选择技术栈Vue + TypeScript;再简单安装个less,即pnpm install less less-loader -D,然后配一下vite.config.ts,顺便给src配个别名。


vite.config.ts


import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { resolve } from "path"; // 让ts识别模块,这里还需要 pnpm i @types/node

// https://vitejs.dev/config/
export default defineConfig({
 plugins: [vue()],
 css: {
   preprocessorOptions: {
     less: {
    },
  },
},
 resolve: {
   alias: [
    {
       find: "@",
       replacement: resolve(__dirname, "/src"),
    },
  ],
},
});

App.vueimport VirtualScroll from '@/components/VirtualScroll.vue'还是报错,ts还要配置别名才行,tsconfig.json中加一下baseUrlpaths即可


tsconfig.json


{
 "compilerOptions": {
   "target": "ESNext",
   "useDefineForClassFields": true,
   "module": "ESNext",
   "moduleResolution": "Node",
   "strict": true,
   "jsx": "preserve",
   "resolveJsonModule": true,
   "isolatedModules": true,
   "esModuleInterop": true,
   "lib": ["ESNext", "DOM"],
   "skipLibCheck": true,
   "noEmit": true,
   "baseUrl": "./",
   "paths": {
     "@/*": ["src/*"]
  }
},
 "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
 "references": [{ "path": "./tsconfig.node.json" }]
}

然后项目删一删没用的就成了这样:


src/
├── App.vue
├── components/
│   └── VirtualScroll.vue
└── shared/
  └── dataConstant.ts

dataConstant.ts是准备的一个长列表渲染的数据源:


export const dataSource = [
{
   text: "jrd",
},
{
   text: "jrd1",
},
 ...
]

结构搭建


为了突出重点,实现虚拟滚动逻辑必要的样式我都写在:style中了,辅助性的样式都写在<style></style>


先把长列表搭建出来:


基本长列表.gif


<template>
 <div
   class="scroll-container"
   :style="{
     overflow: 'auto',
     height: `${viewPortHeight}px` // 列表视口高度(值自定义即可)
   }"

 >

 <div
   class="content-container"
   :style="{
     height: `${itemHeight * dataSource.length}px`
   }"

 >

   <div
     class="content-item"
     v-for="(data, index) in dataSource"
   >

     {{ data.text }}
   </div>
 </div>
</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { dataSource } from "@/shared/dataConstant";

export default defineComponent({
 name: "VirtualScroll",
 setup() {
   const viewPortHeight = 500; // 滚动列表的可视高度
   const itemHeight = 50; // 一个列表项的高度
   return {
     viewPortHeight,
     dataSource,
     itemHeight
  }
},
});
</script>

<style scoped lang="less">
.scroll-container {
 border: 2px solid red;
 width: 300px;
 .content-container {
   .content-item {
     height: 50px;
     background-image: linear-gradient(0deg, pink, blue);
  }
}
}

</style>

注释:


html结构三层嵌套,最外层是div.scroll-container,里面依次是div.content-containerdiv.content-item


div.scroll-container容器是出现滚动条的容器,所以它需要一个固定高度(可视区域的高度)以及overflow: auto,这样他内部元素超过了它的高度它才会出现滚动条;div.content-container的作用就是撑开div.scroll-container,解释一下,因为我们最终要的效果是只渲染一小部分元素,单单渲染的这一小部分内容肯定是撑不开div.scroll-container的,所以根据渲染项的多少以及每个渲染项的高度写死div.content-container的高度,不管渲染项目多少,始终保持div.scroll-containerscrollHeight正常。


核心计算


监听div.scroll-container的滚动事件,滚动回调中计算startIndexendIndex,截取数据源(截取要渲染的一小部分数据,即renderDataList = dataSource.slice(startIndex, endIndex)):


计算startIndex和endIndex.gif


<template>
 <div
   class="scroll-container"
   :style="{
     overflow: 'auto',
     height: `${viewPortHeight}px` // 列表视口高度(值自定义即可)
   }"

   ref="scrollContainer"
   @scroll="handleScroll"
 >

 <div
   class="content-container"
   :style="{
     height: `${itemHeight * dataSource.length}px`
   }"

 >

   <div
     class="content-item"
     v-for="(data, index) in dataSource"
   >

     {{ data.text }}
   </div>
 </div>
</div>

</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import { dataSource } from "@/shared/dataConstant";

export default defineComponent({
 name: "VirtualScroll",
 setup() {
   const viewPortHeight = 525; // 滚动列表的可视高度
   const itemHeight = 50; // 一个列表项的高度
   const startIndex = ref(0);
   const endIndex = ref(0);
   const scrollContainer = ref<HTMLElement | null>(null);
   const handleScroll = () => {
     if(!scrollContainer.value) return
     const scrollTop = scrollContainer.value.scrollTop;
     startIndex.value = Math.floor(scrollTop / itemHeight);
     endIndex.value = Math.ceil((scrollTop + viewPortHeight) / itemHeight) - 1;
     console.log(startIndex.value, endIndex.value);
  }
   return {
     viewPortHeight,
     dataSource,
     itemHeight,
     scrollContainer,
     handleScroll
  }
},
});
</script>

<style scoped lang="less">
.scroll-container {
 border: 2px solid red;
 width: 300px;
 .content-container {
   .content-item {
     height: 50px;
     background-image: linear-gradient(0deg, pink, blue);
  }
}
}

</style>

注释:


startIndexendIndex我们都按照从0开始(而非1开始)的标准来计算。 startIndex对应div.scroll-container上边界压住的div.content-itemindexendIndex对应div.scroll-container下边界压住的div.content-itemindex,也就是说,startIndexendIndex范围内的数据,是我们在保证可视区域不空白的前提下至少要进行渲染的数据,我可能表述不很清楚,静心想一想不难理解的。


收尾


最后的两步就是根据startIndexendIndexdataSource中动态截取出来renderDataListv-for只渲染renderDataList,然后把渲染出来的div.content-item通过定位 + transform移动到正确的位置即可了。


监听startIndexendIndex,变化时修改renderDataList


逻辑:


// 因为slice函数是左闭右开,所以截取时为endIndex.value + 1
const renderDataList = ref(dataSource.slice(startIndex.value, endIndex.value + 1));
watch(() => startIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})
watch(() => endIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})

结构:


<div 
class="content-item"
v-for="(data, index) in renderDataList"
>
{{ data.text }}
</div>

这时候,数据已经正确渲染了,只是位置还不太对


效果:


数据结构正确渲染.gif


我们要做的就是通过css把渲染出来的dom移动到正确的位置,这里采取的方案就是div.content-container相对定位,div.content-item绝对定位,并且topleft都设置为0(所有都移动到左上角),然后通过translate: transformY把它们移动到“正确”的位置:


结构:


<div 
class="content-item"
v-for="(data, index) in renderDataList"
:style="{
position: 'absolute',
top: '0',
left: '0',
transform: `translateY(${(startIndex + index) * itemHeight}px)`
}"
>
{{ data.text }}
</div>

经过上面的修改之后已经基本收工了,不知道是哪个样式的原因div.content-item的宽度不是100%了,手动加上就好了


效果:


虚拟滚动大功告成.gif


优化



  1. 给滚动事件添加节流
  2. 引入缓冲结点数变量countOfBufferItem,适当扩充(startIndex, endIndex)渲染区间,防止滑动过快出现空白

最终代码:


<template>
<div
class="scroll-container"
:style="{
overflow: 'auto',
height: `${viewPortHeight}px` // 列表视口高度(值自定义即可)
}"

ref="scrollContainer"
@scroll="handleScroll"
>

<div
class="content-container"
:style="{
height: `${itemHeight * dataSource.length}px`,
position: 'relative'
}"

>

<div
class="content-item"
v-for="(data, index) in renderDataList"
:style="{
position: 'absolute',
top: '0',
left: '0',
transform: `translateY(${(startIndex + index) * itemHeight}px)`
}"

>

{{ data.text }}
</div>
</div>
</div>

</template>

<script lang="ts">
import { defineComponent, ref, watch } from "vue";
import { dataSource } from "@/shared/dataConstant";

export default defineComponent({
name: "VirtualScroll",
setup() {
const viewPortHeight = 525; // 滚动列表的可视高度
const itemHeight = 50; // 一个列表项的高度
const startIndex = ref(0);
const endIndex = ref(Math.ceil(viewPortHeight / itemHeight) - 1);
const scrollContainer = ref<HTMLElement | null>(null);

let isHandling = false; // 节流辅助变量
const countOfBufferItem = 2; // 缓冲结点数量
const handleScroll = () => {
if(isHandling) return;
isHandling = true;
setTimeout(() => {
if(!scrollContainer.value) return
const scrollTop = scrollContainer.value.scrollTop;
startIndex.value = Math.floor(scrollTop / itemHeight);
startIndex.value = startIndex.value - countOfBufferItem >= 0 ? startIndex.value - countOfBufferItem : 0; // 扩充渲染区间
endIndex.value = Math.ceil((scrollTop + viewPortHeight) / itemHeight) - 1;
endIndex.value = endIndex.value + countOfBufferItem >= dataSource.length - 1 ? dataSource.length - 1 : endIndex.value + countOfBufferItem; // 扩充渲染区间
isHandling = false;
}, 30)
}

const renderDataList = ref(dataSource.slice(startIndex.value, endIndex.value + 1));
watch(() => startIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})
watch(() => endIndex.value, () => {
renderDataList.value = dataSource.slice(startIndex.value, endIndex.value + 1);
})
return {
viewPortHeight,
dataSource,
itemHeight,
scrollContainer,
handleScroll,
renderDataList,
startIndex,
endIndex
}
},
});
</script>

<style scoped lang="less">
.scroll-container {
border: 2px solid red;
width: 300px;
.content-container {
.content-item {
height: 50px;
background-image: linear-gradient(0deg, pink, blue);
width: 100%;
}
}
}

</style>

虽说没啥bug吧,但是滚动的快了还是有空白啥的,这应该也算是这个技术方案的瓶颈。



bug复现


我一开始的思路是一个外层div.container,设置overflow: hidden,然后内部上中下三部分,上面一个空盒子,高度为startIndex * listItemHeight;中间部分为v-for渲染的列表,下面又是一个空盒子,高度(dataSource.length - endIndex - 1) * listItemHeight,总之三部分的总高度始终维持一个定值,即这个值等于所有数据完全渲染时div.containerscrollHeight


实现之后,问题出现了:


不受控制的滚动.gif


一旦触发了“机关”,滚动条就会不受控制的滚动到底


我把滚动回调的节流时间设置长为500ms


不受控制的滚动-长节流.gif


发现滚动条似乎陷入了一种循环之中,每次向下移动一个数据块的高度。 分析这个现象,需要下面一些关于滚动条特性的认知。


滚动条的特性


先给结论:当一个定高(scrollHeight固定)的滚动元素,其(撑开其高度的)子元素高度发生变化时(高度组成发生变化,比如一个变高,一个变低,但保持滚动元素的scrollHeight总高度不变),滚动条位置也会发生变化,变化遵循一个原则:保持当前可视区域展示的元素在可视区域内位置不变。


写个demo模拟一下上面说的场景,div.container是一个滚动且定高的父元素,点击按钮后其内部的div.top变高,div.bottom变矮


Test.vue:


<template>
<div class="container" ref="container">
<div
class="top"
:style="{
height: `${topHeight}px`,
}"

>
</div>
<div class="content"></div>
<div
class="bottom"
:style="{
height: `${bottomHeight}px`,
}"

>
</div>
</div>

<button @click="test">按钮</button>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";

export default defineComponent({
setup() {
const topHeight = ref(300);
const bottomHeight = ref(300);
const container = ref(null);
const test = () => {
topHeight.value += 50;
bottomHeight.value -= 50;
};
return {
topHeight,
bottomHeight,
test,
container,
};
},
});
</script>


<style scoped lang="less">
.container {
width: 200px;
height: 600px;
overflow: auto;
border: 1px solid green;
.top {
width: 100%;
border: 3px solid red;
}
.content {
height: 1000px;
}
.bottom {
width: 100%;
border: 3px solid black;
}
}
</style>


滚动条位置变化demo展示


仔细观察滚动条:


滚动条位置变化demo.gif


解释一下上图,首先是上面一个红色盒子,底部一个黑色盒子:



  • 我们可视区域的左上角在红色区域时点击按钮,这时候浏览器底层判断我们正在浏览红色元素,所以虽然内部元素高度变化,但我们的可视区域相对于红色盒子左上角的位置不变
  • 第一次刷新之后,我们可视区域的左上角在中间盒子上,这时候我们点击按钮,红色盒子高度增加,黑色盒子高度减小,中间盒子的相对整个滚动区域的位置就靠下了,但是——浏览器的滚动条也随之向下移动了(而且,滚动条向下移动的距离 === 红色盒子高度增加值 === 黑色盒子高度减小值 === 中间盒子相对滚动区域向下偏移值
  • 第二次刷新后,更直观的表现了滚动条的这个特点:我把滚动条恰好移动到中间盒子上,上面紧邻红色盒子,点击三次按钮后,滚动条下移三次,此时我向上滚动一点,接着看到了红色盒子。

bug原因分析


有了上面的认知,再来看这个图


不受控制的滚动-长节流.gif


bug的“生命周期”:


1.我们手动向下滚动滚动条 ——> 2.内部计算(startIndex以及endIndex的改变)触发上方占位的<div>元素高度增加,下方占位<div>高度减小,中间渲染的内容部分整体位置相对于整个滚动元素下移 ——> 3.(浏览器为了保持当前可视区域展示的元素在可视区域内位置不变)滚动条自动下移 ——> 4.触发新的计算 ——> 2.


感慨:上中下三个部分,上下动态修改高度占位,中间部分渲染数据,思路多么清晰的方案,但谁能想到浏览器滚动条出来加了道菜呢


网上不少地方都给了这个方案...


成功的虚拟滚动、带bug的虚拟滚动和测试组件的源码我都放到这里了,需要的话可以去clone:github.com/jinrd123/Vi…(带bug的虚拟滚动是我第一次实现时随性写的,代码组织以及注释可能不是很规范)


作者:荣达
来源:juejin.cn/post/7211088034179366973

0 个评论

要回复文章请先登录注册