一、实现原理
1、原理很简单,核心原理就是显示一小部分数据,比如原数据有10000条,滚动的过程中只截取其中10条进行展示。
2、再细一点说,就是获取到滚动条的滚动距离和行高,滚动距离 / 行高 = 滚动行数,比如滚动行数 = 10,当前展示的就是 list.slice(10, 20)的数据。
3、模拟滚动:html结构如下,在首次渲染时计算出 li 的平均高度rowHeight,滚动元素 ul 的高度 = list.length * rowHeight;这样设置了高度,滚动条的位置就是真实的,在滚动监听onScroll中设置元素ul的paddingTop = div 的 scrollTop,就模拟了滚动。
<div onScroll={onScroll} className={styles.virtualized}>
<ul style={ulStyleStr}>
<li key={item}>{item}</li>
</ul>
</div>
style:
.virtualized {
position: relative;
overflow-y: auto;
border: 1px solid #ddd;
height: 200px;
}
二、代码
index.jsx
/**
* VirtualList
* 虚拟滚动列表--lvxh
*
* list: 需要展示的数据
* style: 最外层样式
* rowHeight:初始的行高,默认值50
* hasMoreCol:一行是否有多列
* colWidth:初始列宽,默认值100
* ulRender:自定义ul列表,函数类型 (showList, ulStyleStr) => volid,其中 showList 是最终展示的部分数据,ulStyleStr 是ul的style
* liRender:自定义li,函数类型 (item) => volid,item是展示的数据项
*
* 一般而言建议自定义li,
* 如果没有自定义ul和li,则默认展示<li key={item]}>{item}</li>,所以item需要是能正确展示的数据类型
*/
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'
import styles from './index.less'
const LIST = new Array(100000).fill().map((item, i) => (`test-data-${i + 1}`))
const getPropertyValue = (node, styleName) => {
return window.getComputedStyle(node)?.getPropertyValue(styleName)
}
export default ({ list = LIST, style, rowHeight = 50, colWidth = 100, hasMoreCol, ulRender, liRender }) => {
const boxRef = useRef(null)
const [num, setNum] = useState(10)
const [colNum, setColNum] = useState(1)
const [showList, setShowList] = useState([])
const [paddingTop, setPaddingTop] = useState(0)
const [_rowHeight, setRowHeight] = useState(rowHeight)
const [_colWidth, setColWidth] = useState(colWidth)
const [beyondDistance, setBeyondDistance] = useState(0) // 滚动到底部时,最后一个元素超出可视区域的距离
// 设置可视区域数据
const setData = useCallback((startIndex, endIndex) => {
setShowList(list.slice(startIndex, endIndex)) // 取出要渲染的数据
}, [list])
// 计算num、colNum
const calculateNum = useCallback((colW, rowH, clientHeight, clientWidth) => {
const _rowNum = Math.floor((clientHeight / rowH) + 2)
let _num = _rowNum
if (hasMoreCol) {
const _colNum = Math.floor(clientWidth / colW)
setColNum(_colNum)
_num = _colNum * _rowNum
}
setData(0, _num)
setNum(_num)
}, [hasMoreCol, setData])
// 初始化渲染完成后立马计算出真实的行高和列宽,取初始数据的中位数,列宽取最大值
const calculateSize = useCallback((children, ...ret) => {
const len = children.length
if (len > 0) {
const widthArr = []
const heightArr = []
children.forEach((child) => {
const { width, height } = child.getBoundingClientRect()
const marginTop = getPropertyValue(child, 'margin-top').slice(0, -2)
const marginLeft = getPropertyValue(child, 'margin-left').slice(0, -2)
heightArr.push(Math.floor(height + marginTop * 2))
widthArr.push(Math.floor(width + marginLeft * 2))
})
const h = heightArr.reduce((total, cur) => total + cur) / len
const w = widthArr.reduce((total, cur) => total + cur) / len
setRowHeight(h)
setColWidth(w)
calculateNum(w, h, ...ret)
}
}, [calculateNum])
// 初始化,根据初始的_colWidth、_rowHeight计算出首批渲染数据,再根据首次渲染的列表元素计算出真实的_colWidth和_rowHeight
useEffect(() => {
let timer = null
let count = 0
if (boxRef.current) {
if (list.length === 0) {
setShowList([])
return
}
const { clientHeight, clientWidth } = boxRef.current
calculateNum(_colWidth, _rowHeight, clientHeight, clientWidth)
timer = setInterval(() => {
count++
if (count > 5 || boxRef.current?.offsetParent === null) { // 父元素隐藏了立马停止计算
clearInterval(timer)
return
}
calculateSize(Array.from(boxRef.current?.children[0]?.children || []), clientHeight, clientWidth)
}, 200)
}
() => clearInterval(timer)
}, [list])
// 计算滚动到底部时,最后一个元素超出可视区域的距离,弥补行列计算差异导致的展示不全
const beyondDistanceHandle = useCallback((children, scrollHeight) => {
const len = children.length
if (len > 0) {
const lastChild = children[len - 1]
const { height } = lastChild.getBoundingClientRect()
const marginBottom = Number(getPropertyValue(lastChild, 'margin-bottom').slice(0, -2))
const top = lastChild.offsetTop + height + marginBottom
const _beyondDistance = top - scrollHeight
if (_beyondDistance > 0) {
setBeyondDistance(_beyondDistance)
}
}
}, [])
// 滚动监听,根据滚动距离重新计算展示的数据
const onScroll = useCallback((e) => {
const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = e?.target || {}
const rowNum = Math.floor(scrollTop / _rowHeight) // 滚动的行数 = 滚动距离 / 每一行的高度
let startIndex = rowNum
if (hasMoreCol) startIndex *= colNum // 如果有多列,数据的开始索引 = 滚动行数 * 列数
const endIndex = num + startIndex // 结束索引 = 可视区域容纳数 + 新的开始索引
setPaddingTop(`${e.target.scrollTop}px`) // 设置列表paddingTop
setData(startIndex, endIndex) // 更新渲染数据
if (scrollHeight - scrollTop === clientHeight && !beyondDistance) { // 滚动到底部
beyondDistanceHandle(Array.from(boxRef.current?.children[0]?.children || []), scrollHeight)
}
}, [colNum, num, hasMoreCol, setData, _rowHeight, beyondDistanceHandle, beyondDistance])
// 设置height和paddingTop,模拟滚动
const ulStyleStr = useMemo(() => ({
height: `${Math.ceil(list.length / colNum) * _rowHeight + beyondDistance}px`,
minHeight: '100%',
paddingTop
}), [list, colNum, _rowHeight, paddingTop, beyondDistance])
return (
<div onScroll={onScroll} ref={boxRef} className={styles.virtualized} style={style}>
{typeof ulRender === 'function' ? (
ulRender(showList, ulStyleStr)
) : (
<ul style={ulStyleStr}>
{showList.map((item) => (
typeof liRender === 'function' ? (
liRender(item)
) : (
<li key={item}>{item}</li>
)
))}
</ul>
)}
</div>
)
}
index.less
.virtualized {
position: relative;
overflow-y: auto;
border: 1px solid #ddd;
height: 200px;
}