Skip to content
本页目录

react-window 源码阅读

github 仓库地址

react-window 是什么

react-window 是一个实现虚拟列表的库,对于一些数据量比较大且没有分页的情况下的渲染使用该库非常的有效。

虚拟列表其实主要分为两种:定高和不定高,在react-window中,就是FixedSizeListVariableSizeList.

react-window 源码

react-window 是以 flow 作为类型检查工具,该库类似于 TS。不管是 FixedSizeList 还是 VariableSizeList 都是在 createListComponent 的基础上创建的

tsx
// 定高虚拟列表
const FixedSizeList = createListComponent({
  ...
})

// 不定高虚拟列表
const VariableSizeList = createListComponent({
  ...
})

因此,先看一下createListComponent的实现,以下所有代码都是简化的代码,想要详细的知道其所有实现可移步源码查看。

createListComponent

tsx
import { createElement, PureComponent } from 'react'

export default function createListComponent({
  getItemOffset,
  getEstimatedTotalSize,
  getItemSize,
  getOffsetForIndexAndAlignment,
  getStartIndexForOffset,
  getStopIndexForStartIndex,
  initInstanceProps,
  shouldResetStyleCacheOnItemSizeChange,
  validateProps
}) {
  return class List extends PureComponent {
    // 根据scrollOffset更新滚动信息
    scrollTo(scrollOffset) {}
    // 通过传递对应序号滚动至某一item上
    scrollToItem(index, align = 'auto') {
      // ...省略
      // 通过index获取对应item的偏移量,最后通过偏移量滚动至对应的item
      // getOffsetForIndexAndAlignment为List传入过来的参数
      this.scrollTo(
        getOffsetForIndexAndAlignment(
          this.props,
          index,
          align,
          scrollOffset,
          this._instanceProps,
          scrollbarSize
        )
      )
    }
    // 缓存参数,缓存参数时使用的memoize-one库,该库是一个缓存工具库,通过比较上一次和这一次的参数来减少函数的请求次数,从而优化性能
    _callOnItemsRendered = memoizeOne(
      (overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex) =>
        this.props.onItemsRendered({
          overscanStartIndex, //真实起点
          overscanStopIndex, //真实终点
          visibleStartIndex, //视图起点
          visibleStopIndex //视图终点
        })
    )
    // 缓存滚动信息
    _callOnScroll = memoizeOne((scrollDirection, scrollOffset, scrollUpdateWasRequested) =>
      this.props.onScroll({ scrollDirection, scrollOffset, scrollUpdateWasRequested })
    )
    // 通过 index 来获取对应的style, 其中有width, height, left, top 等具体位置属性, 同时这些属性也有缓存
    _getItemStyle = (index) => {}
    // 获取overscanStartIndex,overscanStopIndex,startIndex,stopIndex
    _getRangeToRender() {}
    //监听滚动事件 水平方向
    _onScrollHorizontal = (event) => {}
    //监听滚动事件 垂直方向
    _onScrollVertical = (event) => {}
    // 渲染函数
    render() {}
  }
}

createListComponent方法中,使用了 memoize-one 库来缓存参数。memoize-one库是一种 JavaScript 缓存工具,其主要作用是优化性能。它的工作原理是通过比较当前和上一次两者的参数来减少函数的请求次数,从而提高性能。这种优化主要是使用了闭包的工作原理,将内部的函数存在内存中。使用示例如下:

jsx
import memoizeOne from 'memoize-one'

const add = (a, b) => a + b
const memoizedAdd = memoizeOne(add)

console.log(memoizedAdd(1, 2)) // 输出:3
console.log(memoizedAdd(1, 2)) // 输出:3,此次不会重新计算,而是返回缓存的结果

下面将详细讲解一下这些方法

render渲染函数

render 函数根据 startIndex,stopIndex 调用 createElement,创建 dom 结构,并绑定 onScroll 事件

