虚拟列表威力揭秘,轻松应对大数据渲染挑战

706 阅读6分钟

原创 侯唐 / 叫叫技术团队

背景

在面对大数据列表的渲染时,我们常常面临一个共同的挑战:随着列表项的增加,渲染速度下降,上下滑动出现明显的卡顿现象。这种情况在移动端尤为明显,影响用户体验,甚至可能使应用变得难以使用。
为了解决这个问题,我们采取了一种高效的优化策略,即引入虚拟列表技术。虚拟列表技术允许我们只渲染可见区域内的列表项,而不是一次性渲染整个大数据列表。通过动态地加载和卸载列表项,我们能够显著提高渲染速度,降低内存占用,同时保持平滑的滑动效果。

基础流程

  • 列表渲染的基本数据流

  • 数据渲染与回收原理

为了解决大数据列表渲染慢,上下滑动卡顿问题,列表只渲染可见的数据,当上下滚动时,计算并填充渲染范围内变化的部分数据,并回收多余的 DOM 节点。

一、初始化配置

为了计算视口可渲染多少条数据,我们需要初始化配置视口的高度和每一条数据渲染的预估高度。

根据视口高度和每条数据预估高度,我们可以计算出视口的渲染数量,由于渲染数量是正整数,所以我们计算结果处理为向上取整:

const visibleCount = Math.ceil(viewportHeight / estimatedItemHeight);

在不同的场景渲染的数据和最终效果会有不同,所以我们需要自定义配置每一条数据的渲染样式。

const renderItem = (item: any) => {
	return (
		<div key={item.id}>
			<span style={{ color: 'red' }}>序号:{item.id}&nbsp;&nbsp;</span>
			<span>{item.text}</span>
		</div>
	);
};

二、初始化数据

  • 每条数据增加唯一 KEY

为了避免数据重复渲染,我们将在每条数据原来的基础上新增一个唯一 key ,数据集合用新的变量 $sourceData 接收:

const $sourceData = useMemo(() => {
	return sourceData?.map((item: any, index: number) => ({
		key: `${index}_${Math.random()}`,
		item
	}));
}, [sourceData]);
  • 计算数据位置信息

为了确定每条数据渲染的位置,我们需要预先计算出每一条数据的预估高度、顶部、底部的位置。假设每一条数据渲染的预估高度为 50 ,那么第 1 条数据渲染的底部 bottom 位置为 50 ,第 2 条数据渲染的底部 bottom 为前面一条数据渲染的高度加自身的渲染高度等于 100,以此类推,即第 n 条数据渲染的底部 bottom 位置为 n * 50”。 以变量 sourceData 表示渲染数据源,每条数据位置信息计算集合以变量 positions 表示:

// 初始化每一条数据的高度和位置信息
const positions = sourceData?.map((_, index) => ({
	index,
	height: estimatedItemHeight,
	top: index * estimatedItemHeight,
	bottom: (index + 1) * estimatedItemHeight
}));
  • 渲染索引

当渲染数据初始化或变更时,从数据的索引从 0 开始渲染,且只渲染视口可渲染数量。

// 渲染数据索引开始
const visibleStart = 0;
// 渲染数据索引结束
const visibleEnd = visibleStart + visibleCount;
  • 渲染数据

当计算出数据渲染的起始索引,即可从数据集合中截取出可渲染数据:

const visibleData = $sourceData.slice(visibleStart, visibleEnd);

三、计算渲染区域

  • 计算当前可见数据渲染高度

在数据渲染过程中,不同数据实际渲染高度不同,那么与我们初始化配置每条数据预估渲染高度有差异,所以需要根据每条数据实际渲染高度,重新计算更新当前及其后面数据的位置信息,以便于列表依次排列渲染。

举个例子,每条数据预估高度为 50 ,当渲染的第 3 条数据高度为 40 ,实际高度比预估高度小 10 ,那么当前这条数据的位置 bottom 则需要在原来的基础上减去 10 ,从第 4 条数据开始,后面的数据的 bottom 都将在原来的基础上减 10 。

