用react实现虚拟滚动组件

问题引入

存在一个数据量大的列表,如select、list,可能存在1000个数据项
传统做法是直接将1000个item渲染到页面上,即渲染1000个dom


渲染到页面上
dom结构

但是

从上图可以看出,可视区域范围内,仅有7个item,也就是说,不可见的993个元素的存在是浪费资源,从而导致渲染时占满内存、造成页面卡顿。


image.png
image.png

从这里我们可以入手优化这个list。

解决思路

既然可视区域只有至多7个元素可见,那么就仅仅渲染这7个dom,不渲染剩下的993个dom。
我们需要计算出如下的数据:

  • 当前滚动容器的可视区域高度

  • 容器内单个dom的高度

  • 可视区域内至多可容纳的元素个数(没显示全的也算)


    容器内元素
  • 当前可视的第一个元素下标

当前滚动容器的可视区域高度

我们可以用到offsetHeight属性,元素的可视高度

在此之前需要先定义一个滚动容器,并使用ref将其管理起来,并且设置容器高度height,和overflow-y, 让它滚动起来。 这里为了组件的通用性,我们将heightwidth都通过父组件传入的方式获取。

  const listRef = useRef()
  const { height, width } = props
  return (
    
)

动态地计算高度

listRef?.current?.offsetHeight

容器内单个dom的高度

这里我们需要先将第一个子元素渲染出来才能动态地获取它的高度, 同样,我们将列表通过props传入

  const listRef = useRef()
  const { height, width, listDom } = props
  const domHeight = listRef?.current?.children[1]?.offsetHeight || 0
  return (
    
{listDom[0]}
)

可视区域内至多可容纳的元素个数(没显示全的也算)

用可视化区域的高度 除以 单个元素的高度,再向上取整,因为存在一些可见,但是不完全可见的元素,所以这里向上取整。

const domCount = Math.ceil(listRef?.current?.offsetHeight / domHeight)

当前可视的第一个元素下标

滚动容器中有个属性叫scrollTop,表示当前已滚动的高度,也就是上方不可见的区域。
我们用scrollTop除以单个元素的高度domHeight,再向下取整,即可得到当前可见区域第一个元素的下标。

const currentIndex = Math.floor(listRef?.current?.scrollTop / domHeight)

至此我们可以将上边需要动态计算的几个变量存入state

  const [ conf, setConf ] = useState({
    domCount: 1, // 当前可见的dom数量
    currentIndex: 0, // 当前可展示的第一个非空白dom
  })

下面开始渲染。
这里需要截取listDom , 使用slice()方法。截取起始位置为currentIndex,结束为止为currentIndex + domCount

    
{listDom.slice(conf?.currentIndex, conf?.currentIndex + conf?.domCount)}

在这里我们会发现,列表上只有7个元素,且无法滚动
我们还没有给元素绑定滚动事件。
这里我们将上述计算逻辑提到一个函数中进行,并将其绑定scroll

  useEffect(() => {
    if(listRef?.current){
      listRef.current.addEventListener('scroll', throttle(()=>{
        handleChangeConf()
      }, 30))
    }
    return () => {
      if(listRef?.current){
        listRef.current.removeEventListener('scroll', ()=>{
          handleChangeConf()
        })
      }
    }
  }, [])

  const handleChangeConf = () => {
    const domHeight = listRef?.current?.children[0]?.offsetHeight || 0
    const domCount = Math.ceil(listRef?.current?.offsetHeight / domHeight)
    const currentIndex = Math.floor(listRef?.current?.scrollTop / domHeight)
    setConf({
      domCount,
      currentIndex
    })
  }

绑定事件后发现还是无法滚动...

原来我们还需要给列表设置上下空白区域,撑开列表,不然无法模拟出来真是列表的高度。

我们继续计算上下空白的高度。

上下空白的高度

上空白的高度,我们已经计算出了当前可视区域第一个元素的下标了,也计算出了单个元素的站位高度那么

const topHeight = currentIndex * domHeight

下空白的高度
整个列表的高度 - 可视区域的元素高度 - 上空白的高度

const bottomHeight = listDom?.length * domHeight - topHeight - domCount * domHeight

将这两个变量也存入state

  const [ conf, setConf ] = useState({
    domCount: 1, // 当前可见的dom数量
    topHeight: 0, // 顶部空白dom
    bottomHeight: 0, // 底部空白dom
    currentIndex: 0, // 当前可展示的第一个非空白dom
  })

  const handleChangeConf = () => {
    const domHeight = listRef?.current?.children[0]?.offsetHeight || 0
    const domCount = Math.ceil(listRef?.current?.offsetHeight / domHeight)
    const currentIndex = Math.floor(listRef?.current?.scrollTop / domHeight)
    const topHeight = currentIndex * domHeight
    const bottomHeight = listDom?.length * domHeight - topHeight - domCount * domHeight
    setConf({
      domCount,
      topHeight,
      bottomHeight,
      currentIndex
    })
  }

然后再将上下两个空白元素添加到dom结构中占位,此时需要将计算domHeight的取值下标改为[1]

    const domHeight = listRef?.current?.children[1]?.offsetHeight || 0

    ...

      
{listDom.slice(conf?.currentIndex, conf?.currentIndex + conf?.domCount)}

初步完成

image.png
image.png

这里可以看到dom结构中只有几个可见的dom, 并且滑动时动态变化。

渲染效率也大大提高。

image.png

可以明显感觉到变快了

还能做什么优化?

在这里我们渲染的时候事在对listDom在不断地增删、也就是在不断的增加、删除dom。

那么可不可以不增加删除、或者尽量少地增加删除dom实现我们的功能呢?

image.png

react diff算法

element_diff(相同层级的虚拟dom发生变化)
如滚动前 A B C D
滚动后 B C D E

如果不加key,则无法判断 B C D是否还是原来的B C D
对比时 发现A!=B,则会删除A,添加B,以此类推 删除B C D、插入 C D E

添加key后,发现B C D没有变化 ,仅仅需要删除A 插入E即可

你可能感兴趣的:(用react实现虚拟滚动组件)