tsx
function render() {
  const {
    children,
    className,
    direction,
    height,
    innerRef,
    innerElementType,
    innerTagName,
    itemCount,
    itemData,
    itemKey = defaultItemKey,
    layout,
    outerElementType,
    outerTagName,
    style,
    useIsScrolling,
    width
  } = this.props
  // ...省略
  // 获取起始索引和结束索引
  const [startIndex, stopIndex] = this._getRangeToRender()
  // 根据startIndex,stopIndex调用createElement,创建dom结构
  const items = []
  if (itemCount > 0) {
    for (let index = startIndex; index <= stopIndex; index++) {
      items.push(
        createElement(children, {
          data: itemData,
          key: itemKey(index, itemData),
          index,
          isScrolling: useIsScrolling ? isScrolling : undefined,
          style: this._getItemStyle(index)
        })
      )
    }
  }
  // 获取实际高度
  const estimatedTotalSize = getEstimatedTotalSize(this.props, this._instanceProps)
  return createElement(
    outerElementType || outerTagName || 'div',
    {
      className,
      onScroll, // 绑定onScroll事件
      ref: this._outerRefSetter,
      style: {
        position: 'relative',
        height,
        width,
        overflow: 'auto',
        WebkitOverflowScrolling: 'touch',
        willChange: 'transform',
        direction,
        ...style
      }
    },
    createElement(innerElementType || innerTagName || 'div', {
      children: items,
      ref: innerRef,
      style: {
        height: isHorizontal ? '100%' : estimatedTotalSize,
        pointerEvents: isScrolling ? 'none' : undefined,
        width: isHorizontal ? estimatedTotalSize : '100%'
      }
    })
  )
}

_getRangeToRender

_getRangeToRender计算需要渲染的起始和结束索引等数据并返回

tsx
function _getRangeToRender() {
  // itemCount:列表渲染数  overscanCount:缓冲数
  const { itemCount, overscanCount } = this.props
  const { isScrolling, scrollDirection, scrollOffset } = this.state
  // 起始索引——根据offset计算  getStartIndexForOffset为List传入的
  const startIndex = getStartIndexForOffset(this.props, scrollOffset, this._instanceProps)
  // 结束索引 getStopIndexForStartIndex为List传入的
  const stopIndex = getStopIndexForStartIndex(
    this.props,
    startIndex,
    scrollOffset,
    this._instanceProps
  )
  const overscanBackward =
    !isScrolling || scrollDirection === 'backward' ? Math.max(1, overscanCount) : 1
  const overscanForward =
    !isScrolling || scrollDirection === 'forward' ? Math.max(1, overscanCount) : 1

  return [
    Math.max(0, startIndex - overscanBackward),
    Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)),
    startIndex,
    stopIndex
  ]
}

_getItemStyle

_getItemStyle通过 index 来获取对应 item 的 style, 其中有 height,width, left, top 等具体位置属性, 同时这些属性也有缓存

tsx
function _getItemStyle(index) {
  let style
  // 有缓存取缓存
  if (itemStyleCache.hasOwnProperty(index)) {
    style = itemStyleCache[index]
  } else {
    // 根据index计算item的offset
    const offset = getItemOffset(this.props, index, this._instanceProps)
    // 根据index计算每个item的itemSize
    const size = getItemSize(this.props, index, this._instanceProps)

    // 将index的style缓存至itemStyleCache对象中
    itemStyleCache[index] = style = {
      position: 'absolute',
      left: isRtl ? undefined : offsetHorizontal,
      right: isRtl ? offsetHorizontal : undefined,
      top: !isHorizontal ? offset : 0,
      height: !isHorizontal ? size : '100%',
      width: isHorizontal ? size : '100%'
    }
  }
  return style
}

_onScrollVertical

_onScrollVertical用于监听滚动事件

tsx
function _onScrollVertical(event) {
  const { clientHeight, scrollHeight, scrollTop } = event.currentTarget
  this.setState((prevState) => {
    if (prevState.scrollOffset === scrollTop) {
      return null
    }

    // Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds.
    const scrollOffset = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight))

    return {
      isScrolling: true,
      scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
      scrollOffset,
      scrollUpdateWasRequested: false
    }
  }, this._resetIsScrollingDebounced)
}

_callPropsCallbacks

_callPropsCallbacks方法用于缓存数据