// 可见数据渲染DOM节点集合
const nodes = content?.current.childNodes;
nodes.forEach((node: any) => {
	const rect = node.getBoundingClientRect();
	const realHeight = rect.height;
	const index = +node.id.slice(1);
	const oldHeight = positions[index].height;
	const dValue = oldHeight - realHeight;
	// 如果存在差值,就更新当前数据的后面所有数据位置信息
	if (dValue) {
		positions[index].bottom -= dValue;
		positions[index].height = realHeight;

		for (let k = index + 1; k < positions.length; k++) {
			positions[k].bottom -= dValue;
		}
	}
});
  • 计算虚拟背影高度

我们知道,当内容高度大于视口高度时,才会出现滚动条允许上下滚动渲染数据,由于可见数据渲染的累计高度不足以撑高全部内容高度,会导致滚动效果与实际交互相差甚远,所以需要在内容区域增加一个“虚拟背影”支撑全部内容的高度。

将位置信息集合的最后一条数据的 bottom 值作为“虚拟背影”的总高度,由此可得出“虚拟背景”的高度,即滚动条的高度和位置也就刚刚好。

const phantomHeight = positions[positions.length - 1]?.bottom || 0;
  • 上下滚动

上下滚动时,监听父容器 DOM 滚动事件,实时获取当前滚动位置:

// 滚动事件
const scrollEvent = () => {
	// 当前滚动高度
	const scrollTop = container?.current.scrollTop || 0;
}

由于上下滚动时,触发滚动事件频率较高,从而增加计算频率,所以我们可以采用“节流”方式来优化计算频率:

// 节流方法
const throttle = (func: () => void, delay: number) => {
  let t: any;
  return function () {
    if (!t) {
      t = setTimeout(() => {
        func();
        t = null;
      }, delay);
    }
  };
};

// 滚动事件
 const scrollEvent = throttle(() => {
    // 当前滚动位置
    const scrollTop = container?.current.scrollTop || 0;
  }, 100);

通过当前的高度,计算出列表渲染的起始索引:

const getStartIndex = (scrollTop: number) => {
	let _start = 0;
	let _end = positions.length - 1;
	let _tempIndex = null;
	while (_start <= _end) {
		const midIndex = Math.floor((_start + _end) / 2);
		const midValue = positions[midIndex].bottom;
		if (midValue === scrollTop) {
			return midIndex + 1;
		} else if (midValue < scrollTop) {
			_start = midIndex + 1;
		} else if (midValue > scrollTop) {
			if (_tempIndex === null || _tempIndex > midIndex) {
				_tempIndex = midIndex;
			}
			_end -= 1;
		}
	}
	return _tempIndex;
};
  • 计算偏移量

当渲染起始索引是 0 时,那么上下滚动偏移量为 0 ,如果渲染起始索引大于等于 1 ,那么从该条数据位置信息中取 bottom 值作为偏移量。

const startOffset = (startIndex: number = visibleStart) => {
	return startIndex >= 1 ? positions[startIndex - 1].bottom : 0;
};

React 完整代码

  • 组件引用展示:
import React, { useEffect, useState } from 'react';

import VirtualList from './VirtualList/index';

/**
 * 测试文本
 */
const testText =
  '在古老的波斯,有一个勇敢的年轻人,名叫阿里巴巴。一次,他偶然发现了隐藏在山洞中的宝库,里面堆满了金银财宝。然而,宝库却被四十大盗控制。阿里巴巴机智地利用魔法口令“开门啦”,成功进入宝库,并偷了一些金币回家。大盗发现宝库中的金币减少,怀疑有人闯入。他们找到阿里巴巴家,但是阿里巴巴的聪明伙伴策马出城,躲过了大盗的追捕。随后,阿里巴巴借助木桶和麻袋,将剩余的金币带回家中。他的贪婪的兄弟发现了这一切,想知道宝库的秘密,阿里巴巴不得不将魔法口令告诉他。大盗再次找到宝库,想利用魔法口令进入,却发现阿里巴巴已经改变了口令。最终,大盗被阿里巴巴的智慧所战胜,阿里巴巴成功保护了财富和自己的安全。';

