likes
comments
collection
share

React处理大数据量场景实践

作者站长头像
站长
· 阅读数 4

前言

本文将要介绍一下 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>
  );
};

我们来看一下渲染页面的效果:

React处理大数据量场景实践

大概会等了一段时间后才会渲染出结果。我们在来看一下页面的性能指标和dom结构:

React处理大数据量场景实践

React处理大数据量场景实践

由上图看到dom全部渲染在页面上且渲染耗时也有1.5s多,在实际的工作中,列表项不会像例子中这么简单,必然是由复杂DOM节点组成的。可以想象出渲染性能;

解决方案

由上小节我们得知了直接渲染的缺点,那么来解决这个问题,一般实际工作中有两种方案来解决:

  • 方案一:分页或滚动加载
  • 方案二:虚拟列表

1.方案一:分页

传统的方法是使用懒加载的方式,下拉到底部获取新的内容加载进来,其实就相当于分页叠加功能,但随着加载数据越来越多,浏览器的回流和重绘的开销将会越来越大,整个滑动也会造成卡顿,这个时候我们就可以考虑使用虚拟列表来解决问题;

2.方案二:虚拟列表

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能;

我们此次采用虚拟列表来解决问题;

虚拟列表介绍

虚拟列表核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分;

React处理大数据量场景实践

由上图可以看出列表中一直都只有可视区域的dom渲染,灰色部分为缓冲区目的是避免滚动出现卡顿,这也就保证了列表的性能;

我们来看一下渲染页面的效果:

React处理大数据量场景实践

由上图可以看出我们无论如何滚动页面总是渲染一定数量的dom,不会全部渲染。我们在来看一下页面的渲染性能:

React处理大数据量场景实践

可以清晰的对比出前后性能的差距。

常用的虚拟列表组件库

简单实现

本次我们参考 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实现思路:

  1. 计算外层可视化容量,如果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;
  };
  1. 计算列表数据对于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;
  };
  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);
      // ... 省略 ...
    }
  };
  1. 计算wrapper总体的高度;
  // 列表总体高度
  const totalHeight = useMemo(() => {
    if (isNumber(itemHeightRef)) {
      return list.length * itemHeightRef;
    }
    return list.reduce(
      (sum, _, index) => sum + itemHeightRef(index, list[index]),
      0
    );
  }, [list]);
  1. 计算列表距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;
  };
  1. 设置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",
      });
    }
  };
  1. 设置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;

来看一下页面渲染效果和性能指标:

React处理大数据量场景实践

React处理大数据量场景实践

可以直观看到这种方式渲染的速度特别慢,需要等待js完全执行完后才会去渲染, 是一次性突然出现,体验不好,所以接下来要用时间分片做性能优化;

时间分片介绍

时间分片的核心思想是:如果任务不能在50毫秒内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权,使浏览器可以处理其他任务。让出控制权意味着停止执行当前任务,让浏览器去执行其他任务,随后再回来继续执行没有执行完的任务。所以时间分片的目的是不阻塞主线程,而实现目的的技术手段是将一个长任务拆分成很多个不超过50ms的小任务分散在宏任务队列中执行;

来看一下渲染页面的效果以及性能: React处理大数据量场景实践

React处理大数据量场景实践

从上图可以看出,时间分片,并没有本质减少浏览器的工作量,而是把一次性任务分割开来,把渲染任务拆分成块,匀到多帧,给用户一种流畅的体验效果,上图紫色rendering分布部分;

简单实现

此次我们使用 requestIdleCallback 来实现时间切片。

时间分片实现思路:

  1. 计算时间片,首先用 eachRenderNum 代表一次渲染多少个,那么除以总数据就能得到渲染多少次;
  2. 开始渲染数据,通过 index > times 判断渲染完成,如果没有渲染完成,那么通过 requestIdleCallback 浏览器空闲执行下一帧渲染;
  3. 通过 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…

参考链接

react其他文章