tsx
// 缓存节点,滚动状态等数据
function _callPropsCallbacks() {
  // 传递onItemsRendered函数时,更新缓存
  if (typeof this.props.onItemsRendered === 'function') {
    const { itemCount } = this.props
    if (itemCount > 0) {
      const [
        overscanStartIndex, //真实起点
        overscanStopIndex, //真实终点
        visibleStartIndex, //视图起点
        visibleStopIndex //视图终点
      ] = this._getRangeToRender()
      // 更新缓存
      this._callOnItemsRendered(
        overscanStartIndex,
        overscanStopIndex,
        visibleStartIndex,
        visibleStopIndex
      )
    }
  }

  // 传递onScroll时,滚动时缓存滚动信息
  if (typeof this.props.onScroll === 'function') {
    const { scrollDirection, scrollOffset, scrollUpdateWasRequested } = this.state
    // 缓存滚动信息
    this._callOnScroll(scrollDirection, scrollOffset, scrollUpdateWasRequested)
  }
}

scrollToItem

scrollToItem 方法用于快速滚动至某个 item 的位置上,示例如下:

tsx
const ReactWindow = () => {
  const listRef = useRef<any>(null)

  const handleScrollToRow = (num: number, type: string) => {
    listRef.current.scrollToItem(num, type)
  }
  return (
    <>
      <button className="h-8 border-b-[1px]" onClick={() => handleScrollToRow(200, 'auto')}>
        滚动到item为第200个的位置 (align: auto)
      </button>
      <FixedSizeList ref={listRef} height={800} itemCount={1000} itemSize={35} width="100%">
        ...省略
      </FixedSizeList>
      <button className="h-8 border-b-[1px]" onClick={() => handleScrollToRow(0, 'center')}>
        滚动到起始位置 (align: auto)
      </button>
    </>
  )
}

该方法接收两个参数,index 和 align

tsx
// 根据scrollOffset更新滚动信息
function scrollTo(scrollOffset) {
  // 确保scrollOffset大于0
  scrollOffset = Math.max(0, scrollOffset)
  this.setState((prevState) => {
    if (prevState.scrollOffset === scrollOffset) {
      return null
    }
    return {
      // 滚动方向
      scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
      // 滚动距离
      scrollOffset: scrollOffset,
      scrollUpdateWasRequested: true
    }
  }, this._resetIsScrollingDebounced)
}

function scrollToItem(index, align = 'auto') {
  // itemCount:列表渲染总数,layout:布局
  const { itemCount, layout } = this.props
  // 滚动距离
  const { scrollOffset } = this.state
  // 保证index在0-item最大值之间
  index = Math.max(0, Math.min(index, itemCount - 1))

  // ...省略

  // 通过index获取对应item的偏移量,最后通过偏移量滚动至对应的item
  // getOffsetForIndexAndAlignment为List传入过来的参数
  this.scrollTo(
    getOffsetForIndexAndAlignment(
      this.props,
      index,
      align,
      scrollOffset,
      this._instanceProps,
      scrollbarSize
    )
  )
}

createListComponent 总结

  1. 根据 List 传递的 getStartIndexForOffset、getStopIndexForStartIndex 和 scrollOffset 计算 startIndex、stopIndex
  2. 根据 startIndex、stopIndex 调用 createElement,创建 dom 结构(创建的外部容器 div 绑定 onScroll 事件)
  3. 根据 index 获取每个 item 对应的 style(_getItemStyle 方法实现的)
  4. onScroll 事件监听滚动,更新滚动信息,根据 scrollOffset 计算得到最新的 startIndex、stopIndex,进而更新视图内容

FixedSizeList

其实 FixedSizeList 就是调用 createListComponent 来创建最终的结果

