虚拟列表无限滚动
源码
Demo
import { VirtualList } from './VirtualList'
const VirtualScroll = () => {
const list = new Array(100).fill('')
const str =
'Voluptatem quia minima rerum culpa culpa ratione vel natus dolor. Voluptatem aut quae incidunt esse ipsum voluptates ratione perferendis qui. Beatae at aspernatur odio suscipit quidem odit.'
return (
<VirtualList
list={list.map((it, index) => ({ id: index }))}
height={800}
estimatedRowHeight={50}
renderItem={(it, id) => {
const endIndex = Math.ceil(Math.random() * 100)
const strContent = str.substring(0, endIndex)
return (
<div
key={id}
style={{
lineHeight: '30px',
padding: '5px',
border: '1px solid
}}
id={`item_${id}`}
>{`item_${it.id}------------${strContent}`}</div>
)
}}
/>
)
}
export default VirtualScroll
/VirtualList/index.tsx
import { useEffect, useRef, useState, useLayoutEffect } from 'react'
import {
initCachedPositions,
CachedPosition,
binarySearch,
CompareResult,
} from './utils'
import './index.less'
type VirtualListProps = {
height: number
list: Record<string, any>[]
renderItem: (it: any, key: number) => any
estimatedRowHeight?: number
}
let originStartIdx = 0
let startIndex = 0
export const VirtualList = ({
height,
list = [],
renderItem,
estimatedRowHeight = 50,
}: VirtualListProps) => {
const total = list.length
const limit = Math.ceil(height / estimatedRowHeight)
let endIndex = Math.min(originStartIdx + limit, total - 1)
const scrollWrapperRef = useRef<HTMLDivElement>(null)
const phantomContentRef = useRef<HTMLDivElement>(null)
const actualContentRef = useRef<HTMLDivElement>(null)
const cachedPositions: CachedPosition[] = initCachedPositions(
list,
estimatedRowHeight,
)
let phantomHeight = cachedPositions[cachedPositions.length - 1].bottom
const [transformY, setTransformY] = useState<number>(0)
useEffect(() => {
const updateCachedPositions = () => {
const nodes: any = actualContentRef.current?.childNodes
const start = nodes[0]
nodes.forEach((node: HTMLDivElement) => {
if (!node) {
// scroll to fast?
return
}
const rect = node.getBoundingClientRect()
const { height } = rect
const index = Number(node.id.split('_')[1])
const oldHeight = cachedPositions[index].height
const dValue = oldHeight - height
if (dValue) {
cachedPositions[index].bottom -= dValue
cachedPositions[index].height = height
cachedPositions[index].dValue = dValue
}
})
let startIdx = 0
if (start) {
startIdx = Number(start.id.split('_')[1])
}
const cachedPositionLen = cachedPositions.length
let cumulativeDiffHeight = cachedPositions[startIdx].dValue
cachedPositions[startIdx].dValue = 0
for (let i = startIdx + 1
const item = cachedPositions[i]
cachedPositions[i].top = cachedPositions[i - 1].bottom
cachedPositions[i].bottom -= cumulativeDiffHeight
if (item.dValue !== 0) {
cumulativeDiffHeight += item.dValue
item.dValue = 0
}
}
// 更新phantom的height
const height = cachedPositions[cachedPositionLen - 1].bottom
phantomHeight = height
if (phantomContentRef.current) {
phantomContentRef.current.style.height = `${height}px`
}
console.log(cachedPositions, 'cachedPositions')
}
if (actualContentRef.current && list.length > 0) {
updateCachedPositions()
}
}, [startIndex])
const getStartIndex = (scrollTop = 0) => {
let idx = binarySearch<CachedPosition, number>(
cachedPositions,
scrollTop,
(currentValue: CachedPosition, targetValue: number) => {
const currentCompareValue = currentValue.bottom
if (currentCompareValue === targetValue) {
return CompareResult.eq
}
if (currentCompareValue < targetValue) {
return CompareResult.lt
}
return CompareResult.gt
},
)
const targetItem = cachedPositions[idx]
if (targetItem.bottom < scrollTop) {
idx += 1
}
return idx
}
const updateVisibleData = () => {
startIndex = Math.max(originStartIdx, 0)
endIndex = Math.min(originStartIdx + limit, total - 1)
const transformY =
startIndex >= 1 ? cachedPositions[startIndex - 1].bottom : 0
setTransformY(transformY)
}
const onScroll = () => {
const scrollTop = scrollWrapperRef.current?.scrollTop
const currentStartIndex = getStartIndex(scrollTop)
if (currentStartIndex !== originStartIdx) {
// we need to update visualized data
originStartIdx = currentStartIndex
updateVisibleData()
}
}
useEffect(() => {
updateVisibleData()
}, [])
useLayoutEffect(() => {
const $el = scrollWrapperRef.current
$el?.addEventListener('scroll', onScroll)
return () => {
$el?.removeEventListener('scroll', onScroll)
}
}, [])
const renderList = () => {
const content: HTMLDivElement[] = []
for (let i = startIndex
content.push(renderItem(list[i], i))
}
return content
}
return (
<div className="list-view" style={{ height }} ref={scrollWrapperRef}>
<div
className="list-view-phantom"
style={{ height: phantomHeight }}
ref={phantomContentRef}
/>
<div
className="list-view-actual"
style={{ transform: `translate3d(0,${transformY}px,0)` }}
ref={actualContentRef}
>
{renderList()}
</div>
</div>
)
}
/VirtualList/utils.ts
export interface CachedPosition {
index: number
top: number
bottom: number
height: number
dValue: number
}
export const initCachedPositions = (list, estimatedRowHeight) => {
const cachedPositions: CachedPosition[] = []
for (let i = 0
cachedPositions[i] = {
index: i,
height: estimatedRowHeight, // 先使用estimateHeight估计
top: i * estimatedRowHeight, // 同上
bottom: (i + 1) * estimatedRowHeight, // same above
dValue: 0,
}
}
return cachedPositions
}
export enum CompareResult {
eq = 1,
lt,
gt,
}
export const binarySearch = <T, VT>(
list: T[],
value: VT,
compareFunc: (current: T, value: VT) => CompareResult,
) => {
let start = 0
let end = list.length - 1
let tempIndex = 0
while (start <= end) {
tempIndex = Math.floor((start + end) / 2)
const midValue = list[tempIndex]
const compareRes: CompareResult = compareFunc(midValue, value)
if (compareRes === CompareResult.eq) {
return tempIndex
}
if (compareRes === CompareResult.lt) {
start = tempIndex + 1
} else if (compareRes === CompareResult.gt) {
end = tempIndex - 1
}
}
return tempIndex
}
/VirtualList/index.less
.list-view {
position: relative;
overflow: auto;
border: 1px solid #aaa;
}
.list-view-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.list-view-actual {
left: 0;
right: 0;
top: 0;
position: absolute;
}
注: 本文只是根据下面文章改写成hooks,非原创。