关于前端:reactvirtual-源码阅读

5次阅读

共计 8197 个字符,预计需要花费 21 分钟才能阅读完成。

前言: 这次原本想解析 react-virtualized 的源码, 然而他的内容太多, 太杂, 咱们先从小的库动手, 由点及面
所以这次改为了 react-virtual 和 react-window 的源码, 这篇就是 react-virtual

什么是虚构列表

一个虚构列表是指当咱们有成千上万条数据须要进行展现然而用户的“视窗”(一次性可见内容)又不大时咱们能够通过奇妙的办法只渲染用户最大可见条数 +“BufferSize”个元素并在用户进行滚动时动静更新每个元素中的内容从而达到一个和长 list 滚动一样的成果但破费非常少的资源。

应用

最简略的应用例子:

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 库提供了最为要害的办法: useVirtual 咱们就从这里来动手他的源码:

useVirtual

咱们先看他的用法之承受参数:

  • size: Integer

    • 要渲染列表的数量(实在数量)
  • parentRef: React.useRef(DOMElement)

    • 一个 ref, 通过这个来操控视窗元素, 获取视窗元素的一些属性
  • estimateSize: Function(index) => Integer

    • 每一项的尺寸长度, 因为是函数, 能够依据 index 来返回不同的尺寸, 当然也能够返回常数
  • overscan: Integer

    • 除了视窗外面默认的元素, 还须要额定渲染的, 防止滚动过快, 渲染不及时
  • horizontal: Boolean

    • 决定列表是横向的还是纵向的
  • paddingStart: Integer

    • 结尾的填充高度
  • paddingEnd: Integer

    • 开端的填充高度
  • keyExtractor: Function(index) => String | Integer

    • 只有启用了动静测量渲染,并且列表中的我的项目的大小或程序发生变化,就应该传递这个函数。

这里也省略了很多 hook 类型的传参, 介绍了很多罕用参数

hooks 返回后果:

  • virtualItems: Array<item>

    • item: Object
    • 用来遍历的变量, 视窗中渲染的数量
  • totalSize: Integer

    • 整个虚构容器的大小, 可能会变动
  • scrollToIndex: Function(index: Integer, { align: String}) => void 0

    • 一个调用办法, 能够动静跳转到某一个 item 上
  • scrollToOffset: Function(offsetInPixels: Integer, { align: String}) => void 0

    • 同上, 不过传递的是偏移量

外部源码

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}
}

总结

虚构列表的根底就是依附着 css 的定位, 和 JS 的计算, 他两的绝妙搭配呈现的
react-virtual 库给出了 JS 的计算, 而 CSS 的定位和布局除了当初仓库中的计划, 其实还有其余的一些能够说道的中央,
我将会在前面的博客中一一论述

应用仓库:
https://github.com/Grewer/rea…

援用

  • https://react-virtual.tanstac…
  • https://zhuanlan.zhihu.com/p/…
正文完
 0