在做项目的过程中,可能会遇到接口返回大量数据需要展示的场景,这里的数据展示如果不做处理就直接展示的话,将数据全部加载到页面上的过程可能需要很长的时间,就容易导致白屏、卡顿的情况。针对这种情况,我们可以做的处理可以从以下几个方向思考。
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) 的实现:
- 状态初始化:
- 使用
useState 初始化 data 状态,这个状态用于存储当前要渲染的数据列表。
- 效果挂钩(**
useEffect**):
- 当
list 更新时,调用 sliceTime 函数开始分批处理数据。
sliceTime 函数使用 setTimeout 来实现分批加载,每批加载 100 个项目,并在每次加载后更新 data 状态。
- 分批逻辑:
sliceTime 递归调用自身,每次递增 times 参数,直到处理完所有数据。
- 每次递归都切片出新的数据列表,并将其添加到
waitList(等待列表)中,然后更新 data 状态。
- 渲染逻辑:
- 如果
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 的实现:
- 数据初始化:
- 使用
useState 初始化一个非常大的列表(例如,50000 项),模拟大数据场景。
- 利用
useEffect 在组件加载时填充这个列表。
- 渲染:
- 将列表数据传递给
ItemHoc(应用了 SlicingHoc 的 Item 组件)。
子组件 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
ItemHeight = 65
- 占位区域高度,也就是整个列表的高度,用于生成滚动条:
占位区域高度 = 子列表高度 * 列表总数个数
listHeight = ItemHeight * list.length
渲染区域的计算点:其实我们渲染的数据只是可视区和缓冲区,我们可以利用slice对list进行截取,所以在我们还需要知道:
- 索引的起始位置: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: [ ], initItemHeight: 50, })
|
需要记录元素的高度,其次可以存入距离顶部和底部的高度,方便后面计算偏移量和列表的整体高度,在设定一个参数(dHeight)判断新的高度与旧的高度是否一样,不一样的话就进行更新
其中最重要的就是index,它用来记录子列表真实高度的下标,这个点极为重要,原因是:在之前的讲解中,我们发现start 和 end的差值实际上是不变的,也就是说,最终渲染的数据,实际上是一个固定值,但里面的子列表高度却是变值,所以我们需要有一个变量来区分数据所对应的高度,所以这个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
| 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> 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
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; 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; }
|
这样就可以将真实的高度替换虚拟的高度
除了首次的渲染之外,还有就是在start或end改变时重新计算,也就是
1 2 3 4 5 6 7 8
| useCreation(() => { state.data = list.slice(state.start, state.end)
if(ref.current){ setPostition() } }, [state.end])
|