虚拟滚动列表的 React 实现 -- 简单的等高虚拟列表

参考

虚拟滚动列表的大致方案

  • 虚拟列表的实现,大致上的思路就是:只加载可是区域范围内的列表项,发生滚动时,动态计算哪些列表项需要渲染
  • 为实现,我们需要(以下步骤以列表项高度 itemHeight 确定的情况为例,列表项高度不定时会麻烦一些)
    1. 有一个已知高度 height 的滚动容器 scrollContainer
    2. 计算所有数据 data 都渲染所需要占用的高度 itemHeight * data.length , 然后用这个高度撑开滚动容器,从而获得滚动条及滚动容器 scrollTop
    3. 计算当前可视区域所能渲染的列表条数 count = Math.ceil(height / itemHeight)
    4. 计算可视区域第一个列表项的索引 start
    5. 计算可视区域最后一个列表项的索引 end = start + count
    6. 渲染可视区域的数据 data.slice(start, end)
    7. 监听滚动容器的 scroll 事件,事件内获得当前滚动容器的 scrollTop, 并根据获得的 scrollTop 重新计算需要展示的第一个列表项索引 start = Math.floor(scrollTop / itemHeight)
    8. 计算当前滚动区域整体的偏移值 offset = scrollTop - (scrollTop % itemHeight) , 借助 css 属性 transform: translate3d(0, ${offset}px, 0) 使得可视区域平滑的上下移动(同时这个列表区域是 position: ‘absolute’ 脱离文档流的),每当 scrollTop 是 itemHeight 的整数倍时,设置偏移值的同时也会切换 start, 这就刚好可以给用户一个列表在正常滚动的错觉

0. 前期准备

  1. 准备一些可分页的数据,虚拟滚动列表,一般用来提升大数据量下的渲染性能,但是对于前端项目来说,数据是通过请求后端获得的,相比于大数据列表的渲染,大数据的请求更加耗时,所以,一般来说,实现虚拟滚动的同时,也要兼顾数据分页请求的考虑(一般是下拉到底后请求下一页数据)
  1. 准备一个可以动态获得容器宽高的控件,虚拟滚动列表需要获取高度来进行计算,一个“动态获得容器宽高的控件” 可以满足虚拟滚动列表在页面缩放或者其他影响展示高度的场景下拿到准确的高度值
/**
 * HOC 获得撑满所在区域的 宽高
 * NOTICE:
 * 1. 此组件的上层组件如果有 padding 处理,那么需要对传出的 width height 做些计算
 * 2. 此组件的下层组件不要设置 height=100% 或者 width=100% 否则可能会导致白屏,建议直接设置 style={width, height}
 * */

/**
 * ResizeObserver 可以用来监听 DOM 元素内容区域的边界改动
 * https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver
 * 但是有兼容问题,因此使用了第三方提供的 polyfill
 * 
*/
import ResizeObserverPolyfill from 'resize-observer-polyfill';

interface ISize {
  width: number;
  height: number;
}

interface IProps {
  children: (size: ISize) => React.ReactNode;
}

const AutoSizer: React.FC<IProps> = (props: IProps) => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [size, setSize] = React.useState<ISize>({} as any);

  React.useEffect(() => {
    if (ref.current && ref.current.parentNode && ref.current.parentNode.ownerDocument) {
      const resizeObserver: ResizeObserver = new ResizeObserverPolyfill((entries) => {
        requestAnimationFrame(() => {
          if (!Array.isArray(entries) || !entries.length) {
            return;
          }
          if (ref && ref.current) {
            const target = entries[0].target as HTMLElement;
            const { offsetWidth, offsetHeight } = target;
            setSize({ width: offsetWidth, height: offsetHeight });
          }
        });
      });
      resizeObserver.observe(ref.current.parentNode as HTMLElement);
      return () => resizeObserver.disconnect();
    }
    return () => {};
  }, [ref]);

  const { children } = props;

  return (
    <div ref={ref} style={{ overflow: 'visible', width: 0, height: 0 }}>
      {children(size)}
    </div>
  );
};

export default React.memo(AutoSizer);

1. 普通的虚拟滚动列表 + 下拉动态加载

虚拟滚动组件封装
/*
 * 虚拟滚动列表
 * */
import VirtualLess from './Virtual.module.less';

