This page looks best with JavaScript enabled

理解虚拟列表的实现之React Virtuoso

 ·  ☕ 8 min read

https://virtuoso.dev/

  • 支持使用不同的 UI 组件实现内部列表:React、MUI
  • 支持:自动处理可变高度的 item、顶部加载更多、底部加载更多
  • 支持:从特定位置开始渲染

结构

结构

Scroller

列表的外层容器,高度为可视区域高度

  • position: relative
  • overflow-y: overlay

scroller

Viewport

列表的内层容器,高度为可视区域高度

  • position: absolute
  • top: 0px

viewport

Items

内容实现渲染的区域,高度为所有内容需要占用的高度(paddingTop + paddingBottom + height)

Items

实现

urx 状态管理

https://virtuoso.dev/blog 作者在实现这个库时,抽象出的一个状态管理框架 – urx

  • 最开始基于 redux:reducer 被反复调用,相互依赖,不好维护和测试
  • 然后尝试 hook:并没有变的更好
  • 接着采用 RxJS 重构:优化了渲染性能和测试,但是整个 rxjs 库太大了
  • 于是作者自己封装了一个状态管理库: urx — 可以看作是一个精简版的 rxjs

urx 中的一些关键方法和概念

  • stream 数据流:一个 stream 既能输出也能输入,每个 stream 可以有多个订阅者。流分为无状态和有状态两种:
    • Stateless streams: 将输入的值发射到订阅者,不持有发布的数据
    • Stateful streams: 将输入的值发射到订阅者,同时会持有最后输入的值
  • system: 在 system 中做数据流的创建、组合等。一个 system 可以将其他 system 指定为依赖项,并且可以在依赖树中以单例的形式存在
  • pipe: stream 中的值可以利用 pipe 管道结合一些操作符做转换和控制,比如 map、filter
  • actions: urx Action 操作符作用于 stream 上
    • publish: 在 stream 发布值
    • subscribe: 订阅一个 stream
  • react-urx: 提供了 systemToComponent 方法,将 urx systems 封装到 react UI 组件中,将 system 的输入、输出流转换到组件的输入、输出上:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 生成 react 组件,并生成 system 依赖树,存放到组件的 state 中
const Component = React.forwardRef<CompMethods, CompProps>((propsWithChildren, ref) => {
    const { children, ...props } = propsWithChildren as any

    const [system] = useState(() => {
      return u.tap(u.init(systemSpec), (system) => applyPropsToSystem(system, props))
    })

    return React.createElement(
      Context.Provider,
      { value: system },
      React.createElement(
        React.ComponentType,
          omit([...requiredPropNames, ...optionalPropNames, ...eventNames], props),
          children
        )
    )
})
  • 还提供了几个 hook:

    • useEmitterValue: 监听 stateful stream 的输出,并在 stream 有新的值时触发组件重新渲染
    • useEmitter: 监听 stream 的输出,在 stream 有新的值时回调 callback,但不会触发重新渲染
    • usePublisher: 返回一个函数,通过这个函数可以向 stream 中传递值
  • u.system 方法用于定义 systemSpec,u.system 调用返回 SystemSpec,SystemSpec 用于存储 system 的相关信息,每个 systemSpec 有 4 个属性

    • id 唯一标识
    • constructor 构造函数,构造函数的参数列表为它依赖的其他 systemSpec 对应的实例
    • dependencies 它依赖的其他 systemSpec
    • singleton 是否在依赖链中作为单例存在,默认为 true
1
2
3
4
5
6
interface SystemSpec<SS extends SystemSpecs, C extends SystemConstructor<SS>> {
  id: string
  constructor: C
  dependencies: SS
  singleton: boolean
}
  • u.init 方法通过递归初始化一个 system 的依赖项来初始化 system
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export function init<SS extends AnySystemSpec>(systemSpec: SS): SR<SS> {
  const singletons = new Map<string, System>()
  const _init = <SS extends AnySystemSpec>({ id, constructor, dependencies, singleton }: SS) => {
    if (singleton && singletons.has(id)) {
      return singletons.get(id)! as SR<SS>
    }
    const system = constructor(dependencies.map((e: AnySystemSpec) => _init(e)))
    if (singleton) {
      singletons.set(id, system)
    }
    return system
  }
  return _init(systemSpec)
}
  • u.connect 方法:连接两个流,发送到流 a 的值,会同时发送到流 b

渲染

