长度高度不固定的虚拟列表

8,709 阅读9分钟

这里的长度是指虚拟列表项的数量,由于需要滚动去加载数据,所以数据量不是开始就给定的。高度是指每个列表项的高度,这包含这一些用户输入的内容,包括文本、图片等。

github地址: git@github.com:yuwanlin/react-virtuallist.git

虚拟列表定义

虚拟列表是相对长列表来说的,长列表是指将所有数据全部以 dom 的形式渲染到页面上,即使有很多数据是在当前屏幕之外的,这么多的 dom 会占用大量的内存,如果列表项涉及到复杂的计算,还会占用大量的主线程资源。虚拟列表就是只展示一部分 dom 结构,对于当前屏幕之外的部分,前后渲染几个(保证滚动时不会出现空白区域),当滚动的时候,只改变 dom 树中的内容,不增加 dom 的数量,达到复用 dom 的效果,这正是享元模式的运用。

享元模式将类似对象分为内部状态和外部状态,内部状态是对象的共性部分,是可以用来复用的,外部状态是每个对象自己独立的部分,不可以被共享。享元模式适用于创建了大量类似的对象而导致内存占用过高的情况下,其通过共享对象中内部状态来减少内存的开销。在虚拟列表这个例子中,内部状态指 dom,外部状态是指每个列表项的内容。

实现思路

假如一个列表总共有 n 个列表项,但是只要求显示 m(m <= n)个列表项,那么 m 个列表项就是可视区域,n 个列表项的高度是可滚动区域。想象一下有个 1000px 的容器,容器外不可见,其中容纳着 10000px 的内容,那么就是这 10000px 的内容在 1000px 的容器中滚动。使用一张图表示如下,文中实现的是垂直方向的滚动列表。

如上图,可视区域和可滚动区域已经介绍过,可视区域的确定是通过 startIndex 和 endIndex 来确定的,它们代表在总的列表项中的开始元素和结束元素。

由于要保持可滚动区域高度是大于可视区域高度,所以有 startOffset 和 endOffset,可以认为它们是未渲染的元素的高度。在笔者的项目中,列表项高度都是未确定的,并且随着向下滚动会加载更多的元素,所以可滚动区域的高度就是未确定的,所以使用 padding-top 来填充 startOffset,padding-bottom 来填充 endOffset。startOffset 比较容易确定,可以拿到每个列表项相对于视口(visual viewport)的距离,endOffset 是那些没有渲染的列表项,其高度不确定,只能通过一个估计高度来确定,在这里,我使用的是列表项可能存在的最小高度值,这个值不需要非常精确。

实现过程

该组件的设计使用的是render-props方法,virtual-list组件接受到父组件传过来的配置参数,然后对配置参数进行处理,将一批新的参数传递给virtual-list的子组件。用法如下。

// 父组件调用虚拟列表组件
<VirtualList
  list={filterContent}
  numberOfContainer={8}
  beforeBufferSize={2}
  afterBufferSize={2}
  minHeight={200}
>
  {
    (props: ChildrenProps) => {
      const { handleCalculatePosition, index, item, handleUnmount } = props;
      const { uid } = item;
      return (
        <RecordItem
          key={ uid }
          item={ item }
          onCalculatePosition={handleCalculatePosition}
          onUnmount={handleUnmount}
          index={index}
        />
      )
    }
  }
</VirtualList>

// 虚拟列表组件的render内容
return (
  <div
    style={{paddingTop: top + 'px', paddingBottom : bottom + 'px'}}
    ref={listBoxRef}
  >
    {
      visibleList.map((item: any, index: number) => {
        return children!({
          item,
          index: startIndexRef.current + index,
          handleCalculatePosition,
          handleUnmount,
        })
      })
    }
  </div>
)

步骤如下

  1. 获取总列表,开始的时候是空数组,通过网络请求获取数据

  2. 根据传进来的参数设置 startIndex 和 endIndex

interface VirtualListProps {
  beforeBufferSize?: number
  afterBufferSize?: number
  list: any[]
  numberOfContainer: number
  children: (props: any) => React.ReactNode
  minHeight: number
}

list 必选。需要渲染的数据

numberOfContainer 必选。应当渲染几个列表元素,这些元素应该充满屏幕

