长列表加载(懒加载、虚拟列表)

1,384 阅读5分钟

概念描述

  • 长列表加载,在前端日常开发中算是比较常见的需求。一般主要在移动端,PC端这类需求用分页就能解决,移动端自然是不会采用分页形式去加载列表(这里说的分页就是常见的有分页选项,需要手动去点击下一页)。
  • 针对具体不同的场景,移动端长列表加载有以下方法来解决:懒加载、定高虚拟列表、非定高虚拟列表。下面分别简单实现下详细过程。

懒加载

  • 实现原理:
    • 其实就是PC端"分页"的思想,只不过这里的分页不是点击下一页,而是随着页面滚动到底部就去加载下一页。
    • 当然还需要设置列表的外层container高度以及允许滚动。
    • 判断滚动到底部的条件(获取container的 scrollHeight、scrollTop、offsetHeight):scrollHeight - scrollTop < offsetHeight + threshold(距离阈值,可以看着定义,主要用于判断滚动到底部的条件)
    • 既然用到滚动事件,肯定要利用防抖去控制事件执行频率,不然太消耗性能。
  • 效果展现

懒加载.gif

  • 代码实现
import { useState, useEffect, useRef } from 'react'
import { debounce } from 'lodash-es'
import { getListByPage } from '../mock/index'
import './style/lazyList.css'
import { IList } from '../mock/index'

// 距离阈值,可以看着定义,主要用于判断滚动到底部的条件
const threshold = 1
const pageSize = 20

function LazyList() {
	// list container ref 引用
	const listRef = useRef<HTMLDivElement | null>(null)
	// 分页获取数据的页码引用
	const pageNumRef = useRef<number>(1)
	// 列表数据
	const [list, setList] = useState<IList[]>([])

	useEffect(() => {
		// 初始化 list
		const list = getListByPage(pageNumRef.current, pageSize)
		setList(list)

		// 监听滚动事件
		listRef.current?.addEventListener('scroll', handleScroll)

		// 组件销毁,取消事件监听
		return () => {
			listRef.current?.removeEventListener('scroll', handleScroll)
		}
	}, [])

	// 滑动到底部,重新设置页码,获取新的一页数据,合并到 list 里面
	const onReachBottom = () => {
		pageNumRef.current++
		const curList = getListByPage(pageNumRef.current, pageSize)
		setList((list) => list.concat(curList))
	}

	// 滚动事件,加防抖事件
	const handleScroll = debounce((e: Event) => {
		const target = e.target
		const { scrollTop, scrollHeight, offsetHeight } =
			target as EventTarget & {
				scrollTop: number
				scrollHeight: number
				offsetHeight: number
			}

		// 滚动到底部
		if (scrollHeight - scrollTop < offsetHeight + threshold) {
			onReachBottom()
		}
	}, 300)

	return (
		<div className="lazy-list-container" ref={listRef}>
			{list.length
				? list.map((item) => {
					return (
						<div key={item.id} className="item">
							懒加载 - {item.value}
						</div>
					)
				})
				: null}
		</div>
	)
}

export default LazyList

懒加载 + 定高虚拟列表

  • 实现原理:
    • 初始化的时候只渲染 2 个屏幕的数据。(多渲染 1 屏防止向下滑动出现空白)
    • 向下滚动的时候,根据是否滚动到底部,重新加载数据。
    • 如果没滚动到底部,计算显示区域需要显示的数据索引,可以前后都多留 1 屏的数据。
    • 然后还得计算顶部块和底部块的高度,用于撑开 list 的高度,方便滚动。(这里你也可以直接设置 list 的高度,然后利用 translateY 向下移动,显示出想要渲染的数据,这种方式也行,总之都是撑开 list 的高度)

虚拟列表.png

  • 效果展现

定高虚拟列表.gif

  • 代码实现
import { useState, useEffect, useRef } from 'react'
import { debounce } from 'lodash-es'
import { getListByPage } from '../mock/index'
import './style/lazyList.css'
import { IList } from '../mock/index'

// 距离阈值,可以看着定义,主要用于判断滚动到底部的条件
const threshold = 1
// 单个 item 高度
const itemHeight = 40
// 获取数据 pageSize
const pageSize = 20

