Foreword: This time I wanted to analyze the source code of react-virtualized, but its content is too much and too complicated. Let's start with a small library, from point to point
So this time I changed to the source code of react-virtual and react-window, this is react-virtual

What is a virtual list

A virtual list means that when we have thousands of pieces of data to be displayed but the user's "window" (one-time visible content) is not large, we can use a clever method to render only the maximum number of visible pieces of the user + "BufferSize" elements and dynamically update the content in each element as the user scrolls to achieve the same effect as long list scrolling but with very few resources.

use

Simplest usage example:

import {useVirtual} from "./react-virtual";

function App(props) {
    const parentRef = React.useRef()

    const rowVirtualizer = useVirtual({
        size: 10000,
        parentRef,
        estimateSize: React.useCallback(() => 35, []),
    })

    return (
        <>
            {/*这里就是用户的视窗*/}
            <div
                ref={parentRef}
                className="List"
                style={{
                    height: `150px`,
                    width: `300px`,
                    overflow: 'auto',
                }}
            >
                <div
                    className="ListInner"
                    style={{
                        height: `${rowVirtualizer.totalSize}px`,
                        width: '100%',
                        position: 'relative',
                    }}
                >
                    {/*具体要渲染的节点*/}
                    {rowVirtualizer.virtualItems.map(virtualRow => (
                        <div
                            key={virtualRow.index}
                            className={virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'}
                            style={{
                                position: 'absolute',
                                top: 0,
                                left: 0,
                                width: '100%',
                                height: `${virtualRow.size}px`,
                                transform: `translateY(${virtualRow.start}px)`,
                            }}
                        >
                            Row {virtualRow.index}
                        </div>
                    ))}
                </div>
            </div>
        </>
    )
}

react-virtual library provides the most critical method: useVirtual Let's start with his source code here:

useVirtual

Let's first look at his usage of accepting parameters:

  • size: Integer

    • Number of lists to render (real number)
  • parentRef: React.useRef(DOMElement)

    • A ref, through which the viewport element is manipulated, and some properties of the viewport element are obtained
  • estimateSize: Function(index) => Integer

    • The size and length of each item, because it is a function, can return different sizes according to the index, of course, it can also return a constant
  • overscan: Integer

    • In addition to the default elements in the window, additional rendering is required to avoid scrolling too fast and rendering in time
  • horizontal: Boolean

    • Determines whether the list is horizontal or vertical
  • paddingStart: Integer

    • start padding height
  • paddingEnd: Integer

    • padding height at the end
  • keyExtractor: Function(index) => String | Integer

    • This function should be passed whenever dynamic measurement rendering is enabled and the size or order of items in the list changes.

A lot of hook-type parameters are omitted here, and many common parameters are introduced.

hooks returns the result:

  • virtualItems: Array<item>

    • item: Object
    • variable to iterate over, the number of renders in the viewport
  • totalSize: Integer

    • The size of the entire virtual container, which may vary
  • scrollToIndex: Function(index: Integer, { align: String }) => void 0

    • A calling method that can dynamically jump to an item
  • scrollToOffset: Function(offsetInPixels: Integer, { align: String }) => void 0

    • Same as above, but passing an offset

Internal source code

export function useVirtual({
                               size = 0,
                               estimateSize = defaultEstimateSize,
                               overscan = 1,
                               paddingStart = 0,
                               paddingEnd = 0,
                               parentRef,
                               horizontal,
                               scrollToFn,
                               useObserver,
                               initialRect,
                               onScrollElement,
                               scrollOffsetFn,
                               keyExtractor = defaultKeyExtractor,
                               measureSize = defaultMeasureSize,
                               rangeExtractor = defaultRangeExtractor,
                           }) {
    // 上面是传参, 这里就不多赘述


    // 判断是否是横向还是纵向
    const sizeKey = horizontal ? 'width' : 'height'
    const scrollKey = horizontal ? 'scrollLeft' : 'scrollTop'

    // ref 常用的作用, 用来存值
    const latestRef = React.useRef({
        scrollOffset: 0,
        measurements: [],
    })

    // 偏移量
    const [scrollOffset, setScrollOffset] = React.useState(0)
    latestRef.current.scrollOffset = scrollOffset // 记录最新的偏移量

    // useRect hooks 方法, 可通过传参覆盖
    // 作用是监听父元素的尺寸, 具体的 useRect 源码会放在下面
    const useMeasureParent = useObserver || useRect

    // useRect 的正式使用
    const {[sizeKey]: outerSize} = useMeasureParent(parentRef, initialRect)

    //  最新的父元素尺寸, 记录
    latestRef.current.outerSize = outerSize

    // 默认的滚动方法
    const defaultScrollToFn = React.useCallback(
        offset => {
            if (parentRef.current) {
                parentRef.current[scrollKey] = offset
            }
        },
        [parentRef, scrollKey]
    )

    // 被传值覆盖的一个操作
    const resolvedScrollToFn = scrollToFn || defaultScrollToFn

    // 添加 useCallback 包裹, 避免 memo 问题, 真实调用的函数
    scrollToFn = React.useCallback(
        offset => {
            resolvedScrollToFn(offset, defaultScrollToFn)
        },
        [defaultScrollToFn, resolvedScrollToFn]
    )

    // 缓存
    const [measuredCache, setMeasuredCache] = React.useState({})

    // 测量的方法, 置为空对象
    const measure = React.useCallback(() => setMeasuredCache({}), [])

    const pendingMeasuredCacheIndexesRef = React.useRef([])

    // 计算测量值
    const measurements = React.useMemo(() => {
        // 循环的最小值, 判断 pendingMeasuredCacheIndexesRef 是否有值, 有则使用其中最小值, 不然就是 0
        const min =
            pendingMeasuredCacheIndexesRef.current.length > 0
                ? Math.min(...pendingMeasuredCacheIndexesRef.current)
                : 0
        // 取完一次值之后置空
        pendingMeasuredCacheIndexesRef.current = []

        // 取 latestRef 中的最新测量值, 第一次渲染应该是 0, slice 避免对象引用
        const measurements = latestRef.current.measurements.slice(0, min)

        // 循环 measuredSize从缓存中取值, 计算每一个 item 的开始值, 和加上尺寸之后的结束值
        for (let i = min; i < size; i++) {
            const key = keyExtractor(i)
            const measuredSize = measuredCache[key]
            // 开始值是前一个值的结束, 如果没有值, 取填充值(默认 0
            const start = measurements[i - 1] ? measurements[i - 1].end : paddingStart
            // item 的高度, 这里就是上面所说的动态高度
            const size =
                typeof measuredSize === 'number' ? measuredSize : estimateSize(i)
            const end = start + size
            // 最后加上缓存
            measurements[i] = {index: i, start, size, end, key}
        }
        return measurements
    }, [estimateSize, measuredCache, paddingStart, size, keyExtractor])

    // 总的列表长度
    const totalSize = (measurements[size - 1]?.end || paddingStart) + paddingEnd

    // 赋值给latestRef
    latestRef.current.measurements = measurements
    latestRef.current.totalSize = totalSize

    // 判断滚动元素, 可以从 props 获取, 默认是父元素的滚动
    const element = onScrollElement ? onScrollElement.current : parentRef.current

    // 滚动的偏移量获取函数, 有可能为空
    const scrollOffsetFnRef = React.useRef(scrollOffsetFn)
    scrollOffsetFnRef.current = scrollOffsetFn

    //  判断是否有 window, 有的话则用 useLayoutEffect, 否则使用 useEffect
    useIsomorphicLayoutEffect(() => {
        // 如果滚动元素没有, 或者说没有渲染出来, 则返回
        if (!element) {
            setScrollOffset(0)

            return
        }

        // 滚动的函数
        const onScroll = event => {
            // 滚动的距离, 如果有传参数, 则使用, 否则就是用 parentRef 的
            const offset = scrollOffsetFnRef.current
                ? scrollOffsetFnRef.current(event)
                : element[scrollKey]

            // 这里使用 setScrollOffset 会频繁触发 render, 可能会造成性能问题, 后面再查看另外的源码时, 考虑有什么好的方案
            setScrollOffset(offset)
        }

        onScroll()

        // 添加监听
        element.addEventListener('scroll', onScroll, {
            capture: false,
            passive: true,
        })

        return () => { // 解除监听
            element.removeEventListener('scroll', onScroll)
        }
    }, [element, scrollKey])

    // 具体源码解析在下方, 作用是通过计算得出范围 start, end 都是数字
    const {start, end} = calculateRange(latestRef.current)

    // 索引, 计算最低和最高的显示索引 最后得出数字数组, 如 [0,1,2,...,20] 类似这样
    const indexes = React.useMemo(
        () =>
            rangeExtractor({
                start,
                end,
                overscan,
                size: measurements.length,
            }),
        [start, end, overscan, measurements.length, rangeExtractor]
    )

    // 传值measureSize, 默认是通过元素获取 offset
    const measureSizeRef = React.useRef(measureSize)
    measureSizeRef.current = measureSize

    // 真实视图中显示的元素, 会返回出去
    const virtualItems = React.useMemo(() => {
        const virtualItems = []
        // 根据索引循环 indexex 类似于 [0,1,2,3,...,20]
        for (let k = 0, len = indexes.length; k < len; k++) {
            const i = indexes[k]

            // 这里是索引对应的数据集合, 有开始尺寸,结束尺寸, 宽度, key等等
            const measurement = measurements[i]

            // item 的数据
            const item = {
                ...measurement,
                measureRef: el => {
                    // 额外有一个 ref, 可以不使用
                    // 一般是用来测量动态渲染的元素
                    if (el) {
                        const measuredSize = measureSizeRef.current(el, horizontal)

                        // 真实尺寸和记录的尺寸不同的时候, 更新
                        if (measuredSize !== item.size) {
                            const {scrollOffset} = latestRef.current

                            if (item.start < scrollOffset) {
                                // 滚动
                                defaultScrollToFn(scrollOffset + (measuredSize - item.size))
                            }

                            pendingMeasuredCacheIndexesRef.current.push(i)

                            setMeasuredCache(old => ({
                                ...old,
                                [item.key]: measuredSize,
                            }))
                        }
                    }
                },
            }

            virtualItems.push(item)
        }

        return virtualItems
    }, [indexes, defaultScrollToFn, horizontal, measurements])

    // 标记是否 mounted,  就是平常使用的 useMount
    const mountedRef = React.useRef(false)

    useIsomorphicLayoutEffect(() => {
        if (mountedRef.current) {
            // mounted 时 重置缓存
            setMeasuredCache({})
        }
        mountedRef.current = true
    }, [estimateSize])

    // 滚动函数
    const scrollToOffset = React.useCallback(
        (toOffset, {align = 'start'} = {}) => {
            // 获取最新的滚动距离, 尺寸
            const {scrollOffset, outerSize} = latestRef.current

            if (align === 'auto') {
                if (toOffset <= scrollOffset) {
                    align = 'start'
                } else if (toOffset >= scrollOffset + outerSize) {
                    align = 'end'
                } else {
                    align = 'start'
                }
            }


            // 调用 scrollToFn, 真实滚动的方法
            if (align === 'start') {
                scrollToFn(toOffset)
            } else if (align === 'end') {
                scrollToFn(toOffset - outerSize)
            } else if (align === 'center') {
                scrollToFn(toOffset - outerSize / 2)
            }
        },
        [scrollToFn]
    )

    // 滚动到某一个 item 上
    const tryScrollToIndex = React.useCallback(
        (index, {align = 'auto', ...rest} = {}) => {
            const {measurements, scrollOffset, outerSize} = latestRef.current

            //通过 index, 获取他的缓存数据
            const measurement = measurements[Math.max(0, Math.min(index, size - 1))]

            if (!measurement) {
                return
            }

            if (align === 'auto') {
                if (measurement.end >= scrollOffset + outerSize) {
                    align = 'end'
                } else if (measurement.start <= scrollOffset) {
                    align = 'start'
                } else {
                    return
                }
            }

            // 计算要滚动的距离
            const toOffset =
                align === 'center'
                    ? measurement.start + measurement.size / 2
                    : align === 'end'
                        ? measurement.end
                        : measurement.start
            // 调用滚动函数
            scrollToOffset(toOffset, {align, ...rest})
        },
        [scrollToOffset, size]
    )

    // 外部包裹函数, 为什么不直接使用 tryScrollToIndex
    // 因为动态尺寸会导致偏移并最终出现在错误的地方。
    // 在我们尝试渲染它们之前,我们无法知道这些动态尺寸的情况。
    // 这里也是一个可能会出现bug的地方
    const scrollToIndex = React.useCallback(
        (...args) => {
            tryScrollToIndex(...args)
            requestAnimationFrame(() => {
                tryScrollToIndex(...args)
            })
        },
        [tryScrollToIndex]
    )

    // 最后抛出的函数,变量
    return {
        virtualItems,
        totalSize,
        scrollToOffset,
        scrollToIndex,
        measure,
    }
}

calculateRange

function calculateRange({measurements, outerSize, scrollOffset}) {
    const size = measurements.length - 1
    const getOffset = index => measurements[index].start

    // 通过二分法找到 scrollOffset 对应的值
    let start = findNearestBinarySearch(0, size, getOffset, scrollOffset)
    let end = start

    // 类似于 通过比例计算出最后的 end 数值
    while (end < size && measurements[end].end < scrollOffset + outerSize) {
        end++
    }

    return {start, end}
}

Summarize

The basis of the virtual list is to rely on the positioning of css and the calculation of JS. The wonderful combination of the two appears.
react-virtual library gives the calculation of JS, and the positioning and layout of CSS, in addition to the solution in the warehouse, there are other places that can be said,
I will explain them one by one in a later blog

Use repository:
https://github.com/Grewer/react-virtualized-notes

quote


Grewer
984 声望28 粉丝

Developer