beforeBufferSize 可选。在 numberOfContainer 个元素的前面渲染的元素,防止向上滚动时出现空白,默认值是 3

afterBufferSize 可选。在 numberOfContainer 个元素的后面渲染的元素,防止向下滚动时出现空白,默认值是 3

minHeight 元素的最小高度,对于非定高的元素,这用于确定 padding-bottom。对于等高元素,就是元素的高度

const updateStartIndex = (scrollTop: number) => {
  const targetItemPosition: ItemPosition = itmesPositionRef.current.find(({ bottom }) => bottom > scrollTop)!
  if(!targetItemPosition) return;
  lastScrollItemPosition.current = targetItemPosition;
  // 滚动的时候,可以确定lastScrollItemPosition,但是对于startIndex,要结合beforeBufferSize判断
  startIndexRef.current = targetItemPosition.index - beforeBufferSize >= 0 ? targetItemPosition.index - beforeBufferSize : 0;
}

let endIndex: number = startIndexRef.current + numberOfContainer + afterBufferSize;

itmesPositionRef 记录了每一个列表项的位置信息。其属性如下。top表示该列表项距离视口顶部的位置,bottom表示top加上自身的高度,index表示该列表项在所有列表的索引。startIndex的设置需要考虑到beforeBufferSize,当startIndex小于beforeBufferSize的时候,startIndex都从0开始,这是为了保留前beforeBufferSize个元素。

interface ItemPosition {
  top: number
  bottom: number
  index: number
}

targetItemPosition 表示该列表项的底部距离视口的高度大于 scrollTop 的列表项。开始的时候,scrollTop 是 0,那么 targetItemPosition 就是第一个列表项。 lastScrollItemPosition即上一次的targetItemPosition。当向下滚动时,如果scrollTop大于该位置的bottom的值时,说明该位置对应的列表项进入不可见区域,需要更新可见区域数据。同理,如果scrollTop小于该位置的top的值时,也需要更新列表。

const handleScroll = () => {
  const scrollTop = getScrollTop();
  // 滚动条向下滚动
  if(scrollTop > lastScrollTopRef.current) {
    if(scrollTop > lastScrollItemPosition.current.bottom) {
      updateStartIndex(scrollTop);
      updateVisibleList();
      lastScrollTopRef.current = scrollTop;
    }
  }

  // 滚动条向上滚动
  if(scrollTop < lastScrollTopRef.current) {
    if(scrollTop < lastScrollItemPosition.current.top) {
      updateStartIndex(scrollTop);
      updateVisibleList();
      lastScrollTopRef.current = scrollTop;
    }
  }
}
  1. 设置 startOffset 和 endOffset
const updateVisibleList = () => {
  let endIndex: number =
    startIndexRef.current + numberOfContainer + afterBufferSize;
  if (startIndexRef.current >= beforeBufferSize) {
    endIndex = endIndex + beforeBufferSize;
  }
  if (endIndex >= list.length) {
    endIndex = list.length;
  }

  let startOffset = 0,
    endOffset = 0;
  endOffset = (list.length - endIndex) * minHeight;

  // 滚动后更前列表前,最后一个元素的高度
  const position = itmesPositionRef.current.find(
    p => p.index === startIndexRef.current
  );
  if (position && itmesPositionRef.current[0]) {
    // itemspositionref.current可能将某一个元素添加了多次
    // 1. 只添加一次
    // 2. 遍历,找到index相等的,而不是找到index对应的元素
    startOffset = position.top - itmesPositionRef.current[0].top;
  }
  listOffsetRef.current = {
    startOffset,
    endOffset
  };
  setVisibleList(list.slice(startIndexRef.current, endIndex));
};

因为可能不是从页面顶部开始滚动,所以startOffset是新的开始列表项的top减去第一个列表项的top。

  1. 更新可视列表
setVisibleList(list.slice(startIndexRef.current, endIndex));
  1. 列表项挂载后,给每个列表项添加监听器,用于监听列表项高度的变化。监听高度变化使用的是MutationObserver API。相对于ResizeObserver,它的兼容性更好。 该API的原理是将dom元素的变化添加到作为micro-task添加到当前的macro-task,在响应上非常及时。

