分步实现带缓冲区的等高子元素的虚拟列表(vue & react)
效果展示
实现思路
-
撑开视口元素,出现滚动条
根据单个元素高度
itemHeight
与元素总数allData.length
,计算出总高度。并给一个元素设置上,用来撑出滚动条。
<script setup>
import { ref } from "vue";
const windowRef = ref(null);
const itemHeight = 100;
const allData = ref(Array.from({ length: 10000 }, (_, i) => i));
</script>
<template>
<div class="window" ref="windowRef">
<div :style="{ height: itemHeight * allData.length + 'px' }" />
</div>
</template>
<style scoped>
.window {
height: 550px;
width: 300px;
border: 1px solid red;
overflow: auto;
}
</style>
-
创建可视元素
-
组件挂载之后根据可视区(
window
元素)高度以及可视元素(listItem
元素)高度计算可视元素个数showingNum
; -
根据可视元素个数
showingNum
,从总数据allData
中截取出可是元素集合showingList
。
<script setup> import { ref, onMounted } from "vue"; const windowRef = ref(null); const itemHeight = 100; const allData = ref(Array.from({ length: 10000 }, (_, i) => i)); const showingList = ref([]); // 可视元素集合 const showingNum = ref(0); // 可视元素个数 const top = ref(0); // 可视元素父元素偏移量(此步骤一直为0) // 挂载之后,计算可视元素应该有几个 onMounted(() => { showingNum.value = Math.ceil( parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight ) + 1; showingList.value = allData.value.slice(0, showingNum.value); }); </script> <template> <div class="window" ref="windowRef"> <div :style="{ height: itemHeight * allData.length + 'px' }" /> <!-- 新增可视节点及其父元素showingListWrapper --> <div class="showingListWrapper" :style="{ top: top + 'px' }"> <div class="listItem" :style="{ height: itemHeight + 'px', }" v-for="num in showingList" :key="num" > {{ num }} </div> </div> </div> </template> <style scoped> .window { height: 550px; width: 300px; border: 1px solid red; overflow: auto; /* 设置为相对定位 */ position: relative; } .showingListWrapper { /* 设置为绝对定位 */ position: absolute; left: 0; } .listItem { width: 200px; border: 2px solid #dde5ff; box-sizing: border-box; background-color: #1772f6; color: #fff; } </style>
-
-
更新可视元素top偏移&可视元素内容
这一步做完,虚拟滚动就已经实现了,关键在于top偏移量的计算。
如果直接将
e.target.scrollTop
的值赋给偏移量top.value
,会不像正常滚动,因为元素一直处于window顶端:设置
top.value = startIndex * itemHeight;
:<script setup> import { ref, onMounted } from "vue"; const windowRef = ref(null); const itemHeight = 100; const allData = ref(Array.from({ length: 100 }, (_, i) => i)); const showingList = ref([]); const showingNum = ref(0); const top = ref(0); onMounted(() => { showingNum.value = Math.ceil( parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight ) + 1; showingList.value = allData.value.slice(0, showingNum.value); // 添加滚动条滚动的事件监听 windowRef.value.addEventListener("scroll", handleScroll); }); // 计算可视元素 function handleScroll(e) { const startIndex = Math.floor(e.target.scrollTop / itemHeight); const endIndex = startIndex + showingNum.value; // 更新可视元素 showingList.value = allData.value.slice(startIndex, endIndex); // 计算top偏移量 top.value = startIndex * itemHeight; } </script> <template> <div class="window" ref="windowRef"> <div :style="{ height: itemHeight * allData.length + 'px' }" /> <div class="showingListWrapper" :style="{ top: top + 'px' }"> <div class="listItem" :style="{ height: itemHeight + 'px', }" v-for="num in showingList" :key="num" > {{ num }} </div> </div> </div> </template> <style scoped> .window { height: 550px; width: 300px; border: 1px solid red; overflow: auto; position: relative; } .showingListWrapper { position: absolute; left: 0; } .listItem { width: 200px; border: 2px solid #dde5ff; box-sizing: border-box; background-color: #1772f6; color: #fff; } </style>
-
添加缓冲元素
通过修改
startIndex
、endIndex
的计算方式,可以添加缓冲元素,以防止快速拖动的时候出现的前后空白。可视区域:
添加缓冲元素前:
添加缓冲元素之后:
<script setup> import { ref, onMounted } from "vue"; const windowRef = ref(null); const itemHeight = 100; const bufferNum = 5; // 缓冲数量 const allData = ref(Array.from({ length: 10000 }, (_, i) => i)); const showingList = ref([]); const showingNum = ref(0); const top = ref(0); onMounted(() => { showingNum.value = Math.ceil( parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight ) + 1; showingList.value = allData.value.slice(0, showingNum.value); windowRef.value.addEventListener("scroll", handleScroll); }); function handleScroll(e) { // 防止startIndex小于0 const startIndex = Math.max(Math.floor(e.target.scrollTop / itemHeight) - bufferNum, 0); // 防止endIndex大于allData.value.length const endIndex = Math.min(startIndex + showingNum.value + bufferNum * 2, allData.value.length); showingList.value = allData.value.slice(startIndex, endIndex); top.value = startIndex * itemHeight; } </script> <template> <div class="window" ref="windowRef"> <div :style="{ height: itemHeight * allData.length + 'px' }" /> <div class="showingListWrapper" :style="{ top: top + 'px' }"> <div class="listItem" :style="{ height: itemHeight + 'px', }" v-for="num in showingList" :key="num" > {{ num }} </div> </div> </div> </template> <style scoped> .window { height: 550px; width: 300px; border: 1px solid red; overflow: auto; position: relative; } .showingListWrapper { position: absolute; left: 0; } .listItem { width: 200px; border: 2px solid #dde5ff; box-sizing: border-box; background-color: #1772f6; color: #fff; } </style>
完整代码
vue
<script setup>
import { ref, onMounted } from "vue";
const windowRef = ref(null);
const itemHeight = 100;
const bufferNum = 5;
const allData = ref(Array.from({ length: 10000 }, (_, i) => i));
const showingList = ref([]);
const showingNum = ref(0);
const top = ref(0);
onMounted(() => {
showingNum.value =
Math.ceil(
parseFloat(window.getComputedStyle(windowRef.value).getPropertyValue("height")) / itemHeight
) + 1;
showingList.value = allData.value.slice(0, showingNum.value);
windowRef.value.addEventListener("scroll", handleScroll);
});
function handleScroll(e) {
const startIndex = Math.max(Math.floor(e.target.scrollTop / itemHeight) - bufferNum, 0);
const endIndex = Math.min(startIndex + showingNum.value + bufferNum * 2, allData.value.length);
showingList.value = allData.value.slice(startIndex, endIndex);
top.value = startIndex * itemHeight;
}
</script>
<template>
<div class="window" ref="windowRef">
<div :style="{ height: itemHeight * allData.length + 'px' }" />
<div class="showingListWrapper" :style="{ top: top + 'px' }">
<div
class="listItem"
:style="{
height: itemHeight + 'px',
}"
v-for="num in showingList"
:key="num"
>
{{ num }}
</div>
</div>
</div>
</template>
<style scoped>
.window {
height: 550px;
width: 300px;
border: 1px solid red;
overflow: auto;
position: relative;
}
.showingListWrapper {
position: absolute;
left: 0;
}
.listItem {
width: 200px;
border: 2px solid #dde5ff;
box-sizing: border-box;
background-color: #1772f6;
color: #fff;
}
</style>
react
// jsx文件:
import styles from "./virtualScrolling.module.css";
import { useState, useRef, useEffect } from "react";
function App() {
const windowRef = useRef(null);
const showingListWrapperRef = useRef(null);
const itemHeight = 100;
const bufferNum = 5;
const [allData, setAllData] = useState(Array.from({ length: 10000 }, (_, i) => i));
const [showingList, setShowingList] = useState([]);
const showingNumRef = useRef(0);
useEffect(() => {
const showingNum =
Math.ceil(
parseFloat(window.getComputedStyle(windowRef.current).getPropertyValue("height")) /
itemHeight
) + 1;
setShowingList(allData.slice(0, showingNum));
console.log(allData.slice(0, showingNum));
showingNumRef.current = showingNum;
windowRef.current.addEventListener("scroll", handleScroll);
}, []);
useEffect(() => {}, []);
function handleScroll(e) {
const startIndex = Math.max(0, Math.floor(e.target.scrollTop / itemHeight) - bufferNum);
const endIndex = Math.min(startIndex + showingNumRef.current + bufferNum * 2, allData.length);
showingListWrapperRef.current.style.top = startIndex * itemHeight + "px";
setShowingList(allData.slice(startIndex, endIndex));
}
return (
<div className={styles.window} ref={windowRef}>
<div style={{ height: itemHeight * allData.length }} />
<div ref={showingListWrapperRef} className={styles.showingListWrapper}>
{showingList.map((num) => (
<div
className={styles.listItem}
style={{
height: itemHeight,
}}
key={num}
>
{num}
</div>
))}
</div>
</div>
);
}
export default App;
/* css文件:*/
.window {
height: 550px;
width: 300px;
border: 1px solid red;
overflow: auto;
position: relative;
}
.showingListWrapper {
position: absolute;
left: 0;
top: 0;
}
.listItem {
width: 200px;
border: 2px solid #dde5ff;
box-sizing: border-box;
background-color: #1772f6;
color: #fff;
}
react注意点:
-
由于
showingNum
在组件挂载之后会更新,并且scroll
的事件回调中handleScroll
使用了showingNum
,此时需要使用useRef
钩子包装一层,变成上述代码中的showingNumRef
。如果使用
[showingNum, setShowingNum] = useState(0)
,会由于闭包的存在,导致handleScroll
回调中的showingNum
值一直是初始值0
,导致刚进入页面的时候看不到列表内容。 -
由于react的渲染机制,我们设置
showingListWrapper
的top偏移量的时候,需要使用给dom直接设置top的方式设置showingListWrapperRef.current.style.top = startIndex * itemHeight + "px";
。如果在jsx中使用top来控制top偏移量,会导致滚动时出现闪烁:
const [top, setTop] = useState(0); // ... function handleScroll(e) { // ... setTop(startIndex * itemHeight); // ... } return ( // ... <div style={{ top: top, }} className={styles.showingListWrapper} > // ... </div> );