tsx
const FixedSizeList = createListComponent({
  // 计算每个item的offset
  getItemOffset: ({ itemSize }, index) => index * itemSize,
  // 计算每个item的itemSize
  getItemSize: ({ itemSize }) => itemSize,
  // 计算列表总的高度
  getEstimatedTotalSize: ({ itemCount, itemSize }) => itemCount * itemSize,
  // 通过index获取对应item的偏移量
  getOffsetForIndexAndAlignment: (
    { direction, height, itemCount, itemSize, layout, width },
    index,
    align,
    scrollOffset,
    instanceProps,
    scrollbarSize
  ) => {
    const isHorizontal = direction === 'horizontal' || layout === 'horizontal'
    const size = isHorizontal ? width : height
    // 最后一个item的offset
    const lastItemOffset = Math.max(0, itemCount * itemSize - size)
    const maxOffset = Math.min(lastItemOffset, index * itemSize)
    const minOffset = Math.max(0, index * itemSize - size + itemSize + scrollbarSize)
    if (align === 'smart') {
      if (scrollOffset >= minOffset - size && scrollOffset <= maxOffset + size) {
        align = 'auto'
      } else {
        align = 'center'
      }
    }
    switch (align) {
      case 'start':
        return maxOffset
      case 'end':
        return minOffset
      case 'center': {
        // "Centered" offset is usually the average of the min and max.
        // But near the edges of the list, this doesn't hold true.
        const middleOffset = Math.round(minOffset + (maxOffset - minOffset) / 2)
        if (middleOffset < Math.ceil(size / 2)) {
          return 0 // near the beginning
        } else if (middleOffset > lastItemOffset + Math.floor(size / 2)) {
          return lastItemOffset // near the end
        } else {
          return middleOffset
        }
      }
      case 'auto':
      default:
        if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
          return scrollOffset
        } else if (scrollOffset < minOffset) {
          return minOffset
        } else {
          return maxOffset
        }
    }
  },
  // 计算渲染列表的起始索引
  getStartIndexForOffset: ({ itemCount, itemSize }, offset) =>
    Math.max(0, Math.min(itemCount - 1, Math.floor(offset / itemSize))),
  // 计算渲染列表的结束索引
  getStopIndexForStartIndex: (
    { direction, height, itemCount, itemSize, layout, width },
    startIndex,
    scrollOffset
  ) => {
    const isHorizontal = direction === 'horizontal' || layout === 'horizontal'
    const offset = startIndex * itemSize
    const size = isHorizontal ? width : height
    // 可视区显示的列表数
    const numVisibleItems = Math.ceil((size + scrollOffset - offset) / itemSize)
    return Math.max(0, Math.min(itemCount - 1, startIndex + numVisibleItems - 1))
  }
})

FixedSizeList 的使用

tsx
import { FixedSizeList } from 'react-window'

const renderRow = ({ index, style }: any) => {
  return (
    <div
      className={`${
        index % 2
          ? 'flex items-center justify-center border-b-[1px]'
          : 'flex items-center justify-center  border-b-[1px] bg-[#f8f8f0]'
      }`}
      style={style}
    >
      Row-{index}
    </div>
  )
}

const ReactWindow = () => {
  return (
    <FixedSizeList height={800} itemCount={1000} itemSize={35} width="100%">
      {renderRow}
    </FixedSizeList>
  )
}

export default ReactWindow

VariableSizeList

VariableSizeListFixedSizeList 不同的是每个 item 的高度是不定的,出于性能考虑,VariableSizeList需要一个数组存储 item 的 offset 和 size,在VariableSizeList中使用itemMetadataMap来存储

tsx
// props初始化
const initInstanceProps = (props, instance) => {
  // 预估高度,默认值为50
  const { estimatedItemSize } = props
  const instanceProps = {
    itemMetadataMap: {}, //存储每个item的offset 和 size值
    estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE,
    lastMeasuredIndex: -1 //上次记录的索引
  }
  // resetAfterIndex方法用于清楚指定index后的所有item的缓存数据,每当itemSize发生变化都会调用
  // 默认情况下,重置索引后列表会重新渲染
  instance.resetAfterIndex = (index, shouldForceUpdate = true) => {
    instanceProps.lastMeasuredIndex = Math.min(instanceProps.lastMeasuredIndex, index - 1)
    // 清空缓存
    instance._getItemStyleCache(-1)
    if (shouldForceUpdate) {
      instance.forceUpdate()
    }
  }
  return instanceProps
}