const addObserver = (node: HTMLElement & { mutationObserver: MutationObserver | null}, index: number) => {
  if(!node.mutationObserver) {
    // 上一次的高度
    let recordHeight = node.getBoundingClientRect().height;
    node.mutationObserver = new MutationObserver(function() {
      // 新高度
      let height = node.getBoundingClientRect().height;
      if (recordHeight === height) {
        return;
      }
      recordHeight = height;
      handleCalculatePosition(node, index, true);
    });

    node.mutationObserver.observe(node, {
      childList: true, // 子节点的变动(新增、删除或者更改)
      attributes: true, // 属性的变动
      characterData: true, // 节点内容或节点文本的变动
      subtree: true // 是否将观察器应用于该节点的所有后代节点
    });
  }
}

为什么需要监听列表项高度变化?在步骤2提到lastScrollItemPosition用于记录上一次滚动时的参照位置,滚动小于该位置的top或者大于该位置的bottom都会触发更新。

①假如该位置对应的列表项高度变化了,比如增加了100px,那么之前滚动到该列表项的底部才会更新,现在距离底部还剩100px就更新了可见区域元素。

②假如现在有1~10这10个列表项,beforeBufferSize为3,那么当滚动超过第4个列表项的底部时,第1个列表项应当消失。这时startOffset是第一个列表项的高度,设为h,但是如果在这期间第一个列表项高度增加了100px,那么startOffset的值应当是h + 100, 如果仍然是h,会造成下面所有的列表项都会向上跳了100px。

当列表项高度变化了之后,需要重新计算该列表项及其之后的所有列表项的位置信息,如下。

6.将列表项的位置信息添加到itmesPositionRef

const handleCalculatePosition = (node: HTMLElement, index: number, isDomtreeChanged?: boolean) => {
  if(!node) return;
  if(!MutationObserver) return;

  addObserver(node as any, index);
  const cachedPosition = itmesPositionRef.current!.find(position => position.index === index);
  if(cachedPosition) {
    // 如果dom树改变了,重新计算位置
    if(isDomtreeChanged) {
      cacheChangedPosition(node, index);
    }
    return;
  };
  const { top, height } = node.getBoundingClientRect();
  const nodeOffsetY = top + getScrollTop();
  const position = {
    top: nodeOffsetY,
    bottom: nodeOffsetY + height,
    index
  }
  itmesPositionRef.current.push(position);
}

更新数据

当滚动到页面底部时,需要通过网络请求去获取新的数据数据,列表数据改变后,需要更新可见区域的数据。

useEffect(() => {
  if(list.length === 0) {
    init();
    return;
  }
  // 当有多屏数据,当滚动的非常快的时候,比如滚动到页面底部后有一个updateVisibleList事件,在事件开始执行时list更新了
  // 那么这里也会有一个updateVisibleList,该事件先执行,数据是正确的。然后滚动到页面底部的updateVisibleList事件继续执行,数据是之前的数据。就有问题。
  // 使用setTimeout创建宏任务延迟执行,保证数据正确。
  setTimeout(() => {
    updateVisibleList();
  }, 0);
  window.addEventListener('scroll', handleScroll)

  return () => {
    window.removeEventListener('scroll', handleScroll)
  }
  // 只要list发生变化,就重新渲染。可能长度没变,但是其中某个item内容变了
}, [list])

当list发生变化时,需要重新添加绑定事件。这是因为handleScroll事件中用到了list,如果只绑定一次滚动事件,那么handleScroll中的list就永远是旧的。这是使用hooks需要注意的一点。

useEffect(() => {
  // 由于只在组件挂载时绑定一次事件,那么handleScroll中的状态就是最终时候的数据,不会改变
  window.addEventListener('scroll', handleScroll)

  return () => {
    window.removeEventListener('scroll', handleScroll)
  }
}, [])

实际效果

总结

虚拟列表是针对长列表的一项优化技术,使用了大批量类似的对象的情况。由于笔者项目中列表项数量不确定,故列表总高度也就未知。所以知道当前列表项数量的情况下,配合列表项距离视口的top距离以及列表项的最低高度(minHeight)来确定padding-top 和padding-bottom。另一个关键点是对列表项添加MutationObserver,监听列表项dom变化,当该列表项高度发生了变化,会影响该列表项及其之后所有列表项的位置信息。

参考资源

浅说虚拟列表的实现原理