美文网首页
React实现虚拟滚动列表

React实现虚拟滚动列表

作者: 书中自有颜如玉__ | 来源:发表于2022-09-15 18:43 被阅读0次

一、实现原理

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

相关文章

网友评论

      本文标题:React实现虚拟滚动列表

      本文链接:https://www.haomeiwen.com/subject/uwnjortx.html