createListComponent 中已经知道,使用的是 startIndex 和 stopIndex 来创建对应的 dom 结构,另外每个 item 初始时只有一个预估高度,在渲染的时候根据 index 来获取每个 item 对应的 style(包括 height,width,left,top 等值),要获取以上这些信息,就需要计算每个 item 的 offset 以及 itemSize

tsx
const getItemOffset = (props, index, instanceProps) => {
  return getItemMetadata(props, index, instanceProps).offset
}

// 根据index获取某个item的offset和size
const getItemMetadata = (props, index, instanceProps) => {
  const { itemSize } = props
  const { itemMetadataMap, lastMeasuredIndex } = instanceProps
  if (index > lastMeasuredIndex) {
    let offset = 0
    if (lastMeasuredIndex >= 0) {
      const itemMetadata = itemMetadataMap[lastMeasuredIndex]
      offset = itemMetadata.offset + itemMetadata.size
    }

    for (let i = lastMeasuredIndex + 1; i <= index; i++) {
      let size = itemSize(i)
      itemMetadataMap[i] = {
        offset,
        size
      }
      offset += size
    }
    instanceProps.lastMeasuredIndex = index
  }

  return itemMetadataMap[index]
}

计算列表总的高度 getEstimatedTotalSize

tsx
// 总的高度=totalSizeOfMeasuredItems+totalSizeOfUnmeasuredItems
// totalSizeOfMeasuredItems为计算后的,itemMetadataMap[lastMeasuredIndex]的offset加上该item的size
// totalSizeOfUnmeasuredItems为未计算的item,因此高度采用的是预估高度estimatedItemSize
const getEstimatedTotalSize = (
  { itemCount },
  { itemMetadataMap, estimatedItemSize, lastMeasuredIndex }
) => {
  let totalSizeOfMeasuredItems = 0
  if (lastMeasuredIndex >= itemCount) {
    lastMeasuredIndex = itemCount - 1
  }
  if (lastMeasuredIndex >= 0) {
    const itemMetadata = itemMetadataMap[lastMeasuredIndex]
    totalSizeOfMeasuredItems = itemMetadata.offset + itemMetadata.size
  }
  const numUnmeasuredItems = itemCount - lastMeasuredIndex - 1
  const totalSizeOfUnmeasuredItems = numUnmeasuredItems * estimatedItemSize

  return totalSizeOfMeasuredItems + totalSizeOfUnmeasuredItems
}

计算渲染列表的起始索引 getStartIndexForOffset

tsx
const getStartIndexForOffset = (props, offset, instanceProps) => {
  return findNearestItem(props, offset, instanceProps)
}

const findNearestItem = (props, offset, instanceProps) => {
  const { itemMetadataMap, lastMeasuredIndex } = instanceProps
  const lastMeasuredItemOffset =
    lastMeasuredIndex > 0 ? itemMetadataMap[lastMeasuredIndex].offset : 0
  if (lastMeasuredItemOffset >= offset) {
    // 如果已经计算过这个item的offset,则使用二分法查找
    return findNearestItemBinarySearch(props, instanceProps, lastMeasuredIndex, 0, offset)
  } else {
    // 如果没有测量到这么高的值,则使用指数搜索和内部二分法搜索
    return findNearestItemExponentialSearch(
      props,
      instanceProps,
      Math.max(0, lastMeasuredIndex),
      offset
    )
  }
}

// 二分法搜索
const findNearestItemBinarySearch = (props, instanceProps, high, low, offset) => {
  while (low <= high) {
    const middle = low + Math.floor((high - low) / 2)
    const currentOffset = getItemMetadata(props, middle, instanceProps).offset
    if (currentOffset === offset) {
      return middle
    } else if (currentOffset < offset) {
      low = middle + 1
    } else if (currentOffset > offset) {
      high = middle - 1
    }
  }
  if (low > 0) {
    return low - 1
  } else {
    return 0
  }
}

