高性能 Taro 小程序虚拟列表与无限下拉实践
虚拟列表是前端开发中经典的性能优化手段,而在 微信小程序 + Taro 环境中,我们需要结合小程序的运行机制,解决以下实际问题:
- 请求频繁触发:用时间戳控制请求间隔
- 触底白屏:通过 提前加载 替代骨架屏
- 闭包陷阱:用
useRef
保持状态最新 - 动态容器:用
SelectorQuery
自适应高度 - 滚动体验:隐藏滚动条,保持 UI 简洁
这套方案适用于 聊天记录、历史消息、商品列表 等场景,能显著提升渲染性能和用户体验。
一、虚拟列表原理
虚拟列表的核心思想:
只渲染可视区域内的列表元素,而不是整个列表
为什么需要虚拟列表?
- 性能瓶颈:大量 DOM 节点渲染会显著降低页面性能
- 滚动流畅度:减少节点数量,提高滚动响应速度
实现方法
- 计算可视区域:根据容器高度和单项高度,计算当前可见索引范围
- 绝对定位元素 :通过
position: absolute
保持列表整体布局,同时只渲染可视区元素 - 预渲染边界元素 :使用
overscan
防止快速滚动时出现空白
ini
const visibleCount = Math.ceil(containerHeight / itemHeight);
const start = Math.floor(scrollTop / itemHeight) - overscan;
const end = start + visibleCount + overscan * 2;
二、主要解决的问题
在实际工程中,除了基础的虚拟列表逻辑,还要考虑小程序环境的种种限制。我在实现时,针对几个常见痛点做了额外处理:
1. 请求间隔过小的问题
ini
if (onScrollToLower && scrollHeight - viewHeight < 250 && now - lastLoadTimeRef.current > 100) {
onScrollToLower();
lastLoadTimeRef.current = now;
}
- 问题背景 :小程序中
onScroll
事件触发非常频繁,如果每次触发都去请求接口,会出现 瞬时并发请求,浪费资源甚至导致接口异常。 - 解决思路 :用时间戳做请求间隔控制 (
now - lastLoadTimeRef.current > 100
),确保短时间内只触发一次加载。
👉 实际上这是一种 节流(throttle)思路,能有效避免接口雪崩。
2. 提前加载,替代骨架屏
在代码中我使用了:
scrollHeight - viewHeight < 250
- 问题背景:如果等用户滑到底部时才触发加载,会出现短暂的空白区,需要骨架屏填充。
- 解决思路 :通过调大这个阈值(例如
250 → 500
),提前发起请求。这样在用户到达底部之前,新数据就已经准备好,不再需要骨架屏。
👉 核心原理 :用"提前量"来平滑用户体验,本质是 空间换时间。
3. 闭包陷阱问题
ini
const loadingRef = useRef(false);
const pageRef = useRef(1);
const hasMoreRef = useRef(true);
- 问题背景 :在 React/Taro 函数组件里,
useState
的值如果直接在异步函数中使用,可能会被"锁死"在某个旧值(典型闭包陷阱)。 - 解决思路 :使用
useRef
存储这些"需要跨渲染周期保持最新值的状态"(例如:是否正在加载、分页页码、是否还有更多数据)。
4. 动态容器高度
scss
Taro.createSelectorQuery()
.select(".drawer-body")
.boundingClientRect((rect) => {
if (rect) setContainerHeight(rect.height);
})
.exec();
- 通过
SelectorQuery
动态获取容器高度,保证虚拟列表在抽屉、半屏等场景下正常工作。
5. 隐藏滚动条的用户体验
ini
<ScrollView className="hide-scrollbar" />
- 小程序默认滚动条在视觉上比较突兀,隐藏后体验更自然。
三、虚拟列表 + 无限下拉流程图
css
[ScrollView] ──监听onScroll──▶ [Scroll处理模块]
│
▼
[虚拟列表渲染模块]
│
▼
[触发加载更多逻辑]
│
▼
[异步接口请求模块]
│
▼
[列表数据更新模块]
│
└───回到──▶ [虚拟列表渲染模块]
- 用户滑动触发滚动事件
- 判断是否接近底部
- 判断距离上次请求时间间隔
- 满足条件则发起接口请求
- 数据返回后追加到列表并重新渲染
四、待优化方向
- 异步请求合并:避免多次重复请求,可以在滚动结束时批量请求。
- 图片懒加载:减少首屏渲染压力。
- 可变高度支持:适配复杂列表场景。
- 骨架屏/占位优化:虽然提前加载能替代,但在弱网环境下仍可能需要备用骨架屏。
- 性能监控:接入 FPS、内存占用等埋点,做进一步优化。
五、完整示例
ini
import React, { useState, useRef, useCallback, useEffect } from "react";
import { ScrollView, View } from "@tarojs/components";
interface VirtualListProps<T> {
list: T[];
height: number; // 容器高度
itemHeight: number; // 每项固定高度
overscan?: number; // 预渲染数量
renderItem: (item: T, index: number) => React.ReactNode;
scrollTop: number;
onScroll: (scrollTop: number) => void;
onScrollToLower?: () => void;
}
function clamp(num: number, min: number, max: number) {
return Math.max(min, Math.min(num, max));
}
export default function VirtualList<T>({
list,
height,
itemHeight,
overscan = 3,
renderItem,
scrollTop,
onScroll,
onScrollToLower,
}: VirtualListProps<T>) {
const [range, setRange] = useState({ start: 0, end: 10 });
const ticking = useRef(false);
const lastLoadTimeRef = useRef(0);
// 计算可视区范围
useEffect(() => {
const visibleCount = Math.ceil(height / itemHeight);
const newStart = clamp(
Math.floor(scrollTop / itemHeight) - overscan,
0,
list.length - 1
);
const newEnd = clamp(
newStart + visibleCount + overscan * 2 - 1,
0,
list.length - 1
);
setRange({ start: newStart, end: newEnd });
}, [scrollTop, list.length, height, itemHeight, overscan]);
const handleScroll = useCallback(
(e) => {
const top = e.detail.scrollTop;
const scrollHeight = e.detail.scrollHeight;
const viewHeight = top + height;
const now = Date.now();
// 滑到底触发加载更多
if (
onScrollToLower &&
scrollHeight - viewHeight < 250 &&
now - lastLoadTimeRef.current > 100
) {
// console.log(now - lastLoadTimeRef.current,'now')
onScrollToLower();
lastLoadTimeRef.current = now;
}
if (!ticking.current) {
ticking.current = true;
requestAnimationFrame(() => {
onScroll(top);
ticking.current = false;
});
}
},
[height, onScroll, onScrollToLower]
);
const items: React.ReactNode[] = [];
for (let i = range.start; i <= range.end; i++) {
const item = list[i];
if (item !== undefined) {
items.push(
<View
key={i}
style={{
position: "absolute",
top: `${i * itemHeight}px`,
height: `${itemHeight}px`,
left: 0,
right: 0,
}}
>
{renderItem(item, i)}
</View>
);
}
}
return (
<ScrollView
scrollY
scrollTop={scrollTop}
style={{ height: `${height}px` }}
onScroll={handleScroll}
className="hide-scrollbar"
>
<View
style={{
height: `${list.length * itemHeight}px`,
position: "relative",
}}
>
{items}
</View>
</ScrollView>
);
}
function List() {
const [list, setList] = useState<string[]>([]);
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(500);
const loadingRef = useRef(false);
const pageRef = useRef(1);
const hasMoreRef = useRef(true);
const pageSize = 50;
const itemHeight = 86;
// 获取父元素高度
useEffect(() => {
const query = Taro.createSelectorQuery();
query
.select(".drawer-body")
.boundingClientRect((rect) => {
if (rect) {
setContainerHeight(rect.height);
}
})
.exec();
}, []);
// 初次加载
useEffect(() => {
loadMore();
}, []);
// 加载更多
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMoreRef.current) return;
loadingRef.current = true;
const data = await getTitleList({
size: pageSize,
last_seq_id: pageRef.current,
});
if (!data.has_more) {
hasMoreRef.current = false;
} else {
setList((prev) => [...prev, ...data.sessions]); // 追加数据
pageRef.current = pageRef.current + 1;
}
loadingRef.current = false;
}, []);
// const debouncedLoadMore = useCallback(
// debounce(() => {
// loadMore();
// }, 50), // 等待 50ms 后才触发 感觉不是很有作用
// []
// );
const handleScroll = (top: number) => {
setScrollTop(top);
};
const longTap = (session_id) => {
Taro.showActionSheet({
itemList: ["删除会话"],
success() {
delChat({ session_id }).then(() => {
let newHis = [...list];
newHis = newHis.filter((item, i) => item.session_id !== session_id);
setList([...newHis]);
});
},
});
};
const url =
"https://img-ys011.didistatic.com/static/picplace_app_imgs/meijia_beijing.JPG";
const RenderItem = (item, index) => (
<View
className="history-item"
hover-class="btn-hover"
hover-start-time="80"
hover-stay-time="0"
hover-stop-propagation="true"
onClick={() => {
smartNavigateTo(`/pages/chat/index?session_id=${item.session_id}`);
}}
onLongPress={() => {
longTap(item.session_id);
}}
>
<View
className="history-item-img"
style={{
background: `url(${url}) no-repeat center/contain`,
}}
></View>
<View className="history-item-title">{item.title}</View>
<View className="history-item-date">{formatDate(item.updated_at)}</View>
</View>
);
return (
<>
<VirtualList
list={list}
height={containerHeight}
itemHeight={itemHeight}
scrollTop={scrollTop}
onScroll={handleScroll}
onScrollToLower={loadMore}
renderItem={RenderItem}
/>
</>
);
}