无限加载
无限加载指的用户滑动到页面底部再去请求下一屏数据,所以核心便是怎么判断用户是否滑动到底部,这里有两种方法:
IntersectionObserver
const io = new IntersectionObserver(callback, option);
**介绍:**上述代码中, IntersectionObserver 接受两个参数:callback 是可见性变化时的回调函数,option 是一个可选的配置项。构造函数的返回值是一个观察器实例。实例的 observe 方法可以指定观察哪个 DOM 节点
**做法:**设定某个哨兵元素(通常是 DIV)放置在列表底部,当监听到它进入视口时,就触发下一屏数据的加载
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
loadMoreData(); // 加载下一页数据
}
});
observer.observe(document.getElementById('sentinel'));
监听 ScrollTop + 容器高度
**介绍: **
document.body.scrollTop // 浏览器滚动了的距离
ele.scrollHeight // 元素内容总高度
ele.clientHeight // 元素可视区域高度
用法:通过手动监听滚动事件,根据通过手动监听滚动事件,根据scrollTop + clientHeight >= scrollHeight判断是否到底判断是否到底
const list = document.getElementById('list');
list.addEventListener('scroll', () => {
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 10) {
loadMoreData(); // 加载下一页数据
}
});
需要注意的是,由于会频繁触发scroll事件,所以需要对 scroll 的回调函数添加节流处理
// 节流函数,确保在 delay 毫秒内只执行一次
function throttle(fn, delay) {
let mark = null;
return function (...args) {
if (!mark) {
mark = setTimeOut(() => {
fn.call(this, args)
mark = null
} , delay)
}
};
}
const list = document.getElementById('list');
function handleScroll() {
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 10) {
loadMoreData(); // 加载下一页数据
}
}
// 加上节流,100ms 执行一次
list.addEventListener('scroll', throttle(handleScroll, 100));
对比
监听 scroll 事件虽然可行,但是其加了节流,仍会多次触发滚动事件,造成性能上的低劣。
优化
当然,在实际使用中,我们可以在用户真正滑到“底部”之前,提前一段距离触发数据加载,让下一页数据在用户到达时已经准备好,避免加载等待,做到“无感下滑”
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
loadMoreData();
}
}, {
root: list, // 滚动容器
rootMargin: '0px 0px 300px 0px' // 提前 300px 触发
});
observer.observe(document.getElementById('sentinel'));
虚拟列表
当页面需要一次性加载渲染大量数据时,虚拟列表无疑是最好的方案。其是一种只渲染可视窗口内的元素而其余元素不进行渲染的技术,从而极大程度上减少 DOM 数量,提升渲染性能
listItem 定高
原理:
虚拟列表主要依赖两个核心进行计算:
- 当前滚动位置
- 列表项的高度
通过滚动的位置+每一项的高度,我们就可以推断出当前应该渲染的列表项的起始 Index,并将容器下方和容器上方顶起来,这样就形成了一个简单的虚拟列表,示意图如下:
+------------------------------------+
| [ 顶部占位高度 (paddingTop) ] |
| [ 可视区域:只渲染可见的几项 ] |
| [ 底部占位高度 (paddingBottom) ] |
+------------------------------------+
实现:
基于此,我们可以用代码简单的实现一下:
import React, { useRef, useState, useEffect } from 'react';
const VirtualList = ({
height = 400, // 容器高度
itemHeight = 40, // 单项高度
data = [], // 列表数据
renderItem, // 渲染函数
buffer = 5 // 额外缓冲项数量
}) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const totalCount = data.length;
const visibleCount = Math.ceil(height / itemHeight) + buffer;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleCount, totalCount);
const offsetTop = startIndex * itemHeight;
const visibleData = data.slice(startIndex, endIndex);
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
return (
<div
ref={containerRef}
style={{ height, overflowY: 'auto', border: '1px solid #ccc' }}
onScroll={handleScroll}
>
<div style={{ height: totalCount * itemHeight, position: 'relative' }}>
<div style={{ paddingTop: offsetTop }}>
{visibleData.map((item, index) => (
<div key={startIndex + index} style={{ height: itemHeight, borderBottom: '1px solid #eee' }}>
{renderItem(item, startIndex + index)}
</div>
))}
</div>
</div>
</div>
);
};
export default VirtualList;
listItem 不定高
定高的虚拟列表基本可以 cover 朋友们的简历内容了,但是为了防止面试官深入挖下去,所以准备一下不定高的虚拟列表的实现方案也是很有必要的。比如对于某些商品长列表,图片稍微大点或者介绍信息稍微长点就会形成这种不定高的列表项。
那么,不定高的虚拟列表的难点在哪里呢,简单来说,其难点有两个:
- 首先外部容器的总长不确定
- 其次无法通过 scrollTop / 元素高度获得初识索引值
对应的解决措施可以归纳为:
- 针对难点 1,可以初始假定一个足够元素长度,因为精确地计算出容器总高度的意义不是很大,之后在每次滚动时,累加计算已经滚动过的元素高度,加上剩余元素的假定高度,实时更新容器的最终高度,但是 pc 端会出现拖拽滑轮滚动比不一致情况
- 针对难点 2,根据视口高度累加展示的元素高度和,如果滑动到的高度高于已经累加过的元素高度和,则再进行累加,直到满足高度。但是需要一个 map 用来存储已经滑动过的元素高度信息,包括元素自身的高度和它之前的总高度 => 双链表+哈希
可参考代码如下:
import React, { useRef, useState, useEffect } from 'react';
const VirtualListDynamic = ({
data = [],
estimatedHeight = 100,
containerHeight = 400,
overscan = 5,
renderItem
}) => {
const containerRef = useRef(null);
const itemRefs = useRef({});
const heightMap = useRef(new Map()); // index => { height, offsetTop }
const [scrollTop, setScrollTop] = useState(0);
const [totalHeight, setTotalHeight] = useState(data.length * estimatedHeight);
// 计算 offsetMap 用于累加高度定位起始项
const buildOffsetMap = () => {
let offset = 0;
const map = new Map();
for (let i = 0; i < data.length; i++) {
const height = heightMap.current.get(i)?.height ?? estimatedHeight;
map.set(i, { height, offsetTop: offset });
offset += height;
}
setTotalHeight(offset);
return map;
};
const offsetMap = buildOffsetMap();
// 找到可视区域的起始 index
const getStartIndex = () => {
let i = 0;
while (i < data.length) {
const { offsetTop, height } = offsetMap.get(i);
if (offsetTop + height > scrollTop) break;
i++;
}
return Math.max(0, i);
};
const startIndex = getStartIndex();
const endIndex = Math.min(data.length, startIndex + overscan + 20);
const topPadding = offsetMap.get(startIndex)?.offsetTop ?? 0;
const bottomPadding = totalHeight - (offsetMap.get(endIndex)?.offsetTop ?? totalHeight);
// 监听滚动
const onScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
// 渲染后记录真实高度
useEffect(() => {
const updated = new Map(heightMap.current);
let changed = false;
for (let i = startIndex; i < endIndex; i++) {
const el = itemRefs.current[i];
if (el) {
const height = el.getBoundingClientRect().height;
if (!updated.has(i) || updated.get(i).height !== height) {
updated.set(i, { height, offsetTop: 0 }); // offsetTop 会在 buildOffsetMap 中更新
changed = true;
}
}
}
if (changed) {
heightMap.current = updated;
}
}, [startIndex, endIndex]);
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflowY: 'auto', border: '1px solid #ccc' }}
onScroll={onScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ paddingTop: topPadding, paddingBottom: bottomPadding }}>
{data.slice(startIndex, endIndex).map((item, i) => {
const index = startIndex + i;
return (
<div
key={index}
ref={(el) => (itemRefs.current[index] = el)}
style={{ marginBottom: 4 }}
>
{renderItem(item, index)}
</div>
);
})}
</div>
</div>
</div>
);
};
export default VirtualListDynamic;
无限加载+虚拟列表
但是在实际的场景应用中,即便我们使用的无限加载,但是当用户多刷几屏之后,当前页面需要维护的 DOM 元素也变得多了起来,那么根据上述所讲的内容,我们就需要给页面加上虚拟列表的功能。
所以综合来说,对于长列表渲染的问题,使用无限加载+虚拟列表才是优化长列表渲染的终极方案