React实现虚拟滚动列表

一、实现原理

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,084评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,623评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,450评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,322评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,370评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,274评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,126评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,980评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,414评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,599评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,773评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,470评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,080评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,713评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,852评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,865评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,689评论 2 354

推荐阅读更多精彩内容