什么是虚拟列表?

就是针对大量数据,例如有10000条数据,只渲染可视区域内的数据,其他的数据不渲染。通过监听scroll事件,来改变页面内的数据。

虚拟列表是对长列表渲染的一种优化,解决大量数据渲染时,造成的渲染性能瓶颈的问题。

方法1:监听scrool事件

import { useEffect, useState } from 'react'
import './App.css'

const list = []
for (let i = 0; i < 10000; i++) {
  list.push({
    id: i,
    content: '我是列表 ' + i
  })

}

function App () {
  const [start, setStart] = useState(0) // 列表起始位置
  const [end, setEnd] = useState(20) // 列表中止位置
  const buffer = 5 // 缓冲数据
  const itemHeight = 50 // 每一项高度
  const total = 10000 // 数据总数
  const height = itemHeight * total // 列表总高度

  const [virtualList, setVirtualList] = useState([])
  // 初始化
  useEffect(() => {
    console.log(11);
    setVirtualList([...list.slice(start, end)])
  }, [])


  function handleScroll (e) {
    console.log('滚动啦', e);
    const viewHeight = e.target.clientHeight // 可视高度
    const scrollTop = e.target.scrollTop // 超出可视区域的高度
    const newStart = scrollTop / itemHeight // 即将离开可视区的起始index
    const newEnd = (viewHeight + scrollTop) / itemHeight // 即将进入可视区的起始index

    const startIndex = Math.max(Math.floor(newStart - buffer), 0)
    const endIndex = Math.min(Math.floor(newEnd + buffer), list.length - 1)
    setStart(startIndex)
    setEnd(endIndex)
    setVirtualList([...list.slice(startIndex, endIndex)])
  }

  return (
    <div className="virtual-list" onScroll={handleScroll}>
      <div className="virtual-list-container" style={{ height: height + 'px' }}>
        {virtualList.length && virtualList.map((item, index) => {
          return <span style={{ height: itemHeight + 'px', top: (itemHeight * (index == 0 ? start : index == virtualList.length - 1 ? end - 1 : start + index)) + 'px' }} key={item.id}>{item.content}</span>
        })}
      </div>
    </div>
  )
}

export default App
html,body,#root,.virtual-list {
  height: 100%;
  overflow: auto;
  padding: 0;
  margin: 0;
}
.virtual-list-container {
  display: flex;
  flex-direction: column;
  position: relative;
}

span {
  border: 1px solid;
  position: absolute;
  left: 0;
  right: 0;
}
span:nth-child(n) {
  border-bottom: none;
}
span:last-child {
  border-bottom: 1px solid;
}

方法二:通过监听元素是否进入可视区(IntersectionObserver ):

IntersectionObserver 可以观察元素是否进入可视区域内,通过判定起始元素和结尾元素是否正在进入可视区域,来改变需要展示的起始坐标。

start:开始下标,给起始元素绑定id用于监听

end:结束下标,给结尾元素绑定id用于监听

当鼠标向上滚动时,我们判定起始元素是否进入可视区域,进入则重新计算起始下标和结束下标。当鼠标向下滚动时,我们判定结尾元素是否进入可视区域,进入则重新计算起始下标和结束下标。

注意:要保证起始坐标和结束坐标都要在不可见的范围内,假设:设定可见范围内数据是20个,起始坐标和结束坐标相差25。

  • 当鼠标向上滚动时,新的结束坐标在可视区域的下一个,此时start坐标在可视区最上方,也就是newEnd = start+20,newStart要保证在可视的20个数据之前并且保持25范围差,也就是newStart = start - 25 + 20。
  • 当鼠标向下滚动时,新的起始坐标在可视数据的上一个,此时end坐标在可视区最下方,也就是newStart = end - 10,newEnd 要保证在可视的20个数据之后并且保持25范围差,也就是newEnd= end + 25 - 10。
  • 即将滚动到最顶端时,newStart在(start - 25 + 20)和0之间取最大值
  • 即将滚动到最低端时,newEnd在(end + 25 - 20)和maxEnd之间取最小值

实现代码如下: 

import { useEffect, useRef, useState } from 'react'
import './App.css'

const list = []
for (let i = 0; i < 10000; i++) {
  list.push({
    id: i,
    content: '我是列表 ' + i
  })

}

function App () {
  const [start, setStart] = useState(0) // 列表起始位置
  const [end, setEnd] = useState(20) // 列表中止位置
  const topEl = useRef()
  const bottomEl = useRef()
  const emptyRef = useRef()

  const buffer = 5 // 缓冲数据
  const itemHeight = 50 // 每一项高度
  const total = 10000 // 数据总数
  const height = itemHeight * total // 列表总高度

  useEffect(() => {
    const ob = new IntersectionObserver(callback)
    // 分别观察开头和结尾的元素
    if (topEl.current) {
      ob.observe(topEl.current)
    }

    if (bottomEl.current) {
      ob.observe(bottomEl.current)
    }

    return () => {
      ob && ob.unobserve(topEl.current)
      ob && ob.unobserve(bottomEl.current)
    }
  }, [end])

  const callback = (entries) => {
    entries.forEach((entry) => {
      // entry.isIntersecting = true or entry.intersectionRatio > 0 进入可视区域
      const maxEndIndex = list.length - 1

      // 鼠标向上滚动
      if (entry.isIntersecting && entry.target.id == 'top') {
        const newStart = Math.max((start - buffer), 0)
        const newEnd = Math.min((start + 20), maxEndIndex)
        setStart(newStart)
        setEnd(newEnd)
      }
      // 鼠标向下滚动
      if (entry.isIntersecting && entry.target.id == 'bottom') {
        const newStart = Math.max((end - 20), 0)
        const newEnd = Math.min((end + buffer), maxEndIndex)
        setStart(newStart)
        setEnd(newEnd)
      }
    })
  }

  // 初始化
  const virtualList = list.slice(start, end)
  const lastIndex = virtualList.length - 1

  return (
    <div className="virtual-list">
      <div className="virtual-list-container" style={{ height: height + 'px' }}>
        {virtualList.length && virtualList.map((item, index) => {
          const refVal = index == 0 ? topEl : index == lastIndex ? bottomEl : emptyRef
          const id = index === 0 ? 'top' : (index === lastIndex ? 'bottom' : '')
          const top = (itemHeight * (index == 0 ? start : index == virtualList.length - 1 ? end - 1 : start + index))

          return <span ref={refVal} style={{ height: itemHeight + 'px', top: top + 'px' }} key={item.id} id={id} >{item.content}</span>
        })}
      </div>
    </div>
  )
}

export default App