无限滚动列表几乎成了内容类小程序的标配。商品流、信息流、评论列表都依赖它来保持浏览节奏。实现无限滚动并不复杂,但要做到滑动不卡、触发不重、内存不涨,确实要考虑加载时机与列表渲染的配合。小程序官方提供了页面级触底回调onReachBottom,很多项目一开始都直接用。但当列表项图片较多、单项高度不一致时,onReachBottom的触发位置并不精确,快速滑动时还可能连续触发导致重复请求。IntersectionObserver提供了一种更细粒度的方案:在列表底部放一个哨兵节点,观察它何时进入视口,再由这个动作驱动下一页加载。这样加载时机可以提前控制,也不依赖页面滚动高度。

为什么建议用IntersectionObserver替代onReachBottom
onReachBottom是页面级触底事件,只有页面滚动到最底部时才会触发。它的实现简单,但存在几个明显问题。第一,触底判断依赖页面总高度,而页面高度会随着图片加载、文字排版动态变化。如果图片尚未加载完成,高度被低估,用户已经看到底部空白但事件没有触发;如果图片加载后高度突然增加,又可能触发多次。第二,快速滚动时触底事件可能会连续到达,如果不在回调里做防抖或加锁,很容易发出重复请求。第三,onReachBottom无法提前触发,用户必须滑到真正的底部才会开始加载,等待感明显。
IntersectionObserver则不是依赖滚动事件的。它由渲染引擎监听目标节点与视口(或指定容器)的相交状态,只有当相交比例或相交位置发生变化时才回调。这样既减少了频繁计算,也能通过设置bottom边距提前触达。比如把观察区域的底部向下扩展120px,当哨兵节点还差120px进入视口时就会触发加载,用户几乎感觉不到等待。
下面是一个最简单的对比,先看onReachBottom的实现:
Page({
onReachBottom() {
// 页面触底事件,容易受到内容高度变化影响
this.loadMore();
}
});
再看基于IntersectionObserver的监听方式,它不关心页面是否真的触底,只关心哨兵节点是否接近视口:
const observer = wx.createIntersectionObserver(this, {
observeAll: false
});
observer.relativeToViewport({
bottom: 120
}).observe('#load-sentinel', (res) => {
if (res.intersectionRatio > 0 && !this.data.loading) {
this.loadMore();
}
});
两种方案在短期项目中差异不大,但当列表变长、图片变多、真机性能吃紧时,基于相交检测的方式会更稳定。
使用IntersectionObserver实现无限滚动列表
接下来把完整流程串起来。先准备列表结构和底部哨兵节点。哨兵节点建议放在列表最后,高度设置得小一些,只负责触发加载,不参与视觉效果。状态提示可以单独放在哨兵下方。
<view class="goods-list">
<view wx:for="{{goodsList}}" wx:key="id" class="goods-card">
<image src="{{item.cover}}" mode="aspectFill" class="goods-img" />
<view class="goods-info">
<text class="goods-title">{{item.title}}</text>
<text class="goods-price">价格:{{item.price}}</text>
</view>
</view>
<view id="load-sentinel" class="load-sentinel"></view>
<view wx:if="{{loading}}" class="status-tip">正在加载...</view>
<view wx:if="{{finished}}" class="status-tip">没有更多了</view>
</view>
在页面逻辑中,需要维护分页参数和加载状态。onReady阶段创建观察器并触发首屏数据,onUnload阶段必须手动断开观察器,避免页面销毁后回调仍持有页面实例。加载函数内部用loading锁阻止并发请求,finished标记避免接口返回空列表后继续空转。
Page({
data: {
goodsList: [],
page: 1,
pageSize: 20,
loading: false,
finished: false
},
onReady() {
this.initSentinelObserver();
this.loadGoods();
},
onUnload() {
this.destroyObserver();
},
initSentinelObserver() {
if (!wx.canIUse('createIntersectionObserver')) {
return;
}
const observer = wx.createIntersectionObserver(this, {
observeAll: false
});
observer.relativeToViewport({
bottom: 120
}).observe('#load-sentinel', (res) => {
if (res.intersectionRatio > 0 && !this.data.loading && !this.data.finished) {
this.loadGoods();
}
});
this.observer = observer;
},
destroyObserver() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
},
async loadGoods() {
if (this.data.loading || this.data.finished) {
return;
}
this.setData({ loading: true });
try {
const { page, pageSize, goodsList } = this.data;
const result = await this.fetchGoods(page, pageSize);
const list = result.list || [];
this.setData({
goodsList: goodsList.concat(list),
page: page + 1,
loading: false,
finished: list.length < pageSize
});
} catch (error) {
this.setData({ loading: false });
wx.showToast({ title: '加载失败,请重试', icon: 'none' });
}
},
fetchGoods(page, pageSize) {
return new Promise((resolve, reject) => {
wx.request({
url: 'https://ipipp.com/api/goods',
data: { page, pageSize },
success: (response) => resolve(response.data),
fail: reject
});
});
}
});
这段代码可以满足大多数内容流场景。首次进入页面时loadGoods会加载第一页,列表渲染后如果内容高度不足以触发哨兵相交,观察器不会反复请求;当用户往下滑,哨兵节点接近视口底部120px时,自动加载下一页,数据合并后页面高度增加,哨兵继续被推远,不会连续触发。
还有一点需要注意:如果列表不是直接放在页面根节点,而是放在scroll-view内部,IntersectionObserver默认相对于视口进行相交检测,可能无法准确判断滚动容器内的可见性。此时应当在relativeTo方法中传入容器选择器,例如observer.relativeTo('#scrollContainer', { bottom: 120 }),这样观察的就是哨兵节点与scroll-view的可视区域之间的相交关系。
性能调优与避坑指南
无限滚动做出来不难,难的是在低端真机上长时间使用仍然稳定。下面从几个容易忽略的细节展开。
第一是控制setData的体积。每次加载更多时,上面示例使用concat合并新数组后再整体setData。这种写法在列表几百条以内通常没有问题,但如果分页size较大或已加载上千条,序列化和传输成本会明显上升。优化方向有两个:一是适当降低pageSize,例如从20改为15或10;二是使用setData的局部路径更新能力,只把新增项追加到数组末尾。不过局部路径追加在动态数组下标上容易出错,需要先计算原数组长度。对于大多数小程序,控制单页数量加上减少无关数据字段已经足够。
第二是防止重复请求和回退兼容。IntersectionObserver回调可能在极短时间内多次触发,因此loading锁必不可少。如果基础库版本过低不支持createIntersectionObserver,需要回退到onReachBottom,并在回退方案中加入时间戳防抖。两个方案不要同时注册,否则会出现双通道触发导致一页数据被重复加载。
onReachBottom() {
if (this.data.loading || this.data.finished) {
return;
}
const now = Date.now();
if (now - (this.lastLoadTime || 0) < 800) {
return;
}
this.lastLoadTime = now;
this.loadGoods();
}
第三是观察器的生命周期管理。如果在页面卸载时没有调用disconnect,观察器可能继续持有页面实例并触发回调,轻则浪费请求,重则引发内存泄漏和页面状态错乱。建议在onUnload中统一销毁。如果页面使用了自定义组件,还需要在组件detached生命周期里处理,避免组件销毁后观察器残留。
第四是加载失败后的状态恢复。catch分支里只设置了loading为false,没有修改finished,这样用户可以继续下拉或让哨兵再次触发加载。失败时也不要把finished置为true,否则用户以为没有更多内容,实际上只是网络抖动。可以进一步增加失败提示与重试按钮,让长列表在弱网环境下体验更稳定。
第五是结合列表项本身的性能优化。无论触发机制多么精准,如果单个列表项渲染成本很高,滑动依然会卡。建议为图片开启lazy-load,为列表项设置固定高度或使用aspectFill模式减少布局抖动,避免在wx:for内部使用过于复杂的嵌套结构。对于数据量特别大的列表,还可以引入虚拟列表思路,只渲染当前视口附近的节点,但这会带来更高的实现复杂度,需要根据场景权衡。
综合来看,IntersectionObserver在微信小程序里实现无限滚动,本质上是用更可靠的相交检测替代页面触底事件,让加载时机更可控、触发判断更精确。配合分页锁、观察器销毁、合理的setData策略和列表项优化,能把长列表的性能表现提升一个量级。如果你的小程序还在使用onReachBottom处理分页加载,不妨从列表末尾的哨兵节点开始改造。
微信小程序IntersectionObserver无限滚动修改时间:2026-10-07 07:24:40