列表的渲染从 systemToComponent 调用开始

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const {
  Component: List,
} = systemToComponent(
  combinedSystem,
  {
    optional: {
      itemContent: 'itemContent',
      totalCount: 'totalCount',
      data: 'data',
      //...
    },
    methods: {
      scrollToIndex: 'scrollToIndex',
      autoscrollToBottom: 'autoscrollToBottom',
      //...
    },
    events: {
      isScrolling: 'isScrolling',
      endReached: 'endReached',
      //...
    },
  },
  ListRoot
)

export const Virtuoso = List as <ItemData = any, Context = any>(
  props: VirtuosoProps<ItemData, Context> & { ref?: React.Ref<VirtuosoHandle> }
) => React.ReactElement

combinedSystem

combinedSystem 依赖 listSystem 和 listComponentPropsSystem 中的所有 system

1
2
3
const combinedSystem = u.system(([listSystem, propsSystem]) => {
  return { ...listSystem, ...propsSystem }
}, u.tup(listSystem, listComponentPropsSystem

combined system

其他参数

  • optional 注入到组件的参数,会作为 react 组件的 props
  • menthods 暴露的方法,当调用某个方法时,会向方法名对应的 stream 传递参数值
  • events 暴露的事件监听,订阅 event 名字对应的 stream

ListRoot 组件

在 ListRoot 中组装所有组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const ListRoot: React.FC<ListRootProps> = React.memo(function VirtuosoRoot(props) {
  const TheScroller = Scroller
  const TheViewport = Viewport
  return (
    <TheScroller {...props}>
      <TheViewport>
        <Items />
      </TheViewport>
    </TheScroller>
  )
})
  • Scroller 组件:负责构建外层的滚动容器

    调用 useScrollTop 方法监听 div 的滚动事件,在滚动时将 scrollTop、scrollHeight、viewportHeight 写到 domIOSystem 的 scrollContainerState 流中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const Scroller = buildScroller({ usePublisher, useEmitterValue, useEmitter })

const scrollerStyle: React.CSSProperties = {
  height: '100%',
  outline: 'none',
  overflowY: 'auto',
  position: 'relative'
}

function buildScroller({ usePublisher, useEmitter, useEmitterValue }: Hooks) {
  const Scroller: React.ComponentType<ScrollerProps> = React.memo(function VirtuosoScroller({ style, children, ...props }) {
    // ScrollerComponent 定义在 listComponentPropsSystem, 初始值是 div
    const ScrollerComponent = useEmitterValue('ScrollerComponent')!
    const scrollContainerStateCallback = usePublisher('scrollContainerState')
    const ScrollerComponent = useEmitterValue('ScrollerComponent')!
    const smoothScrollTargetReached = usePublisher('smoothScrollTargetReached')
    const scrollerRefCallback = useEmitterValue('scrollerRef')
    
    const { scrollerRef, scrollByCallback, scrollToCallback } = useScrollTop(
      scrollContainerStateCallback,
      smoothScrollTargetReached,
      ScrollerComponent,
      scrollerRefCallback
    )
    
    return React.createElement(
      'div',
      {
        ref: scrollerRef as React.MutableRefObject<HTMLDivElement | null>,
        style: { ...scrollerStyle, ...style },
        ...props
      },
      children
    )
  })
  return Scroller
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const handler = useCallback(
  (ev: Event) => {
  const el = ev.target as HTMLElement
  const scrollTop = el.scrollTop
  const scrollHeight = el.scrollHeight
  const viewportHeight = el.offsetHeight
  
  const call = () => {
    scrollContainerStateCallback({
      scrollTop: Math.max(scrollTop, 0),
      scrollHeight,
      viewportHeight,
    })
  }
  call()
})

useEffect(() => {
    const localRef = scrollerRef.current!
    // 监听滚动事件
    localRef.addEventListener('scroll', handler, { passive: true })

    return () => {
      localRef.removeEventListener('scroll', handler)
    }
  }, [scrollerRef, handler])
  • Viewport 组件:负责监听可视区域的 size 变动

    size 变化后将新的高度值发射到 domIOSystem 的 viewportHeight 流

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const viewportStyle: React.CSSProperties = {
  width: '100%',
  height: '100%',
  position: 'absolute',
  top: 0,
}

const Viewport: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
  const viewportHeight = usePublisher('viewportHeight')
  // 当 div size 变化时,会回调 useSize 传递的函数,然后将新的高度值发射到 viewportHeight 流
  const viewportRef = useSize((el) => {
    const h = correctItemSize(el, 'height')
    console.log('Viewport publish height to viewportHeight', h)
    viewportHeight(h)
  })

  return (
    <div style={viewportStyle} ref={viewportRef}>
      {children}
    </div>
  )
}

// 通过 getBoundingClientRect 获取节点 size
export function correctItemSize(el: HTMLElement, dimension: 'height' | 'width') {
  return Math.round(el.getBoundingClientRect()[dimension])
}
  • Items 组件:列表容器,监听 listState 流
    • 根据 listState 的 offsetTop 和 offsetBottom 改变列表容器的 paddingTop 和 paddingBottom
    • 根据 listState.items 决定应该渲染哪些 items,所以核心就是 listState 的维护和更新
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const Items = React.memo(function VirtuosoItems() {
  const listState = useEmitterValue('listState') // 监听 listState
  const itemContent = useEmitterValue('itemContent')
  const computeItemKey = useEmitterValue('computeItemKey')

  const containerStyle: CSSProperties = {
    boxSizing: 'border-box',
    paddingTop: listState.offsetTop,
    paddingBottom: listState.offsetBottom,
    marginTop: deviation
  }

  return React.createElement(
    'div',
    {
      ref: callbackRef,
      style: containerStyle
    },
    listState.items.map((item) => {
      const index = item.originalIndex!
      const key = computeItemKey(index + listState.firstItemIndex, item.data)
      return React.createElement(
        'div',
        {
          key,
          item: item.data,
          style: ITEM_STYLE,
        },
        itemContent(item.index, item.data, context)
      )
    })
  )
})

ListState

listState 在 listStateSystem 中维护,listState 是一个订阅了多个 stream 的 stream,当订阅的 stream 有新的值时就会触发生成 listState

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const listState = u.statefulStreamFromEmitter(
  u.pipe(
    u.combineLatest( // 只有参数中的所有 stream 都发射过值,才会将继续下游
      didMount,
      recalcInProgress,
      u.duc(visibleRange, tupleComparator),
      u.duc(totalCount),
      u.duc(sizes),
      u.duc(initialTopMostItemIndex),
      scrolledToInitialItem,
      u.duc(topItemsIndexes),
      u.duc(firstItemIndex),
      u.duc(gap),
      data
    ),
    u.map(
      ([
        ,
        ,
        [startOffset, endOffset],
        totalCount,
        sizes,
        initialTopMostItemIndex,
        scrolledToInitialItem,
        topItemsIndexes,
        firstItemIndex,
        gap,
        data,
      ]) => {
          // 在这里返回 listState,订阅的地方就会收到新的值: Items 组件就会重新渲染
      }),
      u.distinctUntilChanged()
     ),
     EMPTY_LIST_STATE // 第一次发射空的 state
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
interface ListState {
  items: ListItems      // 当前应该渲染的内容
  offsetTop: number     // 实际渲染内容区域的顶部与整个滚动容器顶部的距离
  offsetBottom: number  // 实际渲染内容区域的底部与整个滚动容器底部的距离
  top: number           // 实际渲染内容区域的顶部
  bottom: number        // 实际渲染内容区域的底部
  totalCount: number    // 数据量
}

type ListItems = ListItem<unknown>[]

interface Item<D> {
  index: number
  offset: number // 相对渲染区域顶部的偏移距离
  size: number   // 高度
  data?: D       // 数据
}

渲染流程

  • 开始渲染后 totalCount、visibleRange 还未设置, listStateSystem 返回空的 state
1
2
3
4
if (totalCount === 0 || (startOffset === 0 && endOffset === 0)) {
  console.log('listStateSystem return empty state')
  return { ...EMPTY_LIST_STATE, totalCount }
}
  • listState 发生改变,触发 Items 组件渲染,渲染后发送 scrollContainerState 到 domIOSystem
1
2
3
4
5
interface ScrollContainerState {
  scrollHeight: number
  scrollTop: number
  viewportHeight: number
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 从 Items 组件向上找,直到找到 Scroller
let scrollableElement = el.parentElement
while (!scrollableElement.dataset['virtuosoScroller']) {
  scrollableElement = scrollableElement.parentElement!
}

const scrollTop = scrollableElement.scrollTop
const scrollHeight = scrollableElement.scrollHeight
const viewportHeight = scrollableElement.offsetHeight

const scrollContainerStateCallback = usePublisher('scrollContainerState')
// 将值传递给 domIOSystem
scrollContainerStateCallback({
  scrollTop: Math.max(scrollTop, 0),
  scrollHeight,
  viewportHeight,
})
  • domIOSystem 收到 scrollContainerState,将 scrollTop 分发到 sizeRangeSystem 的 scrollTop stream
1
2
3
4
5
6
7
8
9
 u.connect(
  u.pipe(
    scrollContainerState,
    u.map(({ scrollTop }) => {
      return scrollTop
    })
  ),
  scrollTop
)
  • Viewport 组件在检测到高度变化后,发送 viewportHeight 到 domIOSystem 的 viewportHeight stream,同时由于 sizeRangeSystem 订阅了 domIOSystem 的 viewportHeight,所以 sizeRangeSystem 也会收到 viewportHeight
1
2
3
4
5
const viewportRef = useSize('Vireport', (el) => {
  const h = correctItemSize(el, 'height')
  console.log('Viewport publish height to viewportHeight', h)
  viewportHeight(h)
})
  • sizeRangeSystem 收到 scrollTop 和 viewportHeight 后,计算 visibleRange
1
2
3
4
const top = scrollTop
const startOffset = top
const endOffset = top + viewportHeight
return [startOffset, endOffset]
  • listStateSystem 收到 visibleRange,由于目前的 sizeTree 还是空的,所以返回初始 state,默认渲染第一个数据
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
if (empty(sizeTree)) {
  const state = buildListState(
    probeItemSet(getInitialTopMostItemIndexNumber(initialTopMostItemIndex, totalCount), sizesValue, data),
    [],
    totalCount,
    gap,
    sizesValue,
    firstItemIndex
  )
  console.log('listStateSystem return when empty sizeTree', { state })
  return state
}
{
    "state": {
        "items": [
            {
                "index": 0,
                "size": 0,
                "offset": 0,
                "data": "My Item 0",
                "originalIndex": 0
            }
        ],
        "offsetTop": 0,
        "offsetBottom": 0,
        "top": 0,
        "bottom": 0,
        "totalCount": 100,
        "firstItemIndex": 0
    }
}
  • Items 组件渲染 items,导致 Items 组件的高度发生变化,触发 resize callback,从而获取 Items 所有 children 节点的高度存为 sizeRange,并发送到 sizeSystem 的 sizeRange stream
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function getChangedChildSizes(children: HTMLCollection, itemSize: SizeFunction) {
    const results: SizeRange[] = []
    for (let i = 0; i < length; i++) {
        const child = children.item(i) as HTMLElement
        const index = parseInt(child.dataset.index)
        const knownSize = parseFloat(child.dataset.knownSize!)
        const size = itemSize(child, field) // 调用 element.getBoundingClientRect
        results.push({ startIndex: index, endIndex: index, size })
    }
}

const sizeRanges = usePublisher('sizeRanges') // 获取 sizeSystem 的 sizeRange 流
const ranges = getChangedChildSizes(el.children, itemSize)
sizeRanges(ranges) // 发送到 sizeSystem
  • sizeSystem 收到 sizeRange,计算 sizeState
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
interface SizeState {
  sizeTree: AANode<number>       // 记录每个item的高度, AA树
  offsetTree: Array<OffsetPoint> // 记录每个item的相对于顶部的offset
  lastIndex: number   // 当前最后渲染的item的index
  lastOffset: number  // 当前最后渲染的item的offset
  lastSize: number    // 当前最后渲染的item的高度
}
const sizes = u.statefulStreamFromEmitter(
  u.pipe(
    sizeRanges,
    u.withLatestFrom(groupIndices, log, gap),
    u.scan(sizeStateReducer, initial), // sizeStateReducer 计算 sizeState
    u.distinctUntilChanged(),
    u.map((value) => {
      return value
    })
  ),
  initial
)

sizeStateReducer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function sizeStateReducer(state: SizeState, [ranges]: [SizeRange[]]) {
  const sizeTree = state.sizeTree
  let newSizeTree: AANode<number> = sizeTree
  let syncStart = 0
  // 将 sizeRanges 插入到 sizeTree
  const res = insertRanges(newSizeTree, ranges)
  newSizeTree = res[0]
  syncStart = res[1]

  if (newSizeTree === sizeTree) {
    console.log('sizSyste newSizeTree == sizeTree return', { ...state })
    return state
  }

  const {
    offsetTree: newOffsetTree, 
    lastIndex, 
    lastSize, 
    lastOffset 
  } = createOffsetTree(state.offsetTree, syncStart, newSizeTree)

  const result = {
    sizeTree: newSizeTree,
    offsetTree: newOffsetTree,
    lastIndex,
    lastOffset,
    lastSize,
  }
  console.log('sizeSystem sizeStateReducer return', { ...result })
  return result
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
function createOffsetTree(prevOffsetTree: OffsetPoint[], syncStart: number, sizeTree: AANode<number>) {
  let offsetTree = prevOffsetTree
  let prevIndex = 0
  let prevSize = 0

  let prevOffset = 0
  let startIndex = 0

  if (syncStart !== 0) {
    startIndex = arrayBinarySearch.findIndexOfClosestSmallerOrEqual(offsetTree, syncStart - 1, indexComparator)
    const offsetInfo = offsetTree[startIndex]
    prevOffset = offsetInfo.offset
    const kv = findMaxKeyValue(sizeTree, syncStart - 1)
    prevIndex = kv[0]
    prevSize = kv[1]!

    if (offsetTree.length && offsetTree[startIndex].size === findMaxKeyValue(sizeTree, syncStart)[1]) {
      startIndex -= 1
    }

    offsetTree = offsetTree.slice(0, startIndex + 1)
  } else {
    offsetTree = []
  }
  
  // 从 sizeTree 中查找 index 小于/等于 syncStart 的所有 item 的 sizeRange
  const ranges = rangesWithin(sizeTree, syncStart, Infinity)
  // 根据所有 item 的 sizeRange, 按照 index 从小到大的计算 offset,然后添加到 offsetTree 中
  for (const { start: startIndex, value } of ranges) {
    const indexOffset = startIndex - prevIndex  //1
    const aOffset = indexOffset * prevSize + prevOffset //30
    offsetTree.push({
      offset: aOffset,
      size: value,
      index: startIndex,
    })
    prevIndex = startIndex
    prevOffset = aOffset
    prevSize = value
  }

  return {
    offsetTree,
    lastIndex: prevIndex,
    lastOffset: prevOffset,
    lastSize: prevSize,
  }
}
  • listStateSystem 收到 sizeState,计算 listState
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function buildListState(
  items: Item<any>[],
  totalCount: number,
  sizes: SizeState,
  firstItemIndex: number
): ListState {
  const { lastSize, lastOffset, lastIndex } = sizes
  let offsetTop = 0
  let bottom = 0

  if (items.length > 0) {
    // 当前渲染的内容距离容器顶部的 offset
    offsetTop = items[0].offset
    const lastItem = items[items.length - 1]
    bottom = lastItem.offset + lastItem.size
  }

  const itemCount = totalCount - lastIndex
  // 根据数据量预算一个总高度
  const total = lastOffset + itemCount * lastSize //3000
  const top = offsetTop
  // 当前渲染的内容距容器底部的 offset
  const offsetBottom = total - bottom //300 2700

  return {
    items: transposeItems(items, sizes, firstItemIndex),
    offsetTop,
    offsetBottom,
    top,
    bottom,
    totalCount,
    firstItemIndex,
  }
}

listState:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
    "items": [
        {
            "index": 0,
            "size": 30,
            "offset": 0,
            "data": "My Item 0",
            "originalIndex": 0
        },
        {
            "index": 1,
            "size": 30,
            "offset": 30,
            "data": "My Item 1",
            "originalIndex": 1
        },
        {
            "index": 2,
            "size": 30,
            "offset": 60,
            "data": "My Item 2",
            "originalIndex": 2
        },
        {
            "index": 3,
            "size": 30,
            "offset": 90,
            "data": "My Item 3",
            "originalIndex": 3
        },
        //...
    ],
    "offsetTop": 0,
    "offsetBottom": 2700,
    "top": 0,
    "bottom": 300,
    "totalCount": 100
}
  • Items 触发重新渲染,渲染新的 listState 中的 items,并修改 padding,就能在未渲染所有内容的情况下,预先确定整个滚动容器的高度 – scrollHeight
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const containerStyle: CSSProperties = {
  paddingTop: listState.offsetTop,
  paddingBottom: listState.offsetBottom
}

listState.items.map((item) => {
    const index = item.originalIndex
    const key = computeItemKey(index, item.data)
    return React.createElement(
      'div',
      {
        key,
        'data-index': index,
        'data-known-size': item.size,
        'data-item-index': item.index,
        style: ITEM_STYLE,
      },
      itemContent(item.index, item.data)
    )
})

欢迎体验新产品, 抖音聊天

Support the author with
alipay QR Code
wechat QR Code

Yang
WRITTEN BY
Yang
Developer