function VirtualListLimitHeight() {
	// list container ref 引用
	const listRef = useRef<HTMLDivElement | null>(null)
	// 分页获取数据的页码引用
	const pageNumRef = useRef<number>(1)
	// 列表数据
	const [list, setList] = useState<IList[]>([])
	// 列表显示区域开始索引
	const [beginIndex, setBeginIndex] = useState<number>(0)
	// 列表显示区域结束索引
	const [endIndex, setEndIndex] = useState<number>(0)

	useEffect(() => {
		// 初始化 list
		const list = getListByPage(pageNumRef.current, pageSize)
		// 获取 list container offsetHeight
		const offsetHeight = listRef.current?.offsetHeight
		// 计算显示区域结束索引,默认展示 2 屏数据
		const endIndex = offsetHeight
			? Math.ceil((offsetHeight * 2) / itemHeight)
			: 0
		setEndIndex(endIndex)
		setList(list)

		// 监听滚动事件
		listRef.current?.addEventListener('scroll', handleScroll)
		// 组件销毁,取消事件监听
		return () => {
			listRef.current?.removeEventListener('scroll', handleScroll)
		}
	}, [])

	// 滑动到底部
	const onReachBottom = () => {
		pageNumRef.current++
		const curList = getListByPage(pageNumRef.current, pageSize)
		setList((list) => list.concat(curList))
	}

	// 滚动事件
	const handleScroll = debounce((e: Event) => {
		const target = e.target
		const { scrollTop, scrollHeight, offsetHeight } =
			target as EventTarget & {
				scrollTop: number
				scrollHeight: number
				offsetHeight: number
			}

		// 滚动到底部,请求下一页数据
		if (scrollHeight - scrollTop < offsetHeight + threshold) {
			onReachBottom()
		} else {
			// 计算开始索引
			let beginIndex = 0
			if (scrollTop > offsetHeight) {
				beginIndex = Math.floor((scrollTop - offsetHeight) / itemHeight)
			}

			// endIndex 设置 2 屏,减少向下滚动出现白屏的可能性
			const endIndex = Math.ceil(
				(scrollTop + offsetHeight * 2) / itemHeight,
			)
			setBeginIndex(beginIndex)
			setEndIndex(endIndex)
		}
	}, 300)

	const getListRender = () => {
		// 计算顶部块和底部块的高度,撑开滚动条
		const beginHeight = beginIndex * itemHeight
		const endHeight = ((list.length - endIndex) < 0 ? 0 : (list.length - endIndex)) * itemHeight
		const listData = list.slice(beginIndex, endIndex + 1)
		return (
			<>
				<div key="begin" style={{ height: beginHeight }}></div>
				{listData.length
					? listData.map((item) => {
						return (
							<div key={item.id} className="item">
								无限滚动-定高虚拟滚动 - {item.value}
							</div>
						)
					})
					: null}
				<div key="end" style={{ height: endHeight }}></div>
			</>
		)
	}
	return (
		<div className="lazy-list-container" ref={listRef}>
			{getListRender()}
		</div>
	)
}

export default VirtualListLimitHeight

非定高虚拟列表

  • 实现原理:
    • 需要将所有的数据一次性获取,这里暂不考虑无限滚动。
    • 首先明确一点要想滚动,必须把 list 高度撑起来。在定高虚拟列表里,因为 item 高度固定,所以很容易计算各种高度。现在 每个 item 高度不固定,直接算没这么简单。
    • 我们可以假设每个 item 有个平均高度(可以根据实际的 item 平均估算一下)itemHeight。
    • 初始化的时候按照这个 itemHeight 显示2屏的初始数据。显示完之后,把每个 item 的高度信息都缓存起来(后面需要利用这实际 item 的高度计算 beginIndex,以及不断的更新item 的平均高度 itemHeight)。
    • 向下滚动的时候(默认滚动的距离不会很大,不会超过1屏),可以根据缓存起来的 item 高度(累计求和),与滚动的高度比较,从而计算出 beginIndex。(到这一步,beginIndex 计算是正确的,因为是根据实际 item 的高度比较计算的,而不是根据平均 itemHeight 计算)
    • 然后计算 endIndex、endHeight 的时候,就要用到 item 的平均itemHeight。(实际上这两个计算的都不是很准确但是不影响滚动就行,因为那个 item 的平均 itemHeight,在不停的接近真实值,如果滚动到底的话,后续的向上、向下滚动计算的值就都是准确的)
  • 效果展现

非定高虚拟列表.gif

  • 代码实现
