项目leader:现在后端有几十万条列表数据,你前端准备怎么渲染?

2,673 阅读7分钟
  • 本文已参与「新人创作礼」活动,一起开启掘金创作之路

hi,小伙伴们好,今天给大家分享虚拟列表实战。工作中我们常常会遇到一些重磅问题,比如这里的列表超大数据渲染问题。很明显,几十万的数据不可能一次请求,后端必定分页,而前端就要做专门的虚拟滚动来加载对应页数的数据。今天,就让我们来看看如何利用虚拟列表做虚拟滚动。技术栈(react+react-window)

1.要实现什么样的效果?

  • 先看效果图: image.png

  • 样式丑了点,但不影响我们做功能。我们要实现的效果是:

  1. 右上角显示数据总条数。
  2. 滚动条滚动时,不会发送请求,数据为空的地方有一个骨架屏效果。停止滚动的时候,根据虚拟列表的scrollTop计算出要请求第几页数据并发起请求,请求过程中,仍然保持骨架屏效果,直到数据请求完成,然后渲染。这就是虚拟滚动效果
  3. 请求回来的数据本地做个缓存,下次再滑动到相同位置,就不用发起请求。
  • 现在知道了要实现什么样的效果,我们就来着手做吧。

2.接口参数和返回值分析,以及技术选型

  • 后端参数分析。首先要明白一点,后端给我们的数据类型是什么样的,根据页面渲染的数据,至少包含:
{
      data:[],
      totalCount:300000
}
  • data就是列表数据,totalCount是总共的数据条数。
  • 前端参数分析。前端参数至少有:
{
        pageSize:20,
        currentPage:1,
}
  • pageSize就是每一页的数据条数,currentPage就是当前的页数(总数据有30万,后端不可能一次性返回,肯定做了分页,我们传这两个参数就可以获取某个分页的pageSize条数据。

  • 技术选型。虚拟列表的实现我选择react-window这个库,github上有11k的stars,是个很不错的项目。同时,这个作者也封装了react-virtualized-auto-sizer,用来做虚拟列表宽高自适应的,这个库我们也要使用。

3.react-window,react-virtualized-auto-sizer初体验。

  • 工欲善其事必先利其器,我们还是先对这两个库做个初步了解。

  • react-window内部封装了四个react组件,分别是:VariableSizeGridFixedSizeGridVariableSizeListFixedSizeList

  • 其中带有list关键字的是虚拟列表,带有Grid关键字的是虚拟网格。

  • 其中带有Variable关键字的,格子高度的获取通过一个函数控制,可以实现每一行不一样高。带有fixed关键字的每一行一样高。

  • 接下来上图说明:

  • FixedSizeList 微信图片_20220112172229.png

  • VariableSizeList 微信图片_20220112172226.png

  • FixedSizeGrid 微信图片_20220112172533.png

  • VariableSizeGrid 微信图片_20220112172536.png

  • 自行体验网址:react-window在线体验

  • react-virtualized-auto-sizer是一个react组件,直接上代码,一看就知道它的功能,十分受用:

import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
<AutoSizer>
    {({ height, width }) => (
        <List
        className="List"
        height={height}
        itemCount={1000}
        itemSize={35}
        width={width}
        >
            {这是渲染的内容}
        </List>
    )}
</AutoSizer>
  • 很明显,它的功能就是监听外部wrap宽高变化,生成实时的height和width来更新内部虚拟列表的宽高,可以说十分完美。

4.功能实现

  • 封装fetch方法,模拟后端接口,这里实际项目肯定是有接口的,我这里不方便做接口,所以写个方法模拟下后端接口
const fetch = (pageSize, currentPage) => {
  return new Promise((re, rj) => {
    setTimeout(() => {
      const data = Array(pageSize).fill("新数据");
      re({
        data,
        totalCount: 1000,
      });
    }, 1000);
  });
};

  • 通过Promise模拟请求时间为一秒的接口,参数pageSize代表要请求多少条数据,参数currentPage代表请求第几页的数据。可以看到currentPage参数我没有使用,在实际的后端接口中,这两个参数要配合使用。fetch的返回值为data和totalCount,这里我设置totalCount为1000是为了好调试,设置成几十万,上百万也没问题。

  • 封装虚拟列表,代码很简单,如下:

import React, { useEffect, useState } from "react";
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import { debounce } from "lodash";

const ITEM_SIZE=30;//定义虚拟列表行的高度

const VisualList=()=> {
  const [list, setList] = useState([]);//虚拟列表数据
  const [totalCount, setTotalCount] = useState("");//数据总条数
  return (
    <div
      className="autoSizeWrap"
      style={{ display: "flex", flexDirection: "column" }}
    >
      <h3 style={{ textAlign: "right" }}>数据总条数{totalCount}</h3>
      <div style={{ flex: 1, minHeight: 0 }} className="autosizeInner">
        <AutoSizer>
          {({ width, height }) => (
            <List
              className="List"
              height={height}
              itemCount={totalCount}
              itemSize={ITEM_SIZE}
              width={width}
              onScroll={(obj) => handleScroll(obj, height)}
              data={list}
            >
              {Row}
            </List>
          )}
        </AutoSizer>
      </div>
    </div>
  );
}
  • 处理页面加载后第一次请求:
useEffect(() => {
 fetchList(1, 20, "first");//第一次请求
}, []);

const fetchList = async (page1, pageSize, first, page2) => {//封装请求方法
  if (first === "first") { //处理第一次请求
    const res = await fetch(pageSize, page1);
    setList(
      res.data.concat(Array(res.totalCount - res.data.length).fill(null))
    );
    setTotalCount(res.totalCount);
  } else if (page2) {//处理请求两页数据的情况
    const { data } = await fetch(20, page1);
    const { data: data1 } = await fetch(20, page2);
    setListData(page1, 40, [...data, ...data1]);
  } else {//处理普通的一页数据请求
    const { data } = await fetch(20, page1);
    setListData(page1, 20, data);
  }
};

const setListData = (page, size, data) => {
    list.splice((page - 1) * 20, size, ...data);//数组的splice方法保留了已有的数据,起到了缓存数据的作用

    setList([...list]);
};
  • 上面这段代码唯一要注意的就第一次请求的setList那里,我们只请求到20条数据,但要生成的是长度为1000的数组。

  • 接下来,我们要封装Row组件,用于定义虚拟列表內每一行怎么渲染。代码如下:

const Row = (props) => {
    const { index, style } = props;//index代表当前数据所处位置
    return (
        <div className="list-item" style={style}>
            {list[index] ? (  //这个判断很重要,当list当前位置是否为null?如果为null就渲染骨架屏
                `Row  ${index} ${list[index]}`
            ) : (
                <>
                    <div className={"loading-span span1"} />
                    <div className={"loading-span span2"} /> //这里是骨架屏
                </>
            )}
        </div>
    );
};
  • 最后处理比较复杂的滚动条滚动过程,看代码:
const handleScroll = debounce(async ({ scrollOffset }, height) => {
  if (scrollOffset === 0) return;

  const startIndex = Math.ceil(scrollOffset / ITEM_SIZE);
  const endIndex = Math.ceil((scrollOffset + height) / ITEM_SIZE);
  const startPage = Math.ceil(startIndex / 20); //虚拟列表內第一行数据所在数据页
  const endPage = Math.ceil(endIndex / 20); //虚拟列表內最后一行数据所在数据页,20是默认的pageSize,这个由自己定

  const startIsNull = !list[startIndex]; //虚拟列表內第一行数据为空
  const endIsNull = !list[endIndex]; //虚拟列表內最后一行数据为空

  const fetchType = {
    start:
      (startIsNull && endIsNull && startPage === endPage) ||
      (startIsNull && !endIsNull), //这种情况,只用请求第一行所在页数据
    end: !startIsNull && endIsNull, //这种情况,只用请求最后一行所在页数据
    all: startIsNull && endIsNull && startPage !== endPage, //这种情况,最后一行所在页,第一行数据所在页数据都要请求
  };

  const fetchMethod = {
    start: fetchList.bind(this, startPage, 20),
    end: fetchList.bind(this, endPage, 20),
    all: fetchList.bind(this, startPage, 20, "", endPage),
  };

  for (let key in fetchType) {
    if (fetchType[key]) {
      fetchMethod[key]();
    }
  }//判断满足哪一个条件,如果满足就调用对应的请求请求数据
}, 400);
  • debounce 是防抖处理,作用就是滚动条滚完之后再发起请求。

  • 函数的 scrollOffset 就是虚拟列表的 scrollTop,就是滚动条滚动的距离,height就是虚拟列表的高度。

  • startIndex 代表滚动条停止时,虚拟列表第一条数据的index;

  • endIndex 代表滚动条停止时,虚拟列表最后一条数据的index;

  • startPage 代表滚动条停止时,虚拟列表第一条数据所处的分页;

  • endPage 代表滚动条停止时,虚拟列表最后一条数据所处的分页;

  • 当list[startIndex]为空,或者list[endIndex]为空,代表骨架屏进入页面,页面就要发起请求。

  • fetchType里面的判断处理了多种情况。就是滚动条滚动时,startPage和endPage的处理,向上或者向下滚动,都可能出现四种情况。

  • endPage没有数据,只需请求endPage的数据,看下图分析: 参数说明.png

  • startPage,endPage数据都没有,分两种情况,看下图分析: 参数说明1.png

  • startPage没有数据,只用请求startPage的数据。看下图分析:

参数说明2.png

5.题后话

虚拟列表的实战到这里就结束了,难点就在于最后的滚动条处理,如果不清楚多看几遍最后三点就知道了。