虚拟列表实践的几种方法

172 阅读2分钟

1 使用react-window库

react-window 是一个用于优化渲染大型列表和表格的 React 库。它通过只渲染可见区域的元素来提高性能。

1.1 列表项的高度是固定的

import React from "react"
import { FixedSizeList as List } from "react-window"

const items = Array.from({ length: 1000 }, (_, index) =>
  `Item ${index}`.repeat(20)
)

const Row = ({
  index,
  style,
}: {
  index: number
  style: React.CSSProperties
}) => <div style={style}>{items[index]}</div>

const App = () => (
  <div
    style={{
      display: "flex",
      justifyContent: "center",
      alignItems: "center",
      height: "100vh",
      width: "100vw",
    }}
  >
    <List
      height={500} // 整个列表的高度
      itemCount={items.length} // 项目总数
      itemSize={60} // 每项的高度
      width={600} // 列表的宽度
    >
      {Row}
    </List>
  </div>
)

export default App

1.2 列表项的高度是不固定的

import React, { useRef } from "react"
import { VariableSizeList as List } from "react-window"

const items = Array.from({ length: 1000 }, (_, index) =>
  `Item ${index}`.repeat(20)
)

const Row = ({
  index,
  style,
}: {
  index: number
  style: React.CSSProperties
}) => <div style={style}>{items[index]}</div>

const App = () => {
  const listRef = useRef<List>(null)

  // 定义一个函数来获取每个项目的高度
  const getItemSize = (index: number): number => {
    // 可以根据项目的内容返回不同的高度
    // 这里我们将每个偶数项的高度设置为50,奇数项设置为75
    return index % 2 === 0 ? 50 : 75
  }

  const scrollTo = (index: number) => {
    if (listRef.current) {
      //VariableSizeList 组件的 scrollToItem 方法
      listRef.current.scrollToItem(index)
    }
  }
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        height: "100vh",
        width: "100vw",
      }}
    >
      <button onClick={() => scrollTo(500)}>Scroll to Item 500</button>
      <List
        height={500} // 列表的高度
        itemCount={items.length} // 项目总数
        itemSize={getItemSize} // 获取每项的高度
        width={600} // 列表的宽度
        ref={listRef}
      >
        {Row}
      </List>
    </div>
  )
}

export default App

2 自己实现虚拟列表

2.1 列表项的高度不固定

// App.txs
import React from "react"
import { VirtualList } from "./components/VirtualList"

const APP = () => {
  const list = Array.from({ length: 1000 }, (_, index) =>
    `Item ${index}`.repeat(20)
  )
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        height: "100vh",
        width: "100vw",
      }}
    >
      <VirtualList list={list} containerHeight={550} bufferCount={1} />
    </div>
  )
}
// components/VirtualList/index.tsx

import React, { useState, useRef, useEffect } from "react"

interface VirtualListProps {
  /** 列表项数组 */
  list: string[]
  /** 容器高度 */
  containerHeight: number
  /** 缓冲区数量 */
  bufferCount: number
}
export const VirtualList: React.FC<VirtualListProps> = ({
  list,
  containerHeight,
  bufferCount,
}) => {
  const containerRef = useRef<HTMLDivElement>(null)
  /** 当前滚动位置 */
  const [scrollTop, setScrollTop] = useState(0)
  /** 每个列表项的高度数组 */
  const [itemHeights, setItemHeights] = useState<number[]>([])

  // 获取每项的高度
  const getItemHeight = (index: number): number => {
    // 偶数项高度为50,奇数项高度为75
    return index % 2 === 0 ? 50 : 75
  }
  // 在组件挂载后计算每个列表项的高度
  useEffect(() => {
    const heights = list.map((_item, index) => getItemHeight(index))
    setItemHeights(heights)
  }, [list])

  // 计算所有列表项的总高度
  const docTotalHeight = itemHeights.reduce((sum, cur) => {
    return sum + cur
  }, 0)

  // 计算当前视口内可见的列表项索引范围
  const getVisibleItemsIndex = () => {
    let accumulatedHeight = 0
    let startIndex = 0
    let endIndex = list.length - 1
    // 计算开始索引
    for (let i = 0; i < list.length; i++) {
      accumulatedHeight += itemHeights[i]
      if (accumulatedHeight > scrollTop) {
        startIndex = i
        break
      }
    }
    accumulatedHeight = 0
    //计算结束索引
    for (let i = startIndex; i < list.length; i++) {
      accumulatedHeight += itemHeights[i]
      if (accumulatedHeight > containerHeight) {
        endIndex = i
        break
      }
    }
    // 返回带有缓冲区的可见项索引范围
    return {
      startIndex: Math.max(startIndex - bufferCount, 0),
      endIndex: Math.min(endIndex + bufferCount, list.length - 1),
    }
  }
  // 根据当前滚动位置计算可见项的索引范围
  const { startIndex, endIndex } = getVisibleItemsIndex()

  // 处理滚动事件
  const handleScroll = () => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop)
    }
  }

  // 在组件挂载时添加滚动事件监听器,并在组件卸载时移除监听器
  useEffect(() => {
    const viewport = containerRef.current
    if (viewport) {
      viewport.addEventListener("scroll", handleScroll)
      return () => {
        viewport.removeEventListener("scroll", handleScroll)
      }
    }
  }, [])

  const renderItem = (item: string) => {
    return <span>{item}</span>
  }
  // 用于存储渲染的列表项
  const items = []
  // 每个项的偏移量
  let topOffset = 0
  // 使用for循环得到开始项的偏移量
  for (let i = 0; i < startIndex; i++) {
    topOffset += itemHeights[i] || 0
  }
  // 根据计算的索引范围渲染可见项
  for (let i = startIndex; i <= endIndex; i++) {
    items.push(
      <div
        key={i}
        style={{ position: "absolute", top: `${topOffset}px`, width: "100%" }}
      >
        {renderItem(list[i])}
      </div>
    )
    topOffset += itemHeights[i] || 0
  }
  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        width: "80%",
        overflowY: "auto",
        position: "relative",
        border: "1px solid #ccc",
      }}
    >
      <div style={{ height: `${docTotalHeight}px`, position: "relative" }}>
        {items}
      </div>
    </div>
  )
}

