Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

前言

工作中一直有接触大量数据渲染的业务,使用 react-window 多之又多,所以对虚拟列表有了些浅显的理解。今天,我们就照着 react-window 的使用方式来实现三种虚拟列表。

  • 元素固定高度的虚拟列表

  • 元素不定高度的虚拟列表

  • 元素动态高度的虚拟列表

虚拟列表核心原理

我们先来看一下整个虚拟列表元素的表现。

动画. gif

看右边的元素个数,会发现起初只有 6 个,之后无论怎么滚动,他都保持着 8 个元素,由此我们可以得出他的静态原理图是这样的。

static-height.png

当我们进行了滚动后。

static-height-scroll.png

从上面两图我们可以总结出,整个虚拟列表划分为三个区域,分别是上缓冲区 (0/2 个元素),可视区 (n 个元素),下缓冲区 (2 个元素)。当我们滚动到一个元素离开可视区范围内时,就去掉上缓冲区顶上的一个元素,然后再下缓冲区增加一个元素。这就是虚拟列表的核心原理了。

虚拟列表的实现

一、元素固定高度的虚拟列表

使用

const Row = ({ index, style, forwardRef }) => {  return (    <div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} ref={forwardRef}>      {`Row ${index}`}    </div>  )}const App = () => {  return (    <FixedSizeList      class      height={200}      width={200}      itemSize={50}      itemCount={1000}    >      {Row}    </FixedSizeList>  );}

实现

(1)首先先计算出由 1000 个元素撑起的盒子 (称之为 container) 的高度,撑开盒子,让用户能进行滚动操作。

(2)计算出可视区的起始索引、上缓冲区的起始索引以及下缓冲区的结束索引(就像上图滚动后,上缓冲区的起始索引为 2,可视区起始索引为 4,下缓冲区结束索引为 9)。

(3)采用绝对定位,计算上缓冲区到下缓冲区之间的每一个元素在 contianer 中的 top 值,只有知道 top 值才能让元素出现在可视区内。

static-height-scroll-over.png

(4)将上缓冲区到下缓冲区的元素塞到 container 中。

