一、前言
做前端开发,难以避免的要和无限滚动加载这类交互打交道,简单的滚动加载大家都知道,监听滚动行为和位置,然后发请求加载下一屏数据。在当前vue/react大行其道的时代,数据驱动视图更新,修改数据来新增dom节点到列表元素中就可以实现,而且这种行为在大部分产品或场景中都没多大问题,但当代随着社交媒体的流行,大量的视频、图片、文字等数据被用户消费,传统的拼dom元素的滚动方案在性能上就存在下次,海量的数据早就大量的元素节点产生,从而会导致页面滚动的卡顿,那么有什么好的方案让用户浏览海量数据?
二、谷歌LightHouse开发推荐
chrome影响页面性能的因素:
- 总共有超过 1,500 个节点。
- 具有大于 32 个节点的深度。
- 有一个超过 60 个子节点的父节点。
三、效果对比
内容社交消耗是目前网上用户消费最多,而且加载数据最多的一种类型,下方是在数据大概2000条左右常规加载和虚拟滚动实现的效果对比:
常规方案在数据量小,dom元素少的情况下,也是非常流畅,但是在数据量达到一定程度,dom元素量过大时,渲染时间就会急剧增多,滚动将变得滞后,灵敏度下降。
四、滚动方式介绍
原理:用固定个数的元素来模拟无线滚动加载,通过位置的布局来达到滚动后的效果
由于无限滚动的列表元素高度是和产品设计的场景有关,有定高的,也有不定高的。
- 定高:滚动列表中子元素高度相等。
- 不定高:滚动列表中子元素高度都随机且不相等。
4.1 不定高方案
由于开发中遇到的不一定是等高的元素,例如刚开始所看到的内容,有好几类内容交互卡片,纯文字的,文字加视频的,文字加一张图的,文字加多张图的,纯图片的,纯文字的等等。元素卡片高度都是不相等的。高度不定,而且返回的内容是不固定的,所以每次返回的内容的可能组成非常多的方式,这样上面的方案就不适用了。
4.1.1 实现原理
通过观察者方式,来观察元素是否进入视口。我们会对固定元素的第一个和最后一个分别打上标签,例如把第一个元素的id设置为top,把最后一个元素的id值设置为bottom。
此时调用异步的api:IntersectionObserver,他能获取到进入到视口的元素,判断当前进入视口的元素是最后个元素,则说明内容是往上滚的,如果进入视口的是第一个元素,则说明内容是往下滚的。
我们依次保存下当前第一个元素距离顶部的高度和距离底部的高度,赋值给滚动内容元素的paddingTop和paddingBottom,这样内容区域的高度就不会坍塌,依旧保持这传统滚动元素充满列表时的内容高度:
4.1.2 滚动效果
4.1.3 实现方式
我们首先定义交叉观察者:IntersectionObserver。
IntersectionObserver API是异步的,不随着目标元素的滚动同步触发,性能消耗极低。
获取到指定元素,然后让其进行绑定监听
const box = document.querySelector('xxxx');
const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 进入可视区域
....
}
})
}, {
threshold: [0, 0.5],
root: document.querySelector('.wrap'),
rootMargin: "10px 10px 30px 20px",
});
intersectionObserver.observe(box);
复制代码
元素绑定位置,在元素的第一个值和最后一个值上挂上ref,方便获取指定的dom元素,这里我们一共使用20个元素来实现无线滚动加载
const NODENUM = 20;
...
const $bottomElement = useRef();
const $topElement = useRef();
const $downAnchor:any = useRef();
const $upAnchor:any = useRef();
...
const getReference = (index) => {
switch (index) {
case 0:
return $topElement;
case 5:
return $upAnchor;
case 10:
return $downAnchor;
case (NODENUM - 1):
return $bottomElement;
default:
return null;
}
}
复制代码
定义起始元素和截止元素,在第一个元素和最后一个元素上绑定id值:top && bottom。通过调用getReference方法获取ref变量绑定在元素上
<div className="container">
<div ref={$wrap} style={{ paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`, 'position': 'relative' }}>
{currentArr.slice(start, end).map((item, index) => {
const refVal = getReference(index);
const id = index === 0 ? 'top' : (index === (NODENUM - 1) ? 'bottom' : '');
const classValue = index % 4 === 0 ? 'test' : ''
return <div id={id} ref={refVal} key={item}>
<div className={`item ${classValue}`}>
{item}
</div>
</div>
})}
</div>
</div>
复制代码
接下来就是核心监听滚动的处理逻辑了,由于IntersectionObserver接收一个回调函数,传入回调函数的参数为该元素的一些相关属性值,我们通过对元素的id进行判断来区分当前是向上滚动还是向下滚动,同时改变起始值start和结束值end去切分数据数组,通过数据去驱动视图重新渲染。
entry中包含的isIntersecting表示是否进入可视区域。
entry.target.id则为进入可视区域的元素的id,我们在第0个元素上绑定id为top,在最后一个元素上绑定id为bottom
const callback = (entries, observer) => {
entries.forEach((entry, index) => {
const listLength = currentArr.length;
// 向下滚动
if (entry.isIntersecting && entry.target.id === "bottom") {
const maxStartIndex = listLength - 1 - NODENUM;
const maxEndIndex = listLength - 1;
const newStart = (end - 10) <= maxStartIndex ? end - 10 : maxStartIndex;
const newEnd = (end + 10) <= maxEndIndex ? end + 10 : maxEndIndex;
// 加载更多数据,这里模拟了每次拉新数据40条
if (newEnd + 10 >= maxEndIndex && !config.current.isRequesting && true) {
currentArr.push(...arr.slice(i * 40, (i + 1)* 40))
i++;
}
if (end + 10 > maxEndIndex) return;
updateState(newStart, newEnd, true);
}
// 向上滚动
if (entry.isIntersecting && entry.target.id === "top") {
const newEnd = end === NODENUM ? NODENUM : (end - 10 > NODENUM ? end - 10 : NODENUM);
const newStart = start === 0 ? 0 : (start - 10 > 0 ? start - 10 : 0);
updateState(newStart, newEnd, false);
}
});
}
复制代码
封装一个paddingTop和paddingBottom更新的函数,如果有当前的padding值,则取出渲染,如果没有,则保存下当前的paddingTop和paddingBottom。
const updateState = (newStart, newEnd, isDown) => {
if (config.current.setting) return;
config.current.syncStart = newStart;
if (start !== newStart || end !== newEnd) {
config.current.setting = true;
setStart(newStart);
setEnd(newEnd);
const page = ~~(newStart / 10) - 1;
if (isDown) { //向下
newStart !== 0 && !config.current.paddingTopArr[page] && (config.current.paddingTopArr[page] = $downAnchor.current.offsetTop);
// setPaddingTop(check ? config.current.paddingTopArr[page] : $downAnchor.current.offsetTop);
setPaddingTop(config.current.paddingTopArr[page]);
setPaddingBottom(config.current.paddingBottomArr[page] || 0);
}else { //向上
// const newPaddingBottom = $wrap.current.scrollHeight - $upAnchor.current.offsetTop;
const newPaddingBottom = $wrap.current.scrollHeight - $downAnchor.current.offsetTop;
newStart !== 0 && (config.current.paddingBottomArr[page] = newPaddingBottom);
setPaddingTop(config.current.paddingTopArr[page] || 0);
// setPaddingBottom(check ? config.current.paddingBottomArr[page] : newPaddingBottom);
setPaddingBottom(config.current.paddingBottomArr[page]);
}
setTimeout(() => {config.current.setting = false;},0);
}
}
复制代码
4.1.4 白屏问题
在开发过程中发现,数据量非常大的情况下,快速滚动页面,由于api是异步的,导致更新方法触发没跟上,所以需要对滚动事件做一个监听,判断是否当前的滚动高度已经更新过了,没更新则通过滚动结束后强行更新,当然,一定要记得对这个滚动监听事件加一个节流
4.1.5 兼容问题
在safari的兼容问题,可以使用polyfill来解决。
4.1.6 整体代码
// index.less
.item {
width: 100vw;
height: 240rpx;
border-bottom: 2rpx solid black;
}
.test {
height: 110rpx;
}
.container {
width: 100vw;
height: 100vh;
overflow: scroll;
-webkit-overflow-scrolling: touch;
}
复制代码
// index.tsx
import { createElement, useEffect, useRef, useState } from "rax";
import "./index.less";
const arr = [];
// 模拟一共有2万条数据
for (let i = 0; i < 20000; i++) {
arr.push(i);
}
let i = 1;
// 默认第一屏取2页数据
const currentArr = arr.slice(0, 40), screenH = window.screen.height;
const NODENUM = 20;
function throttle(fn, wait) {
var timeout;
return function() {
var ctx = this, args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function() {
fn.apply(ctx, args);
}, wait);
};
}
function Index(props) {
const [start, setStart] = useState(0);
const [end, setEnd] = useState(NODENUM);
const [paddingTop, setPaddingTop] = useState(0);
const [paddingBottom, setPaddingBottom] = useState(0);
const [observer, setObserver] = useState(null);
const $bottomElement = useRef();
const $topElement = useRef();
const $downAnchor:any = useRef(); //定位paddingTop的距离
const $upAnchor:any = useRef(); //定位paddingBottom的距离
const $wrap:any = useRef(); //协助定位paddingBottom的距离
const container = useRef();
const config = useRef({
isRequesting: false,
paddingTopArr: [], //paddingTop数据栈
paddingBottomArr: [], //paddingBottom数据栈
preScrollTop: 0,
syncStart: 0,
setting: false,
});
const getReference = (index) => {
switch (index) {
case 0:
return $topElement;
case 5:
return $upAnchor;
case 10:
return $downAnchor;
case (NODENUM - 1):
return $bottomElement;
default:
return null;
}
}
const resetObservation = () => {
observer && observer.unobserve($bottomElement.current);
observer && observer.unobserve($topElement.current);
}
const intiateScrollObserver = () => {
const options = {
root: null,
rootMargin: '0px',
threshold: 0.1
};
const Observer = new IntersectionObserver(callback, options);
if ($topElement.current) {
Observer.observe($topElement.current);
}
if ($bottomElement.current) {
Observer.observe($bottomElement.current);
}
setObserver(Observer);
}
const callback = (entries, observer) => {
entries.forEach((entry, index) => {
const listLength = currentArr.length;
// 向下滚动
if (entry.isIntersecting && entry.target.id === "bottom") {
const maxStartIndex = listLength - 1 - NODENUM;
const maxEndIndex = listLength - 1;
const newStart = (end - 10) <= maxStartIndex ? end - 10 : maxStartIndex;
const newEnd = (end + 10) <= maxEndIndex ? end + 10 : maxEndIndex;
if (newEnd + 10 >= maxEndIndex && !config.current.isRequesting && true) {
currentArr.push(...arr.slice(i * 40, (i + 1)* 40))
i++;
}
if (end + 10 > maxEndIndex) return;
updateState(newStart, newEnd, true);
}
// 向上滚动
if (entry.isIntersecting && entry.target.id === "top") {
const newEnd = end === NODENUM ? NODENUM : (end - 10 > NODENUM ? end - 10 : NODENUM);
const newStart = start === 0 ? 0 : (start - 10 > 0 ? start - 10 : 0);
updateState(newStart, newEnd, false);
}
});
}
const updateState = (newStart, newEnd, isDown) => {
if (config.current.setting) return;
config.current.syncStart = newStart;
if (start !== newStart || end !== newEnd) {
config.current.setting = true;
setStart(newStart);
setEnd(newEnd);
const page = ~~(newStart / 10) - 1;
if (isDown) { //向下
newStart !== 0 && !config.current.paddingTopArr[page] && (config.current.paddingTopArr[page] = $downAnchor.current.offsetTop);
// setPaddingTop(check ? config.current.paddingTopArr[page] : $downAnchor.current.offsetTop);
setPaddingTop(config.current.paddingTopArr[page]);
setPaddingBottom(config.current.paddingBottomArr[page] || 0);
}else { //向上
// const newPaddingBottom = $wrap.current.scrollHeight - $upAnchor.current.offsetTop;
const newPaddingBottom = $wrap.current.scrollHeight - $downAnchor.current.offsetTop;
newStart !== 0 && (config.current.paddingBottomArr[page] = newPaddingBottom);
setPaddingTop(config.current.paddingTopArr[page] || 0);
// setPaddingBottom(check ? config.current.paddingBottomArr[page] : newPaddingBottom);
setPaddingBottom(config.current.paddingBottomArr[page]);
}
setTimeout(() => {config.current.setting = false;},0);
}
}
useEffect(() => {
document.getElementsByClassName('container')[0].addEventListener('scroll', scrollEventListner);
}, [])
useEffect(() => {
resetObservation();
intiateScrollObserver();
}, [end, currentArr])
const scrollEventListner = throttle(function (event) {
const scrollTop = document.getElementsByClassName('container')[0].scrollTop;
let index = config.current.paddingTopArr.findIndex(e => e > scrollTop);
index = index <= 0 ? 0 : index;
const len = config.current.paddingTopArr.length;
len && (config.current.paddingTopArr[len - 1] < scrollTop) && (index = len);
const newStart = index * 10;
const newEnd = index * 10 + NODENUM;
if (newStart === config.current.syncStart) {
config.current.preScrollTop = scrollTop;
return;
}
updateState(newStart, newEnd, scrollTop > config.current.preScrollTop); //true为往下滚动 false为往上滚动
config.current.preScrollTop = scrollTop;
}, 100);
return (
<div className="container">
<div ref={$wrap} style={{ paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`, 'position': 'relative' }}>
{currentArr.slice(start, end).map((item, index) => {
const refVal = getReference(index);
const id = index === 0 ? 'top' : (index === (NODENUM - 1) ? 'bottom' : '');
const classValue = index % 4 === 0 ? 'test' : ''
return <div id={id} ref={refVal} key={item}>
<div className={`item ${classValue}`}>
{item}
</div>
</div>
})}
</div>
</div>
);
}
export default Index;
复制代码
五、总结
定高和不定高2类虚拟滚动,适合用户消费海量数据的场景,尤其是社交媒体这类数据消费。而像通讯录,好友列表这类数据量单一且不会特别多的情况,还是用传统的滚动体验会更好。技术选型就按照自己业务的实际需要去出发选择,千万不要强行套用。
关于懒加载这块的问题,需要根据自己的实际情况来判断是否让元素强行更新,在react下,默认的元素key应该用元素遍历的map索引值来表达。
列表元素等高的场景 ,可以参考《虚拟滚动 - 等高元素无限滚动加载解决方案》
六、其他推荐
虚拟滚动 - 等高元素无限滚动加载解决方案:juejin.cn/post/701847…