无限滚动加载解决方案之虚拟滚动(上)
Posted 阿里巴巴淘系技术团队官网博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了无限滚动加载解决方案之虚拟滚动(上)相关的知识,希望对你有一定的参考价值。
大量的多媒体内容被用户消费,传统的拼dom元素的滚动方案会导致页面滚动的卡顿,有什么好的方案让用户浏览海量数据?
前言
做前端开发,难以避免的要和无限滚动加载这类交互打交道,简单的滚动加载大家都知道,记录某个元素的位置,在该元素即将到达视口时触发去请求加载下一屏数据的行为。在当前vue/react大行其道的时代,数据驱动视图更新,修改数据来新增dom节点到列表元素中就可以实现,而且这种行为在大部分产品或场景中都没多大问题,但当代随着社交媒体的流行,大量的视频、图片、文字等数据被用户消费,传统的拼dom元素的滚动方案在性能上就存在瑕疵,海量的数据造就大量的元素节点产生,从而会导致页面滚动的卡顿,那么有什么好的方案让用户浏览海量数据?
谷歌LightHouse开发推荐
chrome影响页面性能的因素:
总共有超过 1,500 个节点。
具有大于 32 个节点的深度。
有一个超过 60 个子节点的父节点。
效果对比
内容社交消耗是目前网上用户消费最多,而且加载数据最多的一种类型,下方是在数据大概2000条左右常规加载和虚拟滚动实现的效果对比:
常规滚动
虚拟滚动
常规方案在数据量小,dom元素少的情况下,也是非常流畅,但是在数据量达到一定程度,dom元素量过大时,渲染时间就会急剧增多,滚动将变得滞后,灵敏度下降。
滚动方式介绍
原理:用固定个数的元素来模拟无线滚动加载,通过位置的布局来达到滚动后的效果。
由于无限滚动的列表元素高度是和产品设计的场景有关,有定高的,也有不定高的。
定高:滚动列表中子元素高度相等。
不定高:滚动列表中子元素高度都随机且不相等。
▐ 元素定高方案
实现原理
如图所示,我们只渲染固定个数的dom元素,一个视口高度是固定的,子元素的高度也是固定的,我们可以推算出一个视口最多可以看到多少个元素
domNum = window.screen.height / itemHeight
在这个基础上上下增加3~5个元素即可,例如一共可以显示4个元素,上下视口溢出各加3,一共需要10个dom元素来实现虚拟滚动。滚动后,每个元素距离父类都会有个偏移高度,默认的高度就是这个元素之上所有的兄弟元素的高度之和,我们这里只采用固定个数的元素,则视口内的元素上面并没有那么多的兄弟,但有需要该元素距离视口有那么多的偏移高度,则通过transform或者top值来把该元素的位置钉住,来模拟滚动后自己需要处在的位置上。
滚动效果
实现方式
忽略业务相关的任何东西,我们单纯来模拟打造这一套虚拟滚动。我们先通过数组模拟定义2万条数据,并创建一个对象currentArr来表示当前已经获取到的数据。我们模拟一页10条数据,刚进来的时候“后端”放回20条数据,即第一次请求了2页数据(每页10条数据)
...
const arr = [];
for (let i = 0; i < 20000; i++) {
arr.push(i);
}
const currentArr = arr.slice(0, 19) // 默认取前2页(1页10条数据)
...
我们采用3级深度的dom结构
第一层是100vh高度的容器,允许滚动
第二层是所有元素组成高度,可以理解成是一个空有高度的空白元素,这个高度是当前已经获取的所有元素的总高度
第三层是固定元素渲染层
可滚动的元素高度我们需要先撑开。
我们可以动态计算当前元素一共需要多少高度的空间, itemHeight是每一个列表元素的高度,也可以让后端直接返回给我们一共有多少个元素,然后直接全部撑开, 那么height则为total * itemHeight
...
<div className="main" ref={slider} onScroll={throttle(scroll, 200)}>
<div
className="wrap"
style={{
height: currentArr.length * itemHeight * 2, // 这里默认是2倍屏
}}
>
{currentArr.slice(startInex, endIndex).map((item, index) => {
return (
<div
className="item"
key={index}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: `translateY(${(startInex + index) * itemHeight}px)`,
}}
>
{item}
</div>);}
)
}
</div>
</div>
...
这里需获取要展示数组数据里的起始位置和结束位置
第一个元素位置 = 滚动距离/列表元素的高度。
最后一个元素位置 = 第一个元素位置 + 视口内最多展示元素的个数。
拿到起始位置和结束位置来切割数据数组,每次就取固定个数的元素来进行重绘渲染
const scrollTop = slider.current.scrollTop; // 滚动距离
let currentStartIndex = Math.floor(scrollTop / itemHeight); // 起始索引
let currentEndIndex = currentStartIndex + Math.ceil(screenH / itemHeight); // 终点索引
计算元素偏移位置
通过第一个元素的的索引值 + 当前元素数组索引值 可以计算距离父元素顶部的高度, 作用在元素的transform属性上,使其定位在固定的高度偏移量上,这样就大功告成了。
<div
className="item"
key={index}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: `translateY(${(startInex + index) * itemHeight}px)`,
}}
>
整体代码
// index.less
.main {
width: 100vw;js
height: 100vh;
overflow: scroll;
position: relative;
}
.item {
width: 100vw;
height: 180rpx;
border-bottom: 2rpx solid black;
}
// index.js
import { createElement, useRef, useState } from "rax";
import "./index.less";
const arr = [];
// 模拟一共有2万条数据
for (let i = 0; i < 20000; i++) {
arr.push(i);
}
// 默认第一屏取2页数据
const currentArr = arr.slice(0, 19), screenH = window.screen.height;
let i = 2, isReqeust = false;
function throttle(func, delay){
var timer = null;
return function(){
var context = this;
var args = arguments;
if(!timer){
timer = setTimeout(function(){
func.apply(context, args);
timer = null;
}, delay);
}
}
}
function Index(props) {
const { itemHeight = 90 } = props;
const [startInex, setStartIndex] = useState(0);
const [endIndex, setEndIndex] = useState(9);
const [forceUpdate, setForceUpdate] = useState(false);
const slider = useRef(null);
const scroll = () => {
const scrollTop = slider.current.scrollTop; // 滚动距离
if (currentArr.length * itemHeight - scrollTop - screenH < 400 && !isReqeust ) {
isReqeust = true;
// 加载下一页数据
setTimeout(() => {
currentArr.push(...arr.slice(i* 10, i*10 + 9));
i++;
scroll();
isReqeust = false;
setForceUpdate(!forceUpdate)
}, 500)
return;
}
let currentStartIndex = Math.floor(scrollTop / itemHeight); // 起始索引
let currentEndIndex = currentStartIndex + Math.ceil(screenH / itemHeight); // 终点索引
if (currentStartIndex === startInex && currentEndIndex === endIndex) return
requestAnimationFrame(() => {
setStartIndex(currentStartIndex)
setEndIndex(currentEndIndex)
});
}
return (
<div className="main" ref={slider} onScroll={throttle(scroll, 200)}>
<div
className="wrap"
style={{
height: currentArr.length * itemHeight * 2, // 这里默认是2倍屏
}}
>
{currentArr.slice(startInex, endIndex).map((item, index) => {
return (
<div
className="item"
key={index}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: `translateY(${(startInex + index) * itemHeight}px)`,
}}
>
{item}
</div>
);
})}
</div>
</div>
);
}
export default Index;
总结
列表元素等高的方案相对比较容易实现,而且方案很多,有作用在父元素上整体使用transform的,也有作用在每个单一元素上使用transform的,甚至通过top、paddingTop等等各种位置布局属性方案来实现的。
固定元素在渲染上能占到非常大的优势,通过key绑定保障元素性能甚至可以在20个固定元素的场景下渲染做到3ms,但是应用场景往往是多变的,我们的元素等高的场景只是一种,往往还存在非等高的场景,例如微薄、Twitter等等,也例如我们上面的对比图,里面的列表元素卡片分非常多种类,这种再操作上就需要换个思路的,不过换汤不换药,对于非等高的列表元素,可以参考下一篇文档。
✿ 拓展阅读
作者|安笺
编辑|橙子君
出品|阿里巴巴新零售淘系技术
以上是关于无限滚动加载解决方案之虚拟滚动(上)的主要内容,如果未能解决你的问题,请参考以下文章