// 指数搜索
const findNearestItemExponentialSearch = (props, instanceProps, index, offset) => {
  const { itemCount } = props
  let interval = 1
  while (index < itemCount && getItemMetadata(props, index, instanceProps).offset < offset) {
    index += interval
    interval *= 2
  }
  return findNearestItemBinarySearch(
    props,
    instanceProps,
    Math.min(index, itemCount - 1),
    Math.floor(index / 2),
    offset
  )
}

计算结束索引 getStopIndexForStartIndex

tsx
const getStopIndexForStartIndex = (props, startIndex, scrollOffset, instanceProps) => {
  const { direction, height, itemCount, layout, width } = props
  const isHorizontal = direction === 'horizontal' || layout === 'horizontal'
  const size = isHorizontal ? width : height
  const itemMetadata = getItemMetadata(props, startIndex, instanceProps)
  const maxOffset = scrollOffset + size

  let offset = itemMetadata.offset + itemMetadata.size
  let stopIndex = startIndex

  while (stopIndex < itemCount - 1 && offset < maxOffset) {
    stopIndex++
    offset += getItemMetadata(props, stopIndex, instanceProps).size
  }

  return stopIndex
}
tsx
const VariableSizeList = createListComponent({
  // 计算每个item的offset
  getItemOffset: (props, index, instanceProps) =>
    getItemMetadata(props, index, instanceProps).offset,
  // 计算每个item的itemSize,根据index在itemMetadataMap查找,itemMetadataMap存储的是offset和size
  getItemSize: (props, index, instanceProps) => instanceProps.itemMetadataMap[index].size,
  // 计算列表总的高度
  getEstimatedTotalSize,
  // 通过index获取对应item的偏移量
  getOffsetForIndexAndAlignment: (
    props,
    index,
    align,
    scrollOffset,
    instanceProps,
    scrollbarSize
  ) => {
    const isHorizontal = direction === 'horizontal' || layout === 'horizontal'
    const size = isHorizontal ? width : height
    const itemMetadata = getItemMetadata(props, index, instanceProps)
    const estimatedTotalSize = getEstimatedTotalSize(props, instanceProps)

    const maxOffset = Math.max(0, Math.min(estimatedTotalSize - size, itemMetadata.offset))
    const minOffset = Math.max(0, itemMetadata.offset - size + itemMetadata.size + scrollbarSize)

    if (align === 'smart') {
      if (scrollOffset >= minOffset - size && scrollOffset <= maxOffset + size) {
        align = 'auto'
      } else {
        align = 'center'
      }
    }
    switch (align) {
      case 'start':
        return maxOffset
      case 'end':
        return minOffset
      case 'center':
        return Math.round(minOffset + (maxOffset - minOffset) / 2)
      case 'auto':
      default:
        if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
          return scrollOffset
        } else if (scrollOffset < minOffset) {
          return minOffset
        } else {
          return maxOffset
        }
    }
  },
  // 计算渲染列表的起始索引
  getStartIndexForOffset: (props, offset, instanceProps) =>
    findNearestItem(props, offset, instanceProps),
  // 计算渲染列表的结束索引
  getStopIndexForStartIndex: (props, startIndex, scrollOffset, instanceProps) => {
    // ...省略
    return stopIndex
  },
  // props初始化
  initInstanceProps: (props, instance) => {
    // ...省略
    return instanceProps
  }
})

VariableSizeList 的使用

tsx
import { VariableSizeList } from 'react-window'

const rowSizes = new Array(1000).fill(true).map(() => 25 + Math.round(Math.random() * 50))

const getItemSize = (index: number) => rowSizes[index]

const renderRow = ({ index, style }: any) => {
  return (
    <div
      className={`${
        index % 2
          ? 'flex items-center justify-center border-b-[1px]'
          : 'flex items-center justify-center  border-b-[1px] bg-[#f8f8f0]'
      }`}
      style={style}
    >
      Row-{index}
    </div>
  )
}

const ReactWindowVisible = () => {
  return (
    <VariableSizeList height={800} itemCount={1000} itemSize={getItemSize} width="100%">
      {renderRow}
    </VariableSizeList>
  )
}

export default ReactWindowVisible

如有转载或 CV 的请标注本站原文地址