import { useState } from 'react';const FixedSizeList = (props) => {  const { height, width, itemSize, itemCount, children: Child } = props;  // 记录滚动掉的高度  const [scrollOffset, setScrollOffset] = useState(0);  // 外部容器高度  const containerStyle = {    position: 'relative',    width,    height,    overflow: 'auto',  };  // 1000个元素撑起盒子的实际高度  const contentStyle = {    height: itemSize * itemCount,    width: '100%',  };      const getCurrentChildren = () => {    // 可视区起始索引    const startIndex = Math.floor(scrollOffset / itemSize);    // 上缓冲区起始索引    const finialStartIndex = Math.max(0, startIndex - 2);    // 可视区能展示的元素的最大个数    const numVisible = Math.ceil(height / itemSize);    // 下缓冲区结束索引    const endIndex = Math.min(itemCount, startIndex + numVisible + 2);    const items = [];    // 根据上面计算的索引值,不断添加元素给container    for (let i = finialStartIndex; i < endIndex; i++) {      const itemStyle = {        position: 'absolute',        height: itemSize,        width: '100%',        // 计算每个元素在container中的top值        top: itemSize * i,      };      items.push(        <Child key={i} index={i} style={itemStyle} />      );    }    return items;  }  // 当触发滚动就重新计算  const scrollHandle = (event) => {    const { scrollTop } = event.currentTarget;    setScrollOffset(scrollTop);  }  return (    <div style={containerStyle} onScroll={scrollHandle}>       <div style={contentStyle}>          {getCurrentChildren()}       </div>    </div>  );};export default FixedSizeList;

结果

动画. gif

二、元素不定高度的虚拟列表

使用

const rowSizes = new Array(1000).fill(true).map(() => 25 + Math.round(Math.random() * 55))const getItemSize = (index) => rowSizes[index];const Row = ({ index, style }) => {  return (    <div className={index % 2 ? 'list-item-odd' : 'list-item-even'} style={style} >      Row {index}    </div>  )}const App = () => {  return (    <VariableSizeList      class      height={200}      width={200}      itemSize={getItemSize}      itemCount={1000}    >      {Row}    </VariableSizeList>  );}

从代码可以看出,Row 每一个高度都是随机的,就不能像第一种虚拟列表那样简单得通过 itemSize * index 计算出 top 值了。

思路

难点一:

由于每个元素高度不一,我们起先无法直接计算出 container 的总高度。

难点二:

每个元素高度不一,每个元素的 top 值不能通过 itemSize * index 直接计算出 top 值。

难点三:

每个元素高度不一,不能直接通过 scrollOffset / itemSize 计算出已被滚动掉的元素的个数,很难获取到可视区的起始索引。

难点一的解决方案

可以通过遍历所有的 Row 计算出总高度,但我认为计算出精确总高度的必要性不大,同时也为了兼容第三种虚拟列表,我们不去计算精确的总高度。现在我们回到出发点,思考 container 的高度的作用是什么?其实就是为了足够大,让用户能进行滚动操作,那我们可以自己假设每一个元素的高度,在乘上个数,弄出一个假的但足够高的 container 让用户去触发滚动事件。当然这种方案会带来一些小 bug(这个 bug 的影响不大,我认为是可以忽略的)。

难点二和难点三的解决方案

其实难点二和难点三本质都一样,元素高度不一,导致不知道被滚动掉了多少元素,只要知道被滚动掉的元素的个数,top 值和索引都迎刃而解。

我们可以采用这种解决方案,那就是每次只需要计算上缓冲区到下缓冲区之间的元素,并记录他们,并且记录下最底下的那个元素的索引,当用户进行滚动时,如果我们是向上滚动,就可以直接从已经计算好的记录里取,如果向下滚动,我们根据上一次记录的最大的索引的那个元素不断累加新元素的高度,直到它大于已经滚动掉的高度,此时的索引值就是可视区的起始索引了,这个起始索引所对应的 top 就是累加的高度。

文字看起来生硬拗口,我们可以看下面这张图。

unstable-height.png

每一个元素的 top 值都能通过上一个元素的 top 值 + 上一个元素的 height 计算出来。

举个例子,假设我们需要知道 item14 的 top 值

(1)我们先在记录里找有没有 item13 的数据,如果有,我们就拿 item13.top + item13.heighht 得到 item14 的 top。

(2)如果记录中(由上图得知我们只记录了 item1-item10 的数据)没有,我们就拿到记录中最后一个元素的数据(item10)进行累加,先计算并记录 item11 的,再计算并记录 item12 的,再计算并记录 item13 的,最后就是 item14 的了。

实现

import { useState } from 'react';// 元数据const measuredData = {  measuredDataMap: {},  LastMeasuredItemIndex: -1,};const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {  let measuredHeight = 0;  const { measuredDataMap, LastMeasuredItemIndex } = measuredData;  // 计算已经获取过真实高度的项的高度之和  if (LastMeasuredItemIndex >= 0) {    const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];    measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;  }  // 未计算过真实高度的项数  const unMeasuredItemsCount = itemCount - measuredData.LastMeasuredItemIndex - 1;  // 预测总高度  const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;  return totalEstimatedHeight;}const getItemMetaData = (props, index) => {  const { itemSize } = props;  const { measuredDataMap, LastMeasuredItemIndex } = measuredData;  // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset  if (index > LastMeasuredItemIndex) {    let offset = 0;    // 计算当前能计算出来的最大offset值    if (LastMeasuredItemIndex >= 0) {      const lastMeasuredItem = measuredDataMap[LastMeasuredItemIndex];      offset += lastMeasuredItem.offset + lastMeasuredItem.size;    }    // 计算直到index为止,所有未计算过的项    for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {      const currentItemSize = itemSize(i);      measuredDataMap[i] = { size: currentItemSize, offset };      offset += currentItemSize;    }    // 更新已计算的项的索引值    measuredData.LastMeasuredItemIndex = index;  }  return measuredDataMap[index];};const getStartIndex = (props, scrollOffset) => {  const { itemCount } = props;  let index = 0;  while (true) {    const currentOffset = getItemMetaData(props, index).offset;    if (currentOffset >= scrollOffset) return index;    if (index >= itemCount) return itemCount;    index++  }}const getEndIndex = (props, startIndex) => {  const { height, itemCount } = props;  // 获取可视区内开始的项  const startItem = getItemMetaData(props, startIndex);  // 可视区内最大的offset值  const maxOffset = startItem.offset + height;  // 开始项的下一项的offset,之后不断累加此offset,直到等于或超过最大offset,就是找到结束索引了  let offset = startItem.offset + startItem.size;  // 结束索引  let endIndex = startIndex;  // 累加offset  while (offset <= maxOffset && endIndex < (itemCount - 1)) {    endIndex++;    const currentItem = getItemMetaData(props, endIndex);    offset += currentItem.size;  }  return endIndex;};const getRangeToRender = (props, scrollOffset) => {  const { itemCount } = props;  const startIndex = getStartIndex(props, scrollOffset);  const endIndex = getEndIndex(props, startIndex);  return [    Math.max(0, startIndex - 2),    Math.min(itemCount - 1, endIndex + 2),    startIndex,    endIndex,  ];};const VariableSizeList = (props) => {  const { height, width, itemCount, itemEstimatedSize, children: Child } = props;  const [scrollOffset, setScrollOffset] = useState(0);  const containerStyle = {    position: 'relative',    width,    height,    overflow: 'auto',    willChange: 'transform'  };  const contentStyle = {    height: estimatedHeight(itemEstimatedSize, itemCount),    width: '100%',  };  const getCurrentChildren = () => {    const [startIndex, endIndex, originStartIndex, originEndIndex] = getRangeToRender(props, scrollOffset)    const items = [];    for (let i = startIndex; i <= endIndex; i++) {      const item = getItemMetaData(props, i);      const itemStyle = {        position: 'absolute',        height: item.size,        width: '100%',        top: item.offset,      };      items.push(        <Child key={i} index={i} style={itemStyle} />      );    }    return items;  }  const scrollHandle = (event) => {    const { scrollTop } = event.currentTarget;    setScrollOffset(scrollTop);  }  return (    <div style={containerStyle} onScroll={scrollHandle}>      <div style={contentStyle}>        {getCurrentChildren()}      </div>    </div>  );};export default VariableSizeList;

难点的地方都给了注释,如果一遍看不懂的话,可以去调试调试。

以上代码主要写了个思路和功能,其实优化点是很多的,这里给出两个显而易见的优化点。

  • 缓存每一个已经计算完成的 item 的样式,这样回滚的时候不用重新计算样式。

  • getStartIndex 可以通过二分法去优化。

结果

动画. gif

结果还是挺满意的了,这里提一下上文提到的小 bug,那就是在向下拉动滚动条时,鼠标和滚动条时脱节的。

元素动态高度的虚拟列表

最后这一种虚拟列表其实就是基于第二种来实现的,只不过增加监听元素高度变化事件,在某个元素发生变化的时候重新计算各种数据。

使用

const 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(        <div className={i % 2 ? 'list-item-odd' : 'list-item-even'} style={style}>Row {i}</div>    )}const Row = ({ index }) => items[index];const App = () => {    // 注意:这里我没有把itemSize传过去    return (        <VariableSizeList          class          height={200}          width={200}          itemCount={itemCount}          isDynamic        >            {Row}        </VariableSizeList>    );}

从上面代码可以看出,我们没将 itemSize 传过去,虚拟列表是不知道每一个元素的高度的,只有在渲染的时候执行了 Row 才知道。

实现

在上面那种虚拟列表进行改动

// 修改getCurrentChildren函数const getCurrentChildren = () => {  const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)  const items = [];  for (let i = startIndex; i <= endIndex; i++) {    const item = getItemMetaData(props, i);    const itemStyle = {      position: 'absolute',      height: item.size,      width: '100%',      top: item.offset,    };    items.push(      <ListItem key={i} index={i} style={itemStyle} ComponentType={Child} onSizeChange={sizeChangeHandle} />    );  }  return items;}// 增加sizeChangeHandleconst sizeChangeHandle = (index, domNode) => {  const height = domNode.offsetHeight;  const { measuredDataMap, lastMeasuredItemIndex } = measuredData;  const itemMetaData = measuredDataMap[index];  itemMetaData.size = height;  let offset = 0;  for (let i = 0; i <= lastMeasuredItemIndex; i++) {    const itemMetaData = measuredDataMap[i];    itemMetaData.offset = offset;    offset += itemMetaData.size;  }  setState({});}
// 增加一个ListItem组件class ListItem extends React.Component {  constructor(props) {    super(props);    this.domRef = React.createRef();    this.resizeObserver = null;  }  componentDidMount() {    if (this.domRef.current) {      const domNode = this.domRef.current.firstChild;      const { index, onSizeChange } = this.props;      this.resizeObserver = new ResizeObserver(() => {        onSizeChange(index, domNode);      });      this.resizeObserver.observe(domNode);    }  }  componentWillUnmount() {    if (this.resizeObserver && this.domRef.current.firstChild) {      this.resizeObserver.unobserve(this.domRef.current.firstChild);    }  }  render() {    const { index, style, ComponentType } = this.props;    return (      <div style={style} ref={this.domRef}>        <ComponentType index={index} />      </div>    )  }}

完整代码

import React, { useState } from 'react';// 元数据const measuredData = {  measuredDataMap: {},  lastMeasuredItemIndex: -1,};const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {  let measuredHeight = 0;  const { measuredDataMap, lastMeasuredItemIndex } = measuredData;  // 计算已经获取过真实高度的项的高度之和  if (lastMeasuredItemIndex >= 0) {    const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];    measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;  }  // 未计算过真实高度的项数  const unMeasuredItemsCount = itemCount - measuredData.lastMeasuredItemIndex - 1;  // 预测总高度  const totalEstimatedHeight = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;  return totalEstimatedHeight;}const getItemMetaData = (props, index) => {  const { itemSize, itemEstimatedSize = 50 } = props;  const { measuredDataMap, lastMeasuredItemIndex } = measuredData;  // 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset  if (index > lastMeasuredItemIndex) {    let offset = 0;    // 计算当前能计算出来的最大offset值    if (lastMeasuredItemIndex >= 0) {      const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];      offset += lastMeasuredItem.offset + lastMeasuredItem.size;    }    // 计算直到index为止,所有未计算过的项    for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {      const currentItemSize = itemSize ? itemSize(i) : itemEstimatedSize;      measuredDataMap[i] = { size: currentItemSize, offset };      offset += currentItemSize;    }    // 更新已计算的项的索引值    measuredData.lastMeasuredItemIndex = index;  }  return measuredDataMap[index];};const getStartIndex = (props, scrollOffset) => {  const { itemCount } = props;  let index = 0;  while (true) {    const currentOffset = getItemMetaData(props, index).offset;    if (currentOffset >= scrollOffset) return index;    if (index >= itemCount) return itemCount;    index++  }}const getEndIndex = (props, startIndex) => {  const { height, itemCount } = props;  // 获取可视区内开始的项  const startItem = getItemMetaData(props, startIndex);  // 可视区内最大的offset值  const maxOffset = startItem.offset + height;  // 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了  let offset = startItem.offset + startItem.size;  // 结束索引  let endIndex = startIndex;  // 累加offset  while (offset <= maxOffset && endIndex < (itemCount - 1)) {    endIndex++;    const currentItem = getItemMetaData(props, endIndex);    offset += currentItem.size;  }  return endIndex;};const getRangeToRender = (props, scrollOffset) => {  const { itemCount } = props;  const startIndex = getStartIndex(props, scrollOffset);  const endIndex = getEndIndex(props, startIndex);  return [    Math.max(0, startIndex - 2),    Math.min(itemCount - 1, endIndex + 2),    startIndex,    endIndex,  ];};class ListItem extends React.Component {  constructor(props) {    super(props);    this.domRef = React.createRef();    this.resizeObserver = null;  }  componentDidMount() {    if (this.domRef.current) {      const domNode = this.domRef.current.firstChild;      const { index, onSizeChange } = this.props;      this.resizeObserver = new ResizeObserver(() => {        onSizeChange(index, domNode);      });      this.resizeObserver.observe(domNode);    }  }  componentWillUnmount() {    if (this.resizeObserver && this.domRef.current.firstChild) {      this.resizeObserver.unobserve(this.domRef.current.firstChild);    }  }  render() {    const { index, style, ComponentType } = this.props;    return (      <div style={style} ref={this.domRef}>        <ComponentType index={index} />      </div>    )  }}const VariableSizeList = (props) => {  const { height, width, itemCount, itemEstimatedSize = 50, children: Child } = props;  const [scrollOffset, setScrollOffset] = useState(0);  const [, setState] = useState({});  const containerStyle = {    position: 'relative',    width,    height,    overflow: 'auto',    willChange: 'transform'  };  const contentStyle = {    height: estimatedHeight(itemEstimatedSize, itemCount),    width: '100%',  };  const sizeChangeHandle = (index, domNode) => {    const height = domNode.offsetHeight;    const { measuredDataMap, lastMeasuredItemIndex } = measuredData;    const itemMetaData = measuredDataMap[index];    itemMetaData.size = height;    let offset = 0;    for (let i = 0; i <= lastMeasuredItemIndex; i++) {      const itemMetaData = measuredDataMap[i];      itemMetaData.offset = offset;      offset += itemMetaData.size;    }    setState({});  }      const getCurrentChildren = () => {    const [startIndex, endIndex] = getRangeToRender(props, scrollOffset)    const items = [];    for (let i = startIndex; i <= endIndex; i++) {      const item = getItemMetaData(props, i);      const itemStyle = {        position: 'absolute',        height: item.size,        width: '100%',        top: item.offset,      };      items.push(        <ListItem key={i} index={i} style={itemStyle} ComponentType={Child} onSizeChange={sizeChangeHandle} />      );    }    return items;  }  const scrollHandle = (event) => {    const { scrollTop } = event.currentTarget;    setScrollOffset(scrollTop);  }  return (    <div style={containerStyle} onScroll={scrollHandle}>      <div style={contentStyle}>        {getCurrentChildren()}      </div>    </div>  );};export default VariableSizeList;

结果

动画. gif

结尾

react-window 只有前两种虚拟列表,最后一种虚拟列表是在别的虚拟列表库中有,借鉴了一下各路大佬的思路实现的,总得来说三种虚拟列表虽然表现和实现都不同,但只要掌握了核心原理,手撸出来虚拟列表还是手到擒来的。

最后,希望这篇文章能帮助到各位读者。同时也非常欢迎各位大佬对上面的各种实现提出建议,也希望各位大佬对于第二种虚拟列表提出更多的优化点。

代码仓库地址:点这里

  • END -

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。