在做项目的过程中,可能会遇到接口返回大量数据需要展示的场景,这里的数据展示如果不做处理就直接展示的话,将数据全部加载到页面上的过程可能需要很长的时间,就容易导致白屏、卡顿的情况。针对这种情况,我们可以做的处理可以从以下几个方向思考。

1、分页查询

这个解决方案是从改变设定的方向上解决问题,返回的数据少了自然就不会花大量的时候在渲染数据的步骤上。但如果有些场景就是需要一次性展示大量的数据,这种方案自然就被pass掉了。

2、分片渲染

这个解决方案是改变之前一次性渲染全部数据的做法,改为分次渲染,简单的说就是一个执行完再执行下一个,其思想是建立一个队列,通过定时器来进行渲染,比如说一共有3次,先把这三个放入到数组中,当第一个执行完成后,并剔除执行完成的,在执行第二个,直到全部执行完毕,渲染队列清空。

(1)定时器实现

可以通过设置定时器来进行渲染,通过设置一个等待队列(waitList)是否渲染(isRender)的条件去做。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { useEffect, useState } from 'react';

let waitList:any = [] //等待队列

const HOC = (Component:any) => ({list, ...props}:any) => {

const [data, setData] = useState<any>([])


useEffect(() => {
if(list.length !== 0){
sliceTime(list, 0)
}
}, [list])

const sliceTime = (list:any[], times = 0, number:number = 100) => {
if(times === (Math.ceil(list.length / number) + 1)) return //判断条件
setTimeout(() => {
const newList:any = list.slice(times * number, (times + 1) * number)
waitList = [...waitList, ...newList]
setData(waitList)
sliceTime(list, times + 1)
}, 500);

}

if(list.length === 0) return <></>

return <>{
data.map((item:any) => <Component id={item} {...props} key={item} />)
}</>
}

export default HOC;