2.2 列表项的高度不清楚

列表项的高度由其内容撑开,因此这个时候我们不能提前知道每项的高度。

// App.txs
import React from "react"
import { VirtualList } from "./components/VirtualList"

const APP = () => {
  const list = Array.from({ length: 1000 }, (_, index) =>
    `Item ${index}`.repeat(index + 1)
  )
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        height: "100vh",
        width: "100vw",
      }}
    >
      <VirtualList list={list} containerHeight={550} bufferCount={1} />
    </div>
  )
}
// components/VirtualList/index.tsx
import React, { useState, useRef, useEffect } from "react"

interface VirtualListProps {
  /** 列表项数组 */
  list: string[]
  /** 容器高度 */
  containerHeight: number
  /** 缓冲区数量 */
  bufferCount: number
}
export const VirtualList: React.FC<VirtualListProps> = ({
  list,
  containerHeight,
  bufferCount,
}) => {
  const containerRef = useRef<HTMLDivElement>(null)
  const itemRefs = useRef<(HTMLDivElement | null)[]>([])
  /** 当前滚动位置 */
  const [scrollTop, setScrollTop] = useState(0)
  /** 每个列表项的高度数组 */
  const [itemHeights, setItemHeights] = useState<number[]>([])
  // 在组件挂载后计算每个列表项的高度
  useEffect(() => {
    // 使用每项dom实例的offsetHeight获取该项的高度*************************与2.1的区别在这里
    const heights = itemRefs?.current?.map((ref) =>
      ref ? ref.offsetHeight : 0
    )
    setItemHeights(heights)
  }, [list])

  // 计算所有列表项的总高度
  const docTotalHeight = itemHeights.reduce((sum, cur) => {
    return sum + cur
  }, 0)

  // 计算当前视口内可见的列表项索引范围
  const getVisibleItemsIndex = () => {
    let accumulatedHeight = 0
    let startIndex = 0
    let endIndex = list.length - 1
    // 计算开始索引
    for (let i = 0; i < list.length; i++) {
      accumulatedHeight += itemHeights[i]
      if (accumulatedHeight > scrollTop) {
        startIndex = i
        break
      }
    }
    accumulatedHeight = 0
    //计算结束索引
    for (let i = startIndex; i < list.length; i++) {
      accumulatedHeight += itemHeights[i]
      if (accumulatedHeight > containerHeight) {
        endIndex = i
        break
      }
    }
    // 返回带有缓冲区的可见项索引范围
    return {
      startIndex: Math.max(startIndex - bufferCount, 0),
      endIndex: Math.min(endIndex + bufferCount, list.length - 1),
    }
  }
  // 根据当前滚动位置计算可见项的索引范围
  const { startIndex, endIndex } = getVisibleItemsIndex()

  // 处理滚动事件
  const handleScroll = () => {
    if (containerRef.current) {
      setScrollTop(containerRef.current.scrollTop)
    }
  }

  // 在组件挂载时添加滚动事件监听器,并在组件卸载时移除监听器
  useEffect(() => {
    const viewport = containerRef.current
    if (viewport) {
      viewport.addEventListener("scroll", handleScroll)
      return () => {
        viewport.removeEventListener("scroll", handleScroll)
      }
    }
  }, [])

  const renderItem = (item: string) => {
    return <span>{item}</span>
  }
  // 用于存储渲染的列表项
  const items = []
  // 每个项的偏移量
  let topOffset = 0
  // 使用for循环得到开始项的偏移量
  for (let i = 0; i < startIndex; i++) {
    topOffset += itemHeights[i] || 0
  }
  // 根据计算的索引范围渲染可见项
  for (let i = startIndex; i <= endIndex; i++) {
    items.push(
      <div
        key={i}
        ref={(el) => {
          itemRefs.current[i] = el
        }}
        style={{ position: "absolute", top: `${topOffset}px`, width: "100%" }}
      >
        {renderItem(list[i])}
      </div>
    )
    topOffset += itemHeights[i] || 0
  }
  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        width: "80%",
        overflowY: "auto",
        position: "relative",
        border: "1px solid #ccc",
      }}
    >
      <div style={{ height: `${docTotalHeight}px`, position: "relative" }}>
        {items}
      </div>
    </div>
  )
}