interface IProps {
  /** 显示区域高度 */
  height: number;
  /** 显示区域宽度 */
  width?: number;
  /** 每一项 Item 的高度 */
  itemHeight: number;
  /** 总数据 */
  records: { [key: string]: any }[];
  /** 数据中的那一项作为唯一标识 */
  recordKeyName: string;
  /** Item 渲染方法 */
  renderItem: (record: IProps['records'][number]) => React.ReactNode;
  /** 显示到末尾时的回调 */
  onScrollToBottom: () => void;
  /** 为了显示效果富余显示的 Item 数量上下各加 number, 建议是一个比 分页请求数量小的值 */
  buffer?: number;
}

const VirtualList: React.FC<IProps> = (props: IProps) => {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const { height, width, itemHeight, renderItem, recordKeyName, records, buffer = 5,onScrollToBottom } = props;
  const [start, setStart] = React.useState(0);
  const [offset, setOffset] = React.useState(0);
  const visibleCount = Math.ceil(height / itemHeight);
  const end = start + visibleCount;
  const listNum = records.length;
  const listHeight = listNum * itemHeight;
  const displayRecords = records.slice(start, Math.min(end, listNum));

  const scrollListener = React.useCallback(() => {
    if (!scrollRef.current) return;
    const scrollTop = scrollRef.current.scrollTop;
    const nextStart = Math.floor(scrollTop / itemHeight);
    const nextOffset = scrollTop - (scrollTop % itemHeight);
    setStart(nextStart);
    setOffset(nextOffset);
    const nextEnd = nextStart + visibleCount + buffer;
    if (nextEnd >= listNum && onScrollToBottom) onScrollToBottom();
  }, [visibleCount, listNum, itemHeight]);

  React.useEffect(() => {
    const dom = scrollRef.current;
    scrollListener();
    if (dom) dom.addEventListener('scroll', scrollListener);
    return () => {
      if (dom) dom.removeEventListener('scroll', scrollListener);
    };
  }, [scrollListener]);

  return (
    <div
      className={VirtualLess.container}
      ref={scrollRef}
      style={{ height, width: width || '100%' }}
    >
      {/* pillar 负责撑开滚动列表的实际高度 最小值为 height + 1 视为了撑开一个滚动控件,防止一些边界情况导致的 onScrollToBottom 不触发 */}
      <div className={VirtualLess.pillar} style={{ height: Math.max(listHeight, height + 1) }} />
      {/* realList 通过定位展示在可视区域 */}
      <div className={VirtualLess.realList} style={{ transform: `translate3d(0, ${offset}px, 0)` }}>
        {displayRecords.map((record) => {
          return (
            <div className={VirtualLess.listItem} key={record[recordKeyName]}>
              {renderItem(record)}
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default React.memo(VirtualList);

虚拟滚动组件的样式

.container {
  overflow-y: auto;
  position: relative;
}

.pillar{
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.realList {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  text-align: center;
}
.listItem{
  width: 100%;
  border: 1px solid red;
}
虚拟滚动组件的使用 demo
import AutoSizer from './AutoSizer';
import VirtualList from './VirtualList';
import { getData } from './MockRequest'; // 一个获得列表数据的mock 接口

const Virtual = () => {
  const [data, setData] = React.useState<{ id: string; title: string }[]>([]);
  const [pageNumber, setPageNumber] = React.useState(1);
  const [isLoading, setIsLoading] = React.useState(true);

  React.useEffect(() => {
    setIsLoading(true);
    getData({ pageNumber, pageSize: 50 }).then((d) => {
      setIsLoading(false);
      setData(d);
    });
  }, []);

  const onScrollToBottom = React.useCallback(() => {
    if (isLoading) return; // 上一次请求结束才能进行下一页请求,避免频繁请求
    const nextPageNumber = pageNumber + 1;
    getData({ pageNumber: nextPageNumber, pageSize: 50 }).then((d) => {
      setPageNumber(nextPageNumber);
      setIsLoading(false);
      setData(data.concat(d));
    });
    setIsLoading(true);
  }, [data, isLoading, pageNumber]);

  const renderItem = React.useCallback((record: { id: string; title: string }) => {
    return <div style={{ height: 50 }}>{record.title}</div>;
  }, []);

  return (
    <div style={{ width: '100%', height: '100%' }}>
      <AutoSizer>
        {({ width, height = 0 }) => (
          <VirtualList
            width={width}
            height={height}
            itemHeight={50}
            records={data}
            recordKeyName="id"
            renderItem={renderItem}
            onScrollToBottom={onScrollToBottom}
          />
        )}
      </AutoSizer>
    </div>
  );
};

export default React.memo(Virtual);

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值