import { useState, useEffect, useRef } from 'react'
import { debounce } from 'lodash-es'
import { getListByPage } from '../mock/index'
import './style/lazyList.css'
import { IList } from '../mock/index'

let itemHeight = 40

function VirtualList() {
	const listRef = useRef<HTMLDivElement | null>(null)
	const pageNumRef = useRef<number>(1)
	const listMapRef = useRef<Map<string, DOMRect>>(new Map())
	const [list, setList] = useState<IList[]>([])
	const [beginIndex, setBeginIndex] = useState<number>(0)
	const [endIndex, setEndIndex] = useState<number>(0)
	const [beginHeight, setBeginHeight] = useState<number>(0)
	const [endHeight, setEndHeight] = useState<number>(0)

	useEffect(() => {
		const list = getListByPage(pageNumRef.current)
		const offsetHeight = listRef.current?.offsetHeight
		const endIndex = offsetHeight
			? Math.ceil((offsetHeight * 2) / itemHeight)
			: 0
		const endHeight = (list.length - endIndex) * itemHeight
		setEndHeight(endHeight)
		setEndIndex(endIndex)
		setList(list)

		listRef.current?.addEventListener('scroll', handleScroll)
		return () => {
			listRef.current?.removeEventListener('scroll', handleScroll)
		}
	}, [])

	const cacheMapList = () => {
		console.log('list', list)
		const listDom = listRef.current
		if (listDom?.children) {
			for (let node of listDom.children) {
				const id = node.getAttribute('id')
				if (id?.includes('item')) {
					if (!listMapRef.current.has(id)) {
						listMapRef.current.set(id, node.getBoundingClientRect())
					}
				}
			}
		}
	}

	useEffect(cacheMapList, [list, beginIndex])

	// 滚动事件
	const handleScroll = debounce((e: Event) => {
		const target = e.target
		const { scrollTop, scrollHeight, offsetHeight } =
			target as EventTarget & {
				scrollTop: number
				scrollHeight: number
				offsetHeight: number
			}
		const [beginIndex, beginHeight] = getBeginIndex(scrollTop, offsetHeight)
		// 设置 endIndex 时,需要在 beginIndex 的基础上,加上 3 段屏幕的距离。(begin 隐藏区缓存数据、屏幕区、end 隐藏区缓存数据)
		const endIndex = beginIndex + Math.ceil((offsetHeight * 3) / itemHeight)
		const endHeight = scrollHeight - beginHeight - offsetHeight * 3
		setBeginHeight(beginHeight)
		setEndHeight(endHeight > 0 ? endHeight : 0)
		setBeginIndex(beginIndex)
		setEndIndex(endIndex)
	}, 300)

	const getBeginIndex = (scrollTop: number, offsetHeight: number) => {
		// 滚动的距离不超过屏幕的距离,默认从开始索引开始渲染。
		if (scrollTop < offsetHeight) {
			return [0, 0]
		}

		let height = 0
		let index = 0
		for (let value of listMapRef.current.values()) {
			height = height + value.height
			// 滚动的距离超过屏幕的距离,隐藏区域默认设置 1 屏幕的隐藏数据。
			// 优化点:这里可以将 计算好的相对高度,缓存起来,下次直接从缓存中拿
			if (height >= scrollTop - offsetHeight) {
				height = height - value.height
				break
			} else {
				index++
			}
		}
		// 动态计算平均 item 平均高度
		if (height > 0) {
			itemHeight = Math.ceil(height / index)
		}
		// console.log('itemHeight', itemHeight)
		return [index, height]
	}

	const getListRender = () => {
		const listData = list.slice(beginIndex, endIndex + 1)
		return (
			<>
				<div
					key="begin"
					style={{ height: beginHeight }}
					id="block-begin"
				></div>
				{listData.length
					? listData.map((item, index) => {
						return (
							<div
								key={item.id}
								className={
									index % 2 === 0 ? 'item' : 'item1'
								}
								id={`item-${item.id}`}
							>
								非定高虚拟滚动 - {item.value}
							</div>
						)
					})
					: null}
				<div
					key="end"
					style={{ height: endHeight }}
					id="block-end"
				></div>
			</>
		)
	}
	return (
		<div className="lazy-list-container" ref={listRef}>
			{getListRender()}
		</div>
	)
}

export default VirtualList

实际代码演示仓库

参考文档

juejin.cn/post/684490… juejin.cn/post/713499… juejin.cn/post/703615…