高性能渲染十万条数据---虚拟列表

79 阅读4分钟

juejin.cn/post/737248…

虚拟列表

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,也就是设置缓冲区,从而达到极高的渲染性能。如下图所示,未滚动的时候上缓冲区是没有的,虚拟列表有三种情况:

  • 定高虚拟列表,每一项的高度固定,可以很容易计算
  • 不定高虚拟列表,每一项的高度是不固定的,但是我们知道整个列表的高度数组
  • 动态高度虚拟列表,每一项的高度在渲染时确认

对于所有虚拟列表而言,我们的实现考虑的都是如何计算出可视区域的节点索引范围,即[startIndex, endIndex],然后加上缓冲区的大小,将此范围内的节点渲染到容器中,我们最根本的目的是计算起始索引startIndex和终止索引endIndex

image.png

简单理解

  • 第一步:初始状态,屏幕的可见区域高度为500px,列表高度为50px,可以在屏幕最多只能看到10个列表项。首次渲染时,只需加载10条即可。

image.png

  • 第二步:滚动事件触发。滚动发生,滚动条距顶部位置150px,可知在可见区域内的列表项为第4项至第13项。

image.png 总结:首屏加载时,只加载可视区域内需要的列表项。当滚动发生时,动态计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。

列表的高度固定

  • 计算当前可视区域起始数据索引---startIndex
  • 计算当前可视区域结束数据索引---endIndex
  • 计算当前可视区域的结束,渲染到页面中
  • 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上

image.png

<div class="infinite-list-container">   // 可视区域的容器
    <div class="infinite-list-phantom"></div>   // 容器内的占位,高度为总列表高度,用于形成滚动条
    <div class="infinite-list">  // 列表项的渲染区域
        <!-- item1-->
        <!-- item2-->
        <!-- ... -->
        <!-- itemn-->
    </div>
</div>

接着,监听infinite-list-container的scroll事件,获取滚动位置scrollTop

  • 假定可视区域高度固定,称之为screenHeight

  • 假定列表每项高度固定,称之为itemSize

  • 假定列表数据称之为listData

  • 假定当前滚动位置称之为scrollTop 则可推算出:

  • 列表总高度listHeight = listData.length * itemSize

  • 可显示的列表项数visibleCount = Math.ceil(screenHeight / itemSize)

  • 数据的起始索引startIndex = Math.floor(scrollTop / itemSize)

  • 数据的结束索引endIndex = startIndex + visibleCount

  • 列表显示数据为visibleData = listData.slice(startIndex,endIndex)

当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。

偏移量startOffset = scrollTop - (scrollTop % itemSize);

代码如下:

<template>
  <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div ref="items"
        class="infinite-list-item" 
        v-for="item in visibleData" 
        :key="item.id"
        :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
      >{{ item.value }}</div>
    </div>
  </div>
</template>