高阶组件 (HOC) 的实现:

  1. 状态初始化
    • 使用 useState 初始化 data 状态,这个状态用于存储当前要渲染的数据列表。
  2. 效果挂钩(**useEffect**
    • list 更新时,调用 sliceTime 函数开始分批处理数据。
    • sliceTime 函数使用 setTimeout 来实现分批加载,每批加载 100 个项目,并在每次加载后更新 data 状态。
  3. 分批逻辑
    • sliceTime 递归调用自身,每次递增 times 参数,直到处理完所有数据。
    • 每次递归都切片出新的数据列表,并将其添加到 waitList(等待列表)中,然后更新 data 状态。
  4. 渲染逻辑
    • 如果 list 为空,则不渲染任何内容。
    • 使用 data 中的项目渲染 Component 组件,传递 id 和其他 props
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import React,{ useEffect, useState } from 'react';
import img from './img.jpeg'
import { SlicingHoc } from '@/components';

// 子组件
const Item:React.FC<{id: any}> = ({id}) => {

return (
<div style={{display: 'flex', alignItems: 'center', padding: 5}}>
<img src={img} width={80} height={60} alt="" />列表{id}
</div>
)
}

const ItemHoc = SlicingHoc(Item)

const Index:React.FC<any> = (props)=> {

const [list, setList] = useState<Array<number>>([])

useEffect(() => {
let arr:number[] = []
for(let i = 0; i < 50000; i++){
arr.push(i)
}
setList(arr)
}, [])

return (
<div>
<ItemHoc list={list} />
</div>
);
}

export default Index;


主组件 Index 的实现:

  1. 数据初始化
    • 使用 useState 初始化一个非常大的列表(例如,50000 项),模拟大数据场景。
    • 利用 useEffect 在组件加载时填充这个列表。
  2. 渲染
    • 将列表数据传递给 ItemHoc(应用了 SlicingHocItem 组件)。

子组件 Item 的实现:

  • 简单渲染传入的 id 和图片,展示如何在每个列表项中使用数据。

(2)RAF的实现

requestAnimationFrame 在重新渲染之前执行,使用一个回调函数作为参数,可以在浏览器进行下一个渲染前执行回调。
浏览器页面刷新频率一般与设备保持一致,当页面每秒绘制的帧数(FPS)达到 60 时,人眼才会觉得流畅。

通过 requestAnimationFrame 我们可以由浏览器来决定回调函数的执行时机,并将大量数据的多次渲染分为多个片段,在每个片段中解析定量数据交给浏览器渲染,第一时间将页面展现给用户。

(3)存在问题

  • 时间分片相当于代码替用户去触发懒加载,DOM 是逐次渲染的,渲染消耗的总时间肯定比一次渲染所有 DOM 要慢不少
  • 因为页面是逐渐渲染的,如果直接把滚动条拖到底部看到的并不是最后的数据,需要等待渲染完成
  • 实际开发出的代码不是一个<tr> or <li>标签加数据绑定这么简单,随着 dom 结构的复杂度(事件监听、样式、子节点…)和 dom 数量的增加,占用的内存也会更多,不可避免的影响页面性能。

3、虚拟列表

虚拟列表是上述问题的一种解决方案,是按需显示的一种实现,只对可见区域渲染,对非可见区域不渲染或部分渲染,从而减少性能消耗。

虚拟列表将完整的列表分为三个区域:虚拟区 / 缓冲区 / 可视区

  • 虚拟区为非可见区域不进行渲染
  • 缓冲区为后续优化滚动白屏使用,暂不渲染
  • 可视区为用户视窗内的数据,需要渲染对应的列表项

(1)定高的实现

布局

在这个组件中,首先要做的事情就是布局,我们需要有两块区域:

  • 占位区域:在前面提到的分片渲染中,滚动条也在变化,这是因为列表渲染的数据在增加,把内容组件撑开,造成高度上的变化,所以在虚拟列表中,专门提供一个div,用来占位,这样在一进来的时候滚动条就不会产生变化
  • 渲染区域:这块部分为真正用户看到的列表区域,实际上有可视区缓冲区共同组成,缓冲区的作用是防止快速下滑或者上滑的过程中出现空白区域

其次我们需要一个整体的div,通过监听占位区域的滚动条,判断当前截取数组的区域,所以大体的结构是这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div ref={allRef}>
<div
ref={scrollRef}
>
{/* 占位,列表的总高度,用于生成滚动条 */}
<div></div>
{/* 内容区域 */}
<div>
{/* 渲染区域 */}
{
state.data.map((item:any) => <div key={item}>
{/* 子组件 */}
<Component id={item} {...props}/>
</div>)
}
</div>
</div>
</div>

参数设置

相关容器的高度

  • 列表总共的个数(总列表数):

设置为 list

  • 容器的高度: 当前组件所占的位置(可通过传值控制)

scrollAllHeight = allRef.current.offsetHeight

  • 子列表高度:子组件的高度(目前固定为65)

ItemHeight = 65

  • 占位区域高度,也就是整个列表的高度,用于生成滚动条:

占位区域高度 = 子列表高度 * 列表总数个数

listHeight = ItemHeight * list.length

渲染区域的计算点:其实我们渲染的数据只是可视区缓冲区,我们可以利用slicelist进行截取,所以在我们还需要知道:

  • 索引的起始位置:start
  • 索引的结束位置:end
  • 缓冲个数:bufferCount
  • 需要渲染的节点数量(可视区能渲染几个节点)

渲染节点的数量 = 容器的高度 / 子列表高度 (需要向上取整) + 缓冲个数

const renderCount = Math.ceil(scrollAllHeight / ItemHeight)+ state.bufferCount

滚动计算

在这里我使用useEventListener去监听滚动事件,是一个可以监听任何函数的自定义hooks
我们要拿到滚动条距离顶部的高度,然后计算对应的索引起始和结束位置,再截取对应的数据给到data就OK了,并且计算对应的偏移量,也就是:

1
2
3
4
5
6
7
8
9
useEventListener('scroll', () => {
// 顶部高度
const { scrollTop } = scrollRef.current
state.start = Math.floor(scrollTop / state.itemHeight)
state.end = Math.floor(scrollTop / state.itemHeight + state.renderCount + 1)
state.currentOffset = scrollTop - (scrollTop % state.itemHeight)
state.data = list.slice(state.start, state.end)
}, scrollRef)

(2)不定高的实现

定高很简单,我们只需要手动计算下列表的高度,将值传入就行,但不定高就很麻烦了,因为你无法计算出每个高度的情况,导致列表的整体高度偏移量都无法正常的计算

对于子列表的动态高度,我们有两种处理的方案:

1.第一种,将ItemHeight作为参数传递过来,我们可以根据传递数组来控制,但这种情况需要我们提前将列表的高度算出来,首先,算每个子列表的高度很麻烦,其次,这个高度还要根据屏幕的大小去变化,这个方法明显不适合。
2.第二种,预算高度,我们可以假定子列表的高度也就是虚假高度(initItemHeight),当我们渲染的时候,再更新对应高度,这样就可以解决子列表高度的问题。

针对第二种方案,我们需要去维护一个公共的高度列表(positions),这个数组将会记录真实的DOM高度

那么positions需要记录以下这些信息:

1
2
3
4
5
6
7
8
9
10
11
12
const state = useReactive<any>({
...,
positions: [ //需要记录每一项的高度
// index // 当前pos对应的元素的下标
// top; // 顶部位置
// bottom // 底部位置
// height // 元素高度
// dHeight // 用于判断是否需要改变
],
initItemHeight: 50, // 预计高度
})

需要记录元素的高度,其次可以存入距离顶部和底部的高度,方便后面计算偏移量和列表的整体高度,在设定一个参数(dHeight)判断新的高度与旧的高度是否一样,不一样的话就进行更新

其中最重要的就是index,它用来记录子列表真实高度的下标,这个点极为重要,原因是:在之前的讲解中,我们发现startend的差值实际上是不变的,也就是说,最终渲染的数据,实际上是一个固定值,但里面的子列表高度却是变值,所以我们需要有一个变量来区分数据所对应的高度,所以这个index就变的尤为重要

所以在这里我们设置一个ref用来监听子节点node,来获取真实高度,这里我设置id来判断对应的索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
 // list:数据改变
let arr:any[] = []
for(let i = 0; i < 100; i++){
arr.push({
id: i, //设置唯一值
content: Mock.mock('@csentence(40, 100)') // 内容
})
}
setList(arr)


// 渲染数据
{/* 内容区域 */}
<div ref={ref} style={{ transform: `translate3d(0, ${state.currentOffset}px, 0)`, position: 'relative', left: 0, top: 0, right: 0}}>
{/* 渲染区域 */}
{
state.data.map((item:any) => <div id={String(item.id)} key={item.id}>
{/* 子组件 */}
<Component id={item.content} {...props} index={item.id} />
</div>)
}
</div>

//初始的positions
useEffect(() => {
// 初始高度
initPositions()
}, [])

const initPositions = () => {
const data = []
for (let i = 0; i < list.length; i++) {
data.push({
index: i,
height: state.initItemHeight,
top: i * state.initItemHeight,
bottom: (i + 1) * state.initItemHeight,
dHeight: 0
})
}
state.positions = [...data]
}

初始计算

我们要改变的是子列表的高度列表的高度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
useEffect(() => {

// 子列表高度:为默认的预计高度
const ItemHeight = state.initItemHeight

// // 容器的高度
const scrollAllHeight = allRef.current.offsetHeight

// 列表高度:positions最后一项的bottom
const listHeight = state.positions[state.positions.length - 1].bottom;

//渲染节点的数量
const renderCount = Math.ceil(scrollAllHeight / ItemHeight)

state.renderCount = renderCount
state.end = renderCount + 1
state.listHeight = listHeight
state.itemHeight = ItemHeight
state.data = list.slice(state.start, state.end)
}, [allRef, list.length])

这里要注意一点的是:预计高度尽量要小点,可以多加载,但不能少,防止渲染不全

更新具体的高度

当我们第一遍把列表的数据渲染成功后,就更新positions的高度,将真实的高度替换一开始的虚拟高度,并将整体的高度进行更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
useEffect(() => {
setPostition()
}, [ref.current])

const setPostition = () => {
const nodes = ref.current.childNodes
if(nodes.length === 0) return
nodes.forEach((node: HTMLDivElement) => {
if (!node) return;
const rect = node.getBoundingClientRect(); // 获取对应的元素信息
const index = +node.id; // 可以通过id,来取到对应的索引
const oldHeight = state.positions[index].height // 旧的高度
const dHeight = oldHeight - rect.height // 差值
if(dHeight){
state.positions[index].height = rect.height //真实高度
state.positions[index].bottom = state.positions[index].bottom - dHeight
state.positions[index].dHeight = dHeight //将差值保留
}
});

// 重新计算整体的高度
const startId = +nodes[0].id

const positionLength = state.positions.length;
let startHeight = state.positions[startId].dHeight;
state.positions[startId].dHeight = 0;

for (let i = startId + 1; i < positionLength; ++i) {
const item = state.positions[i];
state.positions[i].top = state.positions[i - 1].bottom;
state.positions[i].bottom = state.positions[i].bottom - startHeight;
if (item.dHeight !== 0) {
startHeight += item.dHeight;
item.dHeight = 0;
}
}

// 重新计算子列表的高度
state.itemHeight = state.positions[positionLength - 1].bottom;
}

这样就可以将真实的高度替换虚拟的高度

除了首次的渲染之外,还有就是在startend改变时重新计算,也就是

1
2
3
4
5
6
7
8
useCreation(() => {
state.data = list.slice(state.start, state.end)

if(ref.current){
setPostition()
}
}, [state.end])