基于React hooks的虚拟滚动组件封装

背景

云仓数据大盘中采购单和订单列表存在一单多货的情况,无法使用egGrid组件。
分页每一页数据量较大时,一次性渲染大量dom,势必造成页面卡顿。

原理

无论页面中有多少条数据,我们在一屏范围内能看到的只有那么几条。所以在页面滚动的时候只渲染视口范围内的数据。

效果:


未命名.gif

对于数据层来说,就是需要在列表滚动的时候动态地维护一个出现在视口范围内的数据list。

现成轮子

目前,业内虚拟滚动做的比较好的方案有react-virtualized和react-window,antd的select框的虚拟滚动就是采用的该方案。针对我们这次的需求结合调研我们发现,无论是我们的采购单还是订单都是一单多货,也就是说我们列表项的每一项高度都是不固定的,因此没法用比较轻量级的react-window(不到2K), 如果将react-virtualized引入项目中,会使项目体积变大,由于该项目暂时没有其他组件需要使用虚拟滚动,我暂时选择了自己封装虚拟组件这个方案。


截屏2022-05-29 15.08.06.png

思路

在视图层,需要两个容器, 外层的wrapper视口区域,高度确定。内层的container为所有列表高度之和。

 <div
      className={styles.wrapper}
      onScroll={handleScroll}
      ref={wrapperRef}
    >
      <div
        className={styles.container}
        style={{ height: `${totalHeight}px` }}
      >
       ....
      </div>
    </div>

由于可见列表项是动态渲染的。不能采用普通的布局,所有的列表项必须使用绝对定位。因此,在数据请求回来后,就要计算出列表的总高度和每一项的高度,放在一个list中存储起来,每一个列表的样式也从这个数据中取。这个list的数据结构如下所示。

 interface IPosition {
  id: IdType;
  height: number;
  top: number;
}

type Positions = IPosition[]

列表首次渲染时,计算出上述的位置信息和列表总高度。


 useEffect(() => {
    // 计算wrapper高度
    const newHeight = wrapperRef.current?.offsetHeight || 0;
    if (height !== newHeight) {
      setHeight(newHeight);
    }

    updatePosition();
  }, [list]);

监听列表的滚动事件,实时计算出现在视口区域内的列表的起始和结束索引位置。

const getStartIndex = () => {
    let sum = 0;
    for (let i = 0; i < list.length; i++) {
      const eachHeight = positions[i]?.height || 0;
      sum += eachHeight;

      if (sum >= scrollTop) {
        return i;
      }
    }
    return 0;
  };

  const getLastIndex = () => {
    let sum = 0;

    // 每一项高度都不确定第一项的高度不能列入可视区域计算
    for (let i = startIndex + 1; i < list.length; i++) {
      const eachHeight = positions[i]?.height || 0;
      sum += eachHeight;

      if (sum > height) {
        return i;
      }
    }
    return list.length - 1;
  };

通过这个两个索引位置得到可视区域的slicedList

  const startIndex = getStartIndex();
  const endIndex = Math.min(getLastIndex(), list.length - 1);
  const visibleList = list.slice(startIndex, endIndex + 1);

完整代码

import React, { useState, useRef, useEffect } from 'react';
import styles from './index.less';
import type { VirtualListProps, IPosition, IdType } from './interface';
import VirtualListItem from './listItem';

// 计算每一项的高度
const calcItemHeight = (num: number, estimatedHeight: number): number => {
  return 46 + num * estimatedHeight + (num - 1);
};

export const VirtualList: React.FC<VirtualListProps> = (props) => {
  const { list, eachProductHeight, renderItem, itemMargin } = props;

  const [
    positions,
    setPositions,
  ] = useState<IPosition[]>([]);

  const [
    scrollTop,
    setScrollTop,
  ] = useState<number>(0);// 滚动条卷去的高度

  const [
    totalHeight,
    setTotalHeight,
  ] = useState<number>(0);// 列表项总高度

  const [
    height,
    setHeight,
  ] = useState(0);// 容器高度

  const wrapperRef = useRef<HTMLDivElement>(null);

  function handleScroll() {
    if (wrapperRef.current !== null) {
      setScrollTop(wrapperRef.current.scrollTop);
    }
  }

  const getStartIndex = () => {
    let sum = 0;
    for (let i = 0; i < list.length; i++) {
      const eachHeight = positions[i]?.height || 0;
      sum += eachHeight;

      if (sum >= scrollTop) {
        return i;
      }
    }
    return 0;
  };

  const getLastIndex = () => {
    let sum = 0;

    // 每一项高度都不确定第一项的高度不能列入可视区域计算
    for (let i = startIndex + 1; i < list.length; i++) {
      const eachHeight = positions[i]?.height || 0;
      sum += eachHeight;

      if (sum > height) {
        return i;
      }
    }
    return list.length - 1;
  };

  const getItemStyle = (id: IdType) => {
    const it = positions.find((it) => it.id === id);
    return it
      ? {
        height: `${it.height}px`,
        transform: `translateY(${it.top}px`,
      }
      : {};
  };

  // 计算位置信息
  const updatePosition = () => {
    const result: IPosition[] = [];
    list.forEach((it, index) => {
      const prev = result[index - 1];
      const value = {
        top: prev ? prev.top + prev.height : 0,
        height: calcItemHeight(it.detail?.length, eachProductHeight),
        id: it.id,
      };
      result[index] = value;
    });

    const last = result[result.length - 1];
    const totalHeight = last ? last.height + last.top : 0;
    setTotalHeight(totalHeight);
    
    setPositions(result);
  };

  useEffect(() => {
    // 计算wrapper高度
    const newHeight = wrapperRef.current?.offsetHeight || 0;
    if (height !== newHeight) {
      setHeight(newHeight);
    }

    updatePosition();
  }, [list]);

  const startIndex = getStartIndex();
  const endIndex = Math.min(getLastIndex(), list.length - 1);
  const visibleList = list.slice(startIndex, endIndex + 1);
  
  return (
    <div
      className={styles.wrapper}
      onScroll={handleScroll}
      ref={wrapperRef}
    >
      <div
        className={styles.container}
        style={{ height: `${totalHeight}px` }}
      >
        {
          visibleList.map((item, index) => (
            <VirtualListItem
              item={item}
              itemMargin={itemMargin}
              key={item.id}
              style={getItemStyle(item.id)}
            >
              {renderItem(item)}
            </VirtualListItem>
          ))
        }
      </div>
    </div>
  );
};

最后编辑于
?著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,128评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,316评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事?!?“怎么了?”我有些...
    开封第一讲书人阅读 159,737评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,283评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,384评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,458评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,467评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,251评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,688评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,980评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,155评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,818评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,492评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,142评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,382评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,020评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,044评论 2 352

推荐阅读更多精彩内容