<script>
export default {
  name:'VirtualList',
  props: {
    //所有列表数据
    listData:{
      type:Array,
      default:()=>[]
    },
    //每项高度
    itemSize: {
      type: Number,
      default:200
    }
  },
  computed:{
    //列表总高度
    listHeight(){
      return this.listData.length * this.itemSize;
    },
    //可显示的列表项数
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量对应的style
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    //获取真实显示列表数据
    visibleData(){
      return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  data() {
    return {
      //可视区域高度
      screenHeight:0,
      //偏移量
      startOffset:0,
      //起始索引
      start:0,
      //结束索引
      end:null,
    };
  },
  methods: {
    scrollEvent() {
      //当前滚动位置
      let scrollTop = this.$refs.list.scrollTop;
      //此时的开始索引
      this.start = Math.floor(scrollTop / this.itemSize);
      //此时的结束索引
      this.end = this.start + this.visibleCount;
      //此时的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
  }
};
</script>


<style scoped>
.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}

列表高度不固定

动态高度虚拟列表

原理:先设置一个默认的高度,并记录到缓存数据结构中,我们去监听节点的高度变换,在发生变化后重新计算缓存数据,然后更新列表。

定义默认高度

定义默认高度,在后面渲染时监听节点变化会更新缓存数据。

// 获取每一项的元数据
const getItemMetaData = (index: number) => {
    const {measuredDataMap, LastMeasuredItemIndex} = measuredData;
    // 如果index大于当前记录的最大值,挨个计算到index去,用top+height一个一个计算
    if (index > LastMeasuredItemIndex) {
        let topOffset =
                LastMeasuredItemIndex >= 0
                        ? measuredDataMap[LastMeasuredItemIndex].topOffset +
                                measuredDataMap[LastMeasuredItemIndex].height : 0;
        for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {
                // 这里用的都是默认高度,因为后面渲染时会更新缓存数据
                measuredDataMap[i] = {height: 50, topOffset};
                topOffset += 50;
        }
        measuredData.LastMeasuredItemIndex = index;
    }
    return measuredDataMap[index];
};

动态获取任意高度的项目

项目的高度都是通过getOneChildItem在渲染的时候动态获取到的。

// 获取任意高度的item
const getRandomHeightItem = (() => {
	let items: ReactNode[] | null = null;
	return () => {
		if (items) return items;
		items = [];
		const itemCount = 1000;
		for (let i = 0; i < itemCount; i++) {
			const height = 30 + Math.floor(Math.random() * 30);
			const style = {
				height,
				width: '100%',
			};
			items.push(<ChildItem key={i} childIndex={i} itemStyle={style} />);
		}
		return items;
	};
})();

// 动态获取子集
const DynamicChildItem = (options: DynamicChildItemProps) => {
	const {itemStyle, getChildItem, onSizeChange, childIndex} = options;
	const childRef = useRef(null);
	const resizeObserverRef = useRef<ResizeObserver | null>(null);

	useEffect(() => {
		const domNode = childRef.current;
		if (domNode) {
			if (!resizeObserverRef.current) {
				resizeObserverRef.current = new ResizeObserver(() => {
					onSizeChange(childIndex, domNode);
				});
			}
			resizeObserverRef.current.observe(domNode);
		}
		return () => {
			if (resizeObserverRef.current && domNode) {
				resizeObserverRef.current.unobserve(domNode);
			}
		};
	}, [childIndex, onSizeChange]);

	return (
		<div ref={childRef} style={itemStyle}>
			{getChildItem(childIndex)}
		</div>
	);
};

const getOneChildItem = (index: number) => getRandomHeightItem()[index];

调用

// 根据当前显示范围,插入节点,节点通过getOneChildItem动态获取
const getCurShowChild = (scrollTop: number) => {
    const items = [];
    const {bufferStartIndex, bufferEndIndex} = getChildShowRange(scrollTop);
    for (let i = bufferStartIndex; i <= bufferEndIndex; i++) {
            const item = getItemMetaData(i);
            const itemStyle: CSSProperties = {
                    position: 'absolute',
                    height: item.height,
                    width: '100%',
                    top: item.topOffset,
            };
            items.push(
                    <DynamicChildItem
                            key={`${i}${item.topOffset}`}
                            childIndex={i}
                            getChildItem={getOneChildItem}
                            onSizeChange={sizeChangeHandle}
                            itemStyle={itemStyle}
                    />,
            );
    }
    return items;
};

监听节点宽高变化

通过监听节点的尺寸变化去更新缓存数据,然后触发虚拟列表重新渲染,实现动态高度渲染

useEffect(() => {
    const domNode = childRef.current;
    if (domNode) {
            if (!resizeObserverRef.current) {
                    resizeObserverRef.current = new ResizeObserver(() => {
                            onSizeChange(childIndex, domNode);
                    });
            }
            resizeObserverRef.current.observe(domNode);
    }
    return () => {
            if (resizeObserverRef.current && domNode) {
                    resizeObserverRef.current.unobserve(domNode);
            }
    };
}, [childIndex, onSizeChange]);


// 监听节点尺寸变化,更新measuredDataMap,触发重新渲染
const sizeChangeHandle = (index: number, domNode: HTMLDivElement) => {
    const height = (domNode.children[0] as HTMLDivElement).offsetHeight;
    const {measuredDataMap, LastMeasuredItemIndex} = measuredData;
    measuredDataMap[index].height = height;
    let offset = 0;
    // 重新计算偏移值
    for (let i = 0; i <= LastMeasuredItemIndex; i++) {
            measuredDataMap[i].topOffset = offset;
            offset += measuredDataMap[i].height;
    }
    domNode.style.height = height + 'px';
    // 触发列表的一次更新
    setNeedUpdate(true);
};