react-virtual 原始碼閱讀

Grewer發表於2022-02-09
前言: 這次本來想解析 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...

引用

相關文章