概念描述
- 长列表加载,在前端日常开发中算是比较常见的需求。一般主要在移动端,PC端这类需求用分页就能解决,移动端自然是不会采用分页形式去加载列表(这里说的分页就是常见的有分页选项,需要手动去点击下一页)。
- 针对具体不同的场景,移动端长列表加载有以下方法来解决:懒加载、定高虚拟列表、非定高虚拟列表。下面分别简单实现下详细过程。
懒加载
- 实现原理:
- 其实就是PC端"分页"的思想,只不过这里的分页不是点击下一页,而是随着页面滚动到底部就去加载下一页。
- 当然还需要设置列表的外层container高度以及允许滚动。
- 判断滚动到底部的条件(获取container的 scrollHeight、scrollTop、offsetHeight):
scrollHeight - scrollTop < offsetHeight + threshold(距离阈值,可以看着定义,主要用于判断滚动到底部的条件) - 既然用到滚动事件,肯定要利用防抖去控制事件执行频率,不然太消耗性能。
- 效果展现
- 代码实现
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 的高度)
- 效果展现
- 代码实现
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,在不停的接近真实值,如果滚动到底的话,后续的向上、向下滚动计算的值就都是准确的)
- 效果展现
- 代码实现
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…