作者|安笺
大量的多媒体内容被用户消费,传统的拼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等等,也例如我们上面的对比图,里面的列表元素卡片分非常多种类,这种再操作上就需要换个思路的,不过换汤不换药,对于非等高的列表元素,可以参考下一篇文档。