export default () => {
  const [sourceData, setSourceData] = useState<any[]>([]);

  useEffect(() => {
    const min = 50;
    const max = testText.length;
    setSourceData(
      new Array(20000).fill(null).map((_: any, idx: number) => ({
        id: idx + 1,
        // 随机截取文本长度用于每条数据渲染自适应随机高度
        text: testText.slice(0, Math.random() * (max - min + 1) + min)
      }))
    );
  }, []);

  /**
   * 渲染每一条数据
   */
  const renderItem = (item: any) => {
    return (
      <div key={item.id} style={{ padding: '5px' }}>
        <span style={{ color: 'red' }}>序号:{item.id}&nbsp;&nbsp;</span>
        <span>{item.text}</span>
      </div>
    );
  };

  return (
    <div style={{ width: 500, height: 500, border: '1px solid #e1e1e1' }}>
			<VirtualList
				sourceData={sourceData}
				height={'100%'}
				estimatedItemHeight={140}
				onRenderItem={renderItem}
			/>
		</div>
  );
};

  • 虚拟列表组件:VirtualList/index.tsx
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

import styles from './styles.less';

interface Props {
  sourceData: any[]; // 所有列表数据
  height?: string | number;
  estimatedItemHeight?: number; // 预估高度
  onRenderItem: (data: any) => any; // 列表项渲染
}

/**
 * 初始位置
 */
let positions: any[] = [];

/**
 * 起始索引
 */
let cacheVisibleStart = 0;

/**
 * 渲染延迟定时器
 */
let timer: any = null;

/**
 * 节流
 */
const throttle = (func: () => void, delay: number) => {
  let t: any;
  return function () {
    if (!t) {
      t = setTimeout(() => {
        func();
        t = null;
      }, delay);
    }
  };
};

export default ({
  sourceData,
  onRenderItem,
  estimatedItemHeight = 100,
  height = '100%'
}: Props) => {
  const container: any = useRef(null);
  const content: any = useRef(null);

  // 可视区域高度
  const [viewportHeight, setViewportHeight] = useState<number>(0);

  // 可视渲染数据起始索引
  const [visibleStart, setVisibleStart] = useState<number>(0);

  // 可视渲染数据结束索引
  const [visibleEnd, setVisibleEnd] = useState<number>(0);

  // 当前偏移量
  const [startOffset, setStartOffset] = useState<number>(0);

  // 列表支撑高度
  const [phantomHeight, setPhantomHeight] = useState<number>(0);

  /**
   * 列表数据
   */
  const $sourceData = useMemo(() => {
    return sourceData?.map((item: any, index: number) => ({
      key: `${index}_${Math.random()}`,
      item
    }));
  }, [sourceData]);

  /**
   * 可见列表数量
   */
  const visibleCount = useMemo(() => {
    return Math.ceil(viewportHeight / estimatedItemHeight);
  }, [viewportHeight, estimatedItemHeight]);

  /**
   * 可见数据
   */
  const visibleData = useMemo(() => {
    return $sourceData.slice(visibleStart, visibleEnd);
  }, [$sourceData, visibleStart, visibleEnd]);

  /**
   * 初始化每一条数据的高度和位置信息
   */
  const initPositions = () => {
    positions = sourceData?.map((_, index) => ({
      index,
      height: estimatedItemHeight,
      bottom: (index + 1) * estimatedItemHeight
    }));
  };

  /**
   * 获取列表起始索引
   */
  const getStartIndex = (scrollTop: number) => {
    let _start = 0;
    let _end = positions.length - 1;
    let _tempIndex = null;
    while (_start <= _end) {
      const midIndex = Math.floor((_start + _end) / 2);
      const midValue = positions[midIndex].bottom;
      if (midValue === scrollTop) {
        return midIndex + 1;
      } else if (midValue < scrollTop) {
        _start = midIndex + 1;
      } else if (midValue > scrollTop) {
        if (_tempIndex === null || _tempIndex > midIndex) {
          _tempIndex = midIndex;
        }
        _end -= 1;
      }
    }
    return _tempIndex;
  };

  /**
   * 更新数据高度
   */
  const updateItemsHeight = () => {
    // 可见数据渲染DOM节点集合
    const nodes = content?.current.childNodes;
    nodes.forEach((node: any) => {
      const rect = node.getBoundingClientRect();
      const realHeight = rect.height;
      const index = +node.id.slice(1);
      const oldHeight = positions[index].height;
      const dValue = oldHeight - realHeight;
      // 如果存在差值,就更新当前数据的后面所有数据位置信息
      if (dValue) {
        positions[index].bottom -= dValue;
        positions[index].height = realHeight;

        for (let k = index + 1; k < positions.length; k++) {
          positions[k].bottom -= dValue;
        }
      }
    });
  };

  /**
   * 更新虚拟背影高度
   */
  const updatePhantomHeight = () => {
    const _phantomHeight = positions[positions.length - 1]?.bottom || 0;
    setPhantomHeight(_phantomHeight);
  };

  /**
   * 更新当前的偏移量
   */
  const updateStartOffset = (startIndex: number = visibleStart) => {
    const _startOffset = startIndex >= 1 ? positions[startIndex - 1].bottom : 0;
    setStartOffset(_startOffset);
  };

  /**
   * 上下滚动
   * 由于滚动事件频率较高,可以采用“节流”方式降低计算频率
   */
  const scrollEvent = throttle(() => {
    // 当前滚动位置
    const scrollTop = container?.current.scrollTop || 0;

    // 此时的开始索引
    cacheVisibleStart = getStartIndex(scrollTop) || 0;
    setVisibleStart(cacheVisibleStart);

    // 此时的结束索引
    const _end = cacheVisibleStart + visibleCount;
    setVisibleEnd(_end);

    // 此时的偏移量
    updateStartOffset(cacheVisibleStart);
  }, 100);

  useLayoutEffect(() => {
    if (content?.current?.childNodes?.length) {
      clearTimeout(timer);
      timer = setTimeout(() => {
        updateItemsHeight();
        updatePhantomHeight();

        // 更新真实偏移量
        updateStartOffset(cacheVisibleStart);
      }, 50);
    }
    return () => clearTimeout(timer);
  });

  useEffect(() => {
    cacheVisibleStart = 0;
    setVisibleStart(cacheVisibleStart);
    setVisibleEnd(visibleStart + visibleCount);
    initPositions();
    setViewportHeight(container?.current?.clientHeight || 100);
  }, [sourceData]);

  return (
    // 容器
    <div ref={container} className={styles.container} style={{ height }} onScroll={scrollEvent}>
      <div className={styles.phantom} style={{ height: `${phantomHeight}px` }} />
      {/* 列表内容 */}
      <div
        ref={content}
        style={{ transform: `translate3d(0,${startOffset}px,0)` }}
        className={styles.list}>
        {visibleData?.map((item: any) => (
          <div key={item.key} className={styles.listItem}>
            {onRenderItem?.(item.item)}
          </div>
        ))}
      </div>
    </div>
  );
};

  • 虚拟列表组件样式:virtualList/index.less
.container {
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;

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

  .list {
    left: 0;
    right: 0;
    top: 0;
    position: absolute;

    .listItem+.listItem {
      border-top: 1px solid #f0f0f0;

    }
  }
}

渲染效果

ezgif.com-video-to-gif.gif

虚拟列表 VS 普通列表

以 Vite 初始化的 React 项目为例,创建 20000 条纯文本数据,每条文本长度随机(1 - 300 个文字),比较虚拟列表和普通列表的差别。

事项虚拟列表普通列表
首屏加载速度👍 381 ms image.png3743 ms(伴随页面卡顿)image.png
DOM 列表渲染时间👍 24 ms image.png1851 ms(伴随页面卡顿) image.png
DOM 列表渲染数量👍 4 image.png20000 image.png
HTML 内存👍 5 kb image.png12068 kb image.png

优点总结

  • 提升渲染性能: 虚拟列表只渲染可见区域内的数据,避免了一次性渲染所有数据,减少了大量的 DOM 操作和页面重绘,从而显著提升了渲染性能。
  • 节省内存消耗: 传统方式渲染大数据需要创建大量的 DOM 元素,占用大量内存。虚拟列表只保持可见区域的 DOM 元素,降低了内存占用。
  • 流畅滑动体验: 虚拟列表在滑动过程中动态渲染新的数据项,使得滚动更加平滑,避免了卡顿和延迟,提供了更好的用户体验。
  • 快速加载速度: 虚拟列表只加载当前可见区域的数据,减少了初始化加载时间,使页面更快地展现给用户。
  • 适应不同设备: 虚拟列表适用于不同设备,包括移动端和桌面端。它可以根据设备的屏幕尺寸和性能自动调整渲染策略。
  • 降低 CPU 使用率: 虚拟列表的渲染策略降低了对 CPU 的使用,因为只需要处理少量的 DOM 操作,减少了浏览器的工作量。

参考文献