React处理大数据量场景实践
前言
本文将要介绍一下 react 对于大量数据场景的处理。一般分为两种方案:
- 虚拟列表(主要应用在长列表渲染)
- 时间分片(主要应用在点位渲染)
接下来将围绕以上两点进行讲解和实践;
场景一:虚拟列表
原生dom直接渲染缺陷
为了清晰的展示效果,假设我们的长列表需要展示150000条记录,同时将150000条记录渲染到页面中,代码实现:
import React, { useMemo } from "react";
export default () => {
const originalList = useMemo(() => Array.from(Array(99999).keys()), []);
return (
<div>
{originalList.map((ele) => (
<div
style={{
height: 52,
display: "flex",
justifyContent: "center",
alignItems: "center",
border: "1px solid #e8e8e8",
marginBottom: 8,
}}
key={ele}
>
Row: {ele}
</div>
))}
</div>
);
};
我们来看一下渲染页面的效果:
大概会等了一段时间后才会渲染出结果。我们在来看一下页面的性能指标和dom结构:
由上图看到dom全部渲染在页面上且渲染耗时也有1.5s多,在实际的工作中,列表项不会像例子中这么简单,必然是由复杂DOM节点组成的。可以想象出渲染性能;
解决方案
由上小节我们得知了直接渲染的缺点,那么来解决这个问题,一般实际工作中有两种方案来解决:
- 方案一:分页或滚动加载
- 方案二:虚拟列表
1.方案一:分页
传统的方法是使用懒加载的方式,下拉到底部获取新的内容加载进来,其实就相当于分页叠加功能,但随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大
,整个滑动也会造成卡顿,这个时候我们就可以考虑使用虚拟列表来解决问题;
2.方案二:虚拟列表
虚拟列表
其实是按需显示的一种实现,即只对可见区域
进行渲染,对非可见区域
中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能;
我们此次采用虚拟列表来解决问题;
虚拟列表介绍
虚拟列表核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分;
由上图可以看出列表中一直都只有可视区域的dom渲染,灰色部分为缓冲区目的是避免滚动出现卡顿,这也就保证了列表的性能;
我们来看一下渲染页面的效果:
由上图可以看出我们无论如何滚动页面总是渲染一定数量的dom,不会全部渲染。我们在来看一下页面的渲染性能:
可以清晰的对比出前后性能的差距。
常用的虚拟列表组件库
简单实现
本次我们参考 ahooks 中的use-virtual-list来简单的实现一个;useVirtualList
这个自定义hooks就是我们实现的目标,实现效果如上小节动态图所示,实现代码如下:
import React, { useMemo, useRef } from "react";
import { useVirtualList } from "./hooks/useVirtualList";
export default () => {
const containerRef = useRef(null);
const wrapperRef = useRef(null);
const originalList = useMemo(() => Array.from(Array(9999).keys()), []);
const [list] = useVirtualList(originalList, {
containerTarget: containerRef,
wrapperTarget: wrapperRef,
itemHeight: 60,
});
return (
<>
<div
ref={containerRef}
style={{ height: "300px", overflow: "auto", border: "1px solid" }}
>
<div ref={wrapperRef}>
{list.map((ele) => (
<div
style={{
height: 52,
display: "flex",
justifyContent: "center",
alignItems: "center",
border: "1px solid #e8e8e8",
marginBottom: 8,
}}
key={ele.index}
>
Row: {ele.data}
</div>
))}
</div>
</div>
</>
);
};
useVirtualList实现思路:
- 计算外层可视化容量,如果itemHeight是以像素为单位的数字类型,则容量为container的高度除以itemHeight。如果props的itemHeight是动态值,则for循环列表数据累加itemHeight,得到container容量;
/**
* 计算可视数量
* @param containerHeight
* @param fromIndex
* @returns
*/
const getVisibleCount = (containerHeight: number, fromIndex: number) => {
if (isNumber(itemHeightRef)) {
return Math.ceil(containerHeight / itemHeightRef);
}
let sum = 0;
let endIndex = 0;
for (let i = fromIndex; i < list.length; i++) {
const height = itemHeightRef(i, list[i]);
sum += height;
endIndex = i;
if (sum >= containerHeight) {
break;
}
}
return endIndex - fromIndex;
};
- 计算列表数据对于container的偏移数量(offset)。做法为scrollTop / itemHeight;
/**
* 计算对于container偏移量
* @param scrollTop
* @returns
*/
const getOffset = (scrollTop: number) => {
if (isNumber(itemHeightRef)) {
return Math.floor(scrollTop / itemHeightRef);
}
let sum = 0;
let offset = 0;
for (let i = 0; i < list.length; i++) {
const height = itemHeightRef(i, list[i]);
sum += height;
if (sum >= scrollTop) {
offset = i;
break;
}
}
return offset + 1;
};
- 计算在container区域内可显示的数据个数(calculateRange)。start: offset - overscan;end: offset + visibleCount + overscan。overscan为可允许溢出container范围的最大个数;
const calculateRange = () => {
// 获取外部容器
const container = containerTarget.current;
if (container) {
const { scrollTop, clientHeight } = container;
const offset = getOffset(scrollTop);
const visibleCount = getVisibleCount(clientHeight, offset);
const start = Math.max(0, offset - overscan);
const end = Math.min(list.length, offset + visibleCount + overscan);
// ... 省略 ...
}
};
- 计算wrapper总体的高度;
// 列表总体高度
const totalHeight = useMemo(() => {
if (isNumber(itemHeightRef)) {
return list.length * itemHeightRef;
}
return list.reduce(
(sum, _, index) => sum + itemHeightRef(index, list[index]),
0
);
}, [list]);
- 计算列表距container的高度(distanceTop),监听state中的start,重新计算该值;
/**
* 获取距离顶部距离
* @param index
* @returns
*/
const getDistanceTop = (index: number) => {
if (isNumber(itemHeightRef)) {
const height = index * itemHeightRef;
return height;
}
const height = list
.slice(0, index)
.reduce((sum, _, i) => sum + itemHeightRef(i, list[i]), 0);
return height;
};
- 设置wrapper的高度和偏移量;
const calculateRange = () => {
// 获取外部容器
const container = containerTarget.current;
if (container) {
const { scrollTop, clientHeight } = container;
const offset = getOffset(scrollTop);
const visibleCount = getVisibleCount(clientHeight, offset);
const start = Math.max(0, offset - overscan);
const end = Math.min(list.length, offset + visibleCount + overscan);
const offsetTop = getDistanceTop(start);
// 设置wrapper的高度和偏移量
setWrapperStyle({
height: totalHeight - offsetTop + "px",
marginTop: offsetTop + "px",
});
}
};
- 设置wrapper展示dom;
const calculateRange = () => {
// 获取外部容器
const container = containerTarget.current;
if (container) {
const { scrollTop, clientHeight } = container;
const offset = getOffset(scrollTop);
const visibleCount = getVisibleCount(clientHeight, offset);
const start = Math.max(0, offset - overscan);
const end = Math.min(list.length, offset + visibleCount + overscan);
const offsetTop = getDistanceTop(start);
// 设置wrapper的高度和偏移量
setWrapperStyle({
height: totalHeight - offsetTop + "px",
marginTop: offsetTop + "px",
});
// 设置wrapper展示dom
setTargetList(
list.slice(start, end).map((ele, index) => ({
data: ele,
index: index + start,
}))
);
}
};
总结:监听container的Scroll事件,当滚动时,计算在container区域可显示的数据个数,同时修改列表可显示的范围(start和end)。之后计算当前已滚动的高度(distanceTop)。最后修改wrapper的marginTop。整体代码:
// hooks/useVirtualList/index.ts
import { useEffect, useMemo, useState, CSSProperties } from "react";
import type { Options } from "@/types/list";
import { isNumber } from "@/utils/util";
import { useSize, useLatest } from "../useUtils/util";
export const useVirtualList = <T = any>(list: T[], options: Options<T>) => {
const { containerTarget, wrapperTarget, itemHeight, overscan = 5 } = options;
const { width, height } = useSize(containerTarget);
const itemHeightRef = useLatest(itemHeight);
const [wrapperStyle, setWrapperStyle] = useState<CSSProperties>({});
const [targetList, setTargetList] = useState<{ index: number; data: T }[]>(
[]
);
/**
* 计算对于container偏移量
* @param scrollTop
* @returns
*/
const getOffset = (scrollTop: number) => {
if (isNumber(itemHeightRef)) {
return Math.floor(scrollTop / itemHeightRef);
}
let sum = 0;
let offset = 0;
for (let i = 0; i < list.length; i++) {
const height = itemHeightRef(i, list[i]);
sum += height;
if (sum >= scrollTop) {
offset = i;
break;
}
}
return offset + 1;
};
/**
* 计算可视数量
* @param containerHeight
* @param fromIndex
* @returns
*/
const getVisibleCount = (containerHeight: number, fromIndex: number) => {
if (isNumber(itemHeightRef)) {
return Math.ceil(containerHeight / itemHeightRef);
}
let sum = 0;
let endIndex = 0;
for (let i = fromIndex; i < list.length; i++) {
const height = itemHeightRef(i, list[i]);
sum += height;
endIndex = i;
if (sum >= containerHeight) {
break;
}
}
return endIndex - fromIndex;
};
/**
* 获取距离顶部距离
* @param index
* @returns
*/
const getDistanceTop = (index: number) => {
if (isNumber(itemHeightRef)) {
const height = index * itemHeightRef;
return height;
}
const height = list
.slice(0, index)
.reduce((sum, _, i) => sum + itemHeightRef(i, list[i]), 0);
return height;
};
// 列表总体高度
const totalHeight = useMemo(() => {
if (isNumber(itemHeightRef)) {
return list.length * itemHeightRef;
}
return list.reduce(
(sum, _, index) => sum + itemHeightRef(index, list[index]),
0
);
}, [list]);
const calculateRange = () => {
// 获取外部容器
const container = containerTarget.current;
if (container) {
const { scrollTop, clientHeight } = container;
const offset = getOffset(scrollTop);
const visibleCount = getVisibleCount(clientHeight, offset);
const start = Math.max(0, offset - overscan);
const end = Math.min(list.length, offset + visibleCount + overscan);
const offsetTop = getDistanceTop(start);
// 设置wrapper的高度和偏移量
setWrapperStyle({
height: totalHeight - offsetTop + "px",
marginTop: offsetTop + "px",
});
// 设置wrapper展示dom
setTargetList(
list.slice(start, end).map((ele, index) => ({
data: ele,
index: index + start,
}))
);
}
};
const resize = (e: Event) => {
e.preventDefault();
calculateRange();
};
useEffect(() => {
const wrapper = wrapperTarget.current;
if (wrapper) {
const styles = Object.keys(wrapperStyle) as (keyof CSSProperties)[];
styles.forEach(
// @ts-ignore
(key) => (wrapper.style[key] = wrapperStyle[key])
);
}
}, [wrapperStyle]);
useEffect(() => {
if (containerTarget.current) {
containerTarget.current.addEventListener("scroll", resize);
}
return () => {
containerTarget.current?.removeEventListener("scroll", resize);
};
}, []);
useEffect(() => {
if (!width || !height) {
return;
}
calculateRange();
}, [width, height, list]);
return [targetList] as const;
};
实现效果参考上小节动图;
场景二:时间分片
直接渲染所带来的问题
为了清晰的展示效果,我们将20000个颜色块直接渲染到页面中,代码实现:
import React, { useState, CSSProperties } from "react";
import { getColor, getPostion } from "@/utils/util";
import type { Size } from "@/types/list";
interface StateTypes {
renderList: JSX.Element[];
dataList: number[];
position: Size;
}
/* 色块组件 */
function Circle({ position }: { position: Size }) {
const style: CSSProperties = React.useMemo(() => {
return {
background: getColor(),
position: "absolute",
width: "20px",
height: "20px",
...getPostion(position),
};
}, [position]);
return <div style={style} className="circle" />;
}
class Index extends React.Component {
state: StateTypes = {
dataList: [],
renderList: [],
position: { width: 0, height: 0 }, // 位置信息
};
wrapperRef = React.createRef<HTMLDivElement>();
componentDidMount() {
if (this.wrapperRef.current) {
const { offsetHeight, offsetWidth } = this.wrapperRef.current;
const originList = new Array(20000).fill(1);
this.setState({
position: { height: offsetHeight, width: offsetWidth },
dataList: originList,
renderList: originList,
});
}
}
render() {
const { renderList, position } = this.state;
return (
<div
ref={this.wrapperRef}
style={{ width: "100%", height: "100vh", position: "relative" }}
>
{renderList.map((_item, index) => (
<Circle position={position} key={index} />
))}
</div>
);
}
}
const DefaultDrawing: React.FC = () => {
const [show, setShow] = useState(false);
const [btnShow, setBtnShow] = useState(true);
const handleClick = () => {
setBtnShow(false);
setTimeout(() => {
setShow(true);
}, 0);
};
return (
<div>
{btnShow && <button onClick={handleClick}>展示效果</button>}
{show && <Index />}
</div>
);
};
export default DefaultDrawing;
来看一下页面渲染效果和性能指标:
可以直观看到这种方式渲染的速度特别慢,需要等待js完全执行完后才会去渲染, 是一次性突然出现,体验不好,所以接下来要用时间分片做性能优化;
时间分片介绍
时间分片的核心思想是:如果任务不能在50毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务。让出控制权意味着停止执行当前任务,让浏览器去执行其他任务,随后再回来继续执行没有执行完的任务。所以时间分片的目的是不阻塞主线程,而实现目的的技术手段是将一个长任务拆分成很多个不超过50ms的小任务分散在宏任务队列中执行;
来看一下渲染页面的效果以及性能:
从上图可以看出,时间分片,并没有本质减少浏览器的工作量,而是把一次性任务分割开来,把渲染任务拆分成块,匀到多帧,给用户一种流畅的体验效果,上图紫色rendering分布部分;
简单实现
此次我们使用 requestIdleCallback 来实现时间切片。
时间分片实现思路:
- 计算时间片,首先用 eachRenderNum 代表一次渲染多少个,那么除以总数据就能得到渲染多少次;
- 开始渲染数据,通过
index > times
判断渲染完成,如果没有渲染完成,那么通过 requestIdleCallback 浏览器空闲执行下一帧渲染; - 通过 renderList 把已经渲染的 element 缓存起来,这种方式可以直接跳过下一次的渲染。实际每一次渲染的数量仅仅为 demo 中设置的 500 个;
代码实现:
// 时间分片
import React, { useState, CSSProperties } from "react";
import { getColor, getPostion } from "@/utils/util";
import type { Size } from "@/types/list";
interface StateTypes {
renderList: JSX.Element[];
dataList: number[];
position: Size;
eachRenderNum: number;
}
/* 色块组件 */
function Circle({ position }: { position: Size }) {
const style: CSSProperties = React.useMemo(() => {
return {
background: getColor(),
position: "absolute",
width: "20px",
height: "20px",
...getPostion(position),
};
}, [position]);
return <div style={style} className="circle" />;
}
class Index extends React.Component {
state: StateTypes = {
dataList: [], //数据源列表
renderList: [], //渲染列表
position: { width: 0, height: 0 }, // 位置信息
eachRenderNum: 500, // 每次渲染数量
};
wrapperRef = React.createRef<HTMLDivElement>();
componentDidMount() {
if (this.wrapperRef.current) {
const { offsetHeight, offsetWidth } = this.wrapperRef.current;
const originList = new Array(20000).fill(1);
// 计算需要渲染次数
const times = Math.ceil(originList.length / this.state.eachRenderNum);
let index = 1;
this.setState(
{
dataList: originList,
position: { height: offsetHeight, width: offsetWidth },
},
() => {
this.toRenderList(index, times);
}
);
}
}
toRenderList = (index: number, times: number) => {
if (index > times) return; /* 如果渲染完成,那么退出 */
const { renderList } = this.state;
renderList.push(this.renderNewList(index));
this.setState({
renderList,
});
// // 浏览器空闲执行下一批渲染
requestIdleCallback(() => {
this.toRenderList(++index, times);
});
};
renderNewList(index: number) {
/* 得到最新的渲染列表 */
const { dataList, position, eachRenderNum } = this.state;
const list = dataList.slice(
(index - 1) * eachRenderNum,
index * eachRenderNum
);
return (
<React.Fragment key={index}>
{list.map((_, listIndex) => (
<Circle key={listIndex} position={position} />
))}
</React.Fragment>
);
}
render() {
return (
<div
style={{ width: "100%", height: "100vh", position: "relative" }}
ref={this.wrapperRef}
>
{this.state.renderList}
</div>
);
}
}
export default () => {
const [show, setShow] = useState(false);
const [btnShow, setBtnShow] = useState(true);
const handleClick = () => {
setBtnShow(false);
setTimeout(() => {
setShow(true);
}, 0);
};
return (
<div>
{btnShow && <button onClick={handleClick}>展示效果</button>}
{show && <Index />}
</div>
);
};
实现效果参考上小节动图;
最后
本文的目的是提供一个思路,通过时间分片和虚拟列表的方式来同时加载大量DOM。大家可以根据自己的项目需求应用到实际的工作之中;
项目源码地址:github.com/linhexs/rea…