在特定的场景下,接口会返回大量的数据,渲染这种列表叫做长列表,那么处理这种长列表我们有比较常见的两种方式:分片渲染
和 虚拟列表
,接下来的内容中我们将来学习一下这两个知识点。
为什么不能直接渲染?
很多小伙伴可能搞不懂为什么长列表不能直接渲染,还需要分片渲染或者虚拟列表。
接下来我们写一个小小的 demo 来了解一下长列表的渲染会有什么问题,如下代码:
tsx
import React, { useState, useEffect } from "react";
const Index = () => {
const [flag, setFlag] = useState<boolean>(false);
const [list, setList] = useState<Array<number>>([]);
const renderHandle = () => {
setFlag(true);
let arr: number[] = [];
console.time("默认渲染");
for (let i = 0; i < 50000; i++) {
arr.push(i);
}
setList(arr);
};
useEffect(() => {
if (flag) {
console.timeEnd("默认渲染");
}
}, [list, flag]);
return (
<div style={{ width: "100%" }}>
<button onClick={renderHandle}>点击开始渲染</button>
<div>
{flag &&
list.map((item) => (
<div key={item} style={{ width: "100%", height: "60px" }}>
{item}
</div>
))}
</div>
</div>
);
};
export default Index;
在上面的这些代码中,我们模拟服务端给我们返回了一万条数据,并且使用了一个按钮来获取这些数据并来实现渲染,并且通过 console.time()和 console.timeEnd()计算一下加载这五万条数据需要多长时间。
可以看到加载的时间大概为 4 秒,我们这条数据只是单纯的一个值,如果还有其他的内容呢,例如图片,这样的速度明显是一坨答辩,而且在真实情况下很容易出现白屏,卡顿的现象,这明显不是我们想要的情况。
对五万条记录进行循环操作,JS 的运行时间是很短的,但是最终的时间是需要达到了快四秒的,这是因为 JS 的事件循环中,当 JS 引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染。
所以大量数据渲染的时候,JS 运行并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段。
分片渲染
分片渲染是一种按顺序执行任务的方法,其核心思想在于创建一个任务队列,并利用定时器逐个处理这些任务。假设有三个渲染任务,这个方法首先会将这三个任务添加到一个数组(队列)中。当第一个任务执行完毕后,它会从队列中被移除,然后系统开始执行第二个任务。这个过程会持续进行,直到所有任务都被执行完毕,此时渲染队列变为空,标志着整个分片渲染过程的完成。
settimeout
tsx
import React, { useState } from "react";
const Index: React.FC = () => {
const [flag, setFlag] = useState<boolean>(false);
const [list, setList] = useState<number[]>([]);
const renderHandle = () => {
setFlag(true);
setList([]); // 清空列表开始新的渲染
console.time("总渲染耗时");
const total: number = 50000; // 总任务数
const perChunk: number = 1000; // 每个小任务处理的元素数量
let start: number = 0; // 当前任务的开始索引
const processChunk = () => {
const chunkStartTime = performance.now(); // 记录分片开始的时间
const chunk: number[] = [];
for (let i: number = start; i < start + perChunk && i < total; i++) {
chunk.push(i);
}
setList((prevList: number[]) => [...prevList, ...chunk]);
const chunkEndTime = performance.now(); // 记录分片结束的时间
const timeTaken = (chunkEndTime - chunkStartTime).toFixed(2); // 保留两位小数
console.log(
`分片 ${Math.ceil(start / perChunk) + 1} 完成: 从 ${start} 到 ${
start + perChunk - 1
},耗时 ${timeTaken} 毫秒`
);
start += perChunk;
if (start < total) {
setTimeout(processChunk, 0); // 设置下一个 setTimeout 继续处理
} else {
console.timeEnd("总渲染耗时"); // 记录总耗时
}
};
processChunk(); // 开始第一个分片任务
};
return (
<div style={{ width: "100%" }}>
<button onClick={renderHandle}>点击开始渲染</button>
<div>
{list.map((item: number) => (
<div key={item} style={{ width: "100%", height: "60px" }}>
{item}
</div>
))}
</div>
</div>
);
};
export default Index;
在进行了分片之后,我们是可以看到,内容是立刻被渲染出来了的,而不是像前面的那些内容中,需要等两三秒才能渲染出来:
这是因为我们把耗时任务拆分成了多个快来执行,每个快渲染 1000 个元素,这样的话 fps 就会变得比之前稳定,因为我们把耗时的任务拆分成多个块来执行了。
但是也并不是没有问题,当我们快速拖动滚动条时,数据列表中会有闪烁的现象
当使用 setTimeout 来拆分大量的 DOM 插入操作时,虽然我们将延迟时间设置为 0ms,但实际上由于 JavaScript 是单线程的,任务执行时会被放入到事件队列中,而事件队列中的任务需要等待当前任务执行完成后才能执行。所以即使设置了 0ms 延迟,setTimeout 的回调函数也不一定会立即执行,可能会受到其他任务的阻塞。
当 setTimeout 的回调函数执行的间隔超过了浏览器每帧更新的时间间隔(一般是 16.7ms),就会出现丢帧现象。丢帧指的是浏览器在更新页面时,没有足够的时间执行全部的任务,导致部分任务被跳过,从而导致页面渲染不连续,出现闪烁的情况。
使用 requestAnimationFrame 对代码进行改造
requestAnimationFrame 是浏览器提供的一个 API,用于在下一次重绘之前调用指定的回调函数,是进行动画和连续帧更新的推荐方法。这两者的区别主要从四个维度进行讲解:
-
设计目的:
- requestAnimationFrame:专为动画设计,确保回调函数在浏览器重绘前执行,从而提供流畅的视觉效果。
- setTimeout:用于在指定的延迟后执行一次回调函数,设计初衷并非专门用于动画。
-
性能和效率:
- requestAnimationFrame:与浏览器的绘制过程同步,可以减少重绘和重排,提高性能。当页面不可见或最小化时,会暂停调用,减少 CPU、GPU 和电源的消耗。
- setTimeout:执行时间受任务队列和其他任务影响,可能会导致动画卡顿或延迟。不会自动暂停,即使页面不可见也会继续执行。
-
精确度:
- requestAnimationFrame:由于与浏览器的帧率同步,因此可以更加精确地控制动画的帧率。
- setTimeout:延迟时间可能不够精确,受到事件循环的影响。
-
使用场景:
- requestAnimationFrame:最适用于动画和请求连续帧更新的场景。
- setTimeout:更适合需要延迟执行但不要求与浏览器帧率同步的任务。
那么在接下来的代码中我们将使用 requestAnimationFrame 对 setTimeout 进行改造:
tsx
"use client";
import React, { useState, useRef } from "react";
const Index: React.FC = () => {
const [list, setList] = useState<number[]>([]);
const frameId = useRef<number | null>(null);
const renderHandle = () => {
if (frameId.current) {
cancelAnimationFrame(frameId.current); // 取消已有的帧请求
}
setList([]);
console.time("总渲染耗时");
const total: number = 50000;
const perChunk: number = 500;
let start: number = 0;
const processChunk = () => {
const chunk: number[] = [];
for (let i: number = start; i < start + perChunk && i < total; i++) {
chunk.push(i);
}
setList((prevList) => [...prevList, ...chunk]); // 在每个分片后更新状态
start += perChunk;
if (start < total) {
frameId.current = requestAnimationFrame(processChunk);
} else {
console.timeEnd("总渲染耗时");
}
};
frameId.current = requestAnimationFrame(processChunk);
};
return (
<div style={{ width: "100%" }}>
<button onClick={renderHandle}>点击开始渲染</button>
<div>
{list.map((item: number) => (
<div key={item} style={{ width: "100%", height: "60px" }}>
{item}
</div>
))}
</div>
</div>
);
};
export default Index;
在上面的这段代码当中,我们除了使用 requestAnimationFrame 来对代码进行了优化,还使用了 DocumentFragment 来创建了一个实例,然后循环创建了一个 div 元素,将这些 div 添加到 DocumentFragment 中,最后将整个 DocumentFragment 一次性插入到 #list-container 容器中。这样做可以显著减少因直接操作 DOM 而引起的性能问题。
最终看看滚动的效果,你会发现闪烁的问题就没有那么严重了:
如果屏幕刷新率是 60Hz,那么回调函数就每 16.7ms 被执行一次,如果刷新率是 75Hz,那么这个时间间隔就变成了 1000/75=13.3ms,换句话说就是,requestAnimationFrame 的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象。但是也会有一种特例,requestAnimationFrame 并不保证每次刷新执行一次回调函数,它能保证执行回调函数的时机是在某一次刷新。
现在假设有一个计算任务在回调函数执行时占用了 20 毫秒。由于浏览器仍然按照 60Hz 的刷新频率工作,因此下一次刷新将在 16.77 毫秒之后进行,但是这一次回调函数需要 20 毫秒才能完成。那么在完成当前回调函数后,下一次回调函数不会立即执行,而是等到下一个刷新周期的时候才执行。那么下一次回调执行会在 16.77\3
ms 的时候,这个计算公式如下:
ini
0+16.77+20=36.77,16.77*3=50.31,36.77+16.77=53.54
参考资料
总结
在本章的内容中我们学习到了通过使用时间分片的手段来将数据进行了分片渲染,从而优化了页面首次的时间,那么在下一篇文章中我们将会了解到更复杂的需求,那么它就需要用到虚拟列表了。
最后分享两个我的两个开源项目,它们分别是:
这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