你有没有遇到过这种情况:在一个电商小程序里浏览商品列表,滑到三四十条的时候,手指开始感觉不跟手,再往下滑手机开始发烫,甚至小程序直接闪退。
这不是手机的问题,是列表渲染方式的问题。今天聊聊当数据量变大时,小程序列表为什么会卡,以及如何解决。
小程序中展示列表最直接的方式是这样的:
Page({ data: { list: [] // 假设这里有1000条数据 }, onLoad() { wx.request({ success: (res) => { this.setData({ list: res.data }) // 一次性设置1000条 } }) } })
对应的WXML:
<view wx:for="{{list}}" wx:key="index"> <view class="item">{{item.title}}</view> </view>
这段代码看似简单,实际上存在两个性能问题。
第一个问题:setData传输的数据量过大。小程序逻辑层和渲染层是分开的两个线程,每次setData都会把数据通过序列化传输给渲染层。1000条完整数据可能达到几百KB甚至几MB,传输时间很长。
第二个问题:渲染层同时创建大量DOM节点。1000个列表项意味着1000个节点,每个节点都有布局、样式、事件绑定。浏览器或者小程序的渲染引擎处理这么多节点,内存占用和重绘成本都很高。
最简单有效的优化是不要一次加载全部数据。
实现方式:
Page({ data: { list: [], page: 1, hasMore: true }, onLoad() { this.loadMore() }, loadMore() { if (!this.data.hasMore) return wx.request({ url: 'https://api.example.com/list', data: { page: this.data.page, pageSize: 20 }, success: (res) => { const newList = this.data.list.concat(res.data.list) this.setData({ list: newList, page: this.data.page + 1, hasMore: res.data.hasMore }) } }) }, onReachBottom() { this.loadMore() // 滚动到底部时加载下一页 } })
用户先看到前20条,滑到底部自动加载下一批。首屏渲染速度明显提升,单次setData的数据量也控制住了。
但分页加载并没有解决另一个问题:当用户滑到第10页时,页面中仍然存在200个节点。这时候滑动还是会逐渐变卡。
虚拟列表的核心思路是:只渲染屏幕可见区域的那几条数据,以及上下各少量缓冲项。用户滑动时,动态替换可见区域的数据。
假设屏幕高度可容纳8个列表项,虚拟列表实际只渲染12个(可见8个加上下各2个缓冲)。用户滚动时,计算当前滚动位置对应的数据索引,更新这12个项的数据。
实现方式有两种。
第一种是使用官方提供的recycle-view组件。微信小程序从基础库2.8.1开始提供了长列表组件。
在页面的JSON中声明:
{ "usingComponents": { "recycle-view": "/miniprogram_npm/miniprogram-recycle-view/recycle-view", "recycle-item": "/miniprogram_npm/miniprogram-recycle-view/recycle-item" } }
WXML中使用:
<recycle-view batch="{{batchSetRecycleData}}" bindscrolltolower="loadMore"> <recycle-item wx:for="{{recycleList}}" wx:key="id"> <view class="item">{{item.title}}</view> </recycle-item> </recycle-view>
recycle-view会自动管理节点创建和回收,开发者只需要提供数据即可。但需要注意这个组件有一定的学习成本,而且自定义能力有限。
第二种是自己实现虚拟列表。计算逻辑如下:
// 简化版虚拟列表逻辑 const ITEM_HEIGHT = 100 // 每个列表项固定高度(px) const BUFFER_COUNT = 4 // 缓冲数量 let screenHeight = 0 // 屏幕高度 let startIndex = 0 // 当前显示的起始索引 function onScroll(scrollTop) { // 计算当前应该显示从哪条开始 startIndex = Math.floor(scrollTop / ITEM_HEIGHT) startIndex = Math.max(0, startIndex - BUFFER_COUNT) const visibleCount = Math.ceil(screenHeight / ITEM_HEIGHT) + 2 * BUFFER_COUNT const visibleData = originalList.slice(startIndex, startIndex + visibleCount) this.setData({ visibleList: visibleData, startIndex: startIndex, topPadding: startIndex * ITEM_HEIGHT // 用空的占位撑开滚动区域 }) }
这种方式效果很好,但需要列表项高度固定。如果列表项高度不固定,计算会复杂很多。
长列表里的图片是另一个性能杀手。即使用户没滑到图片位置,图片资源也会被请求和渲染。
解决方案是使用image组件的lazy-load属性:
<image src="{{item.imageUrl}}" lazy-load mode="aspectFill"></image>
设置lazy-load后,图片只在即将进入可视区域时才开始加载。这能大幅减少网络请求和内存占用。
对于更精细的控制,可以自己实现懒加载:监听滚动位置,只对当前可视区域前后一定范围内的图片设置真实的src,其余图片用占位图。
微信小程序提供了增强版的scroll-view组件,开启后会使用自定义模式处理滚动,性能更好。
配置方式:
<scroll-view scroll-y enhanced show-scrollbar="{{false}}" fast-deceleration="{{true}}" bindscrolltolower="loadMore"> <!-- 列表内容 --> </scroll-view>
其中enhanced属性开启增强模式,fast-deceleration让滚动更接近iOS原生体验。配合分页加载,很多中小型列表场景就不需要上虚拟列表了。
根据实际的列表规模选择合适的方案:
列表少于50条:分页加载就够了,不需要额外优化。首屏加载20条,滑动加载下一批。
列表在50到200条:分页加载加上图片懒加载,开启scroll-view的enhanced模式。这种组合下体验已经相当流畅。
列表超过200条:需要用虚拟列表。优先尝试官方的recycle-view组件,不能满足时再自己实现。
列表包含复杂子组件:如果每个列表项内部还有自己的图片轮播、视频播放、表单输入等复杂组件,虚拟列表的效果会打折扣。这种情况下应该考虑从根本上减少单页数据量,优化交互设计。比如分页控制在每页10条,引导用户按分类筛选而不是无限滚动。
无论用哪种方案,wx:key都要正确设置。
<!-- 错误:使用index作为key,数据顺序变化时会有问题 --> <view wx:for="{{list}}" wx:key="index"> <!-- 正确:使用数据中唯一的id --> <view wx:for="{{list}}" wx:key="id">
正确的wx:key能帮助小程序框架高效地重用和重排现有节点,避免不必要的重新渲染。
优化的效果需要用数据说话。在开发者工具中打开调试器的Performance面板,录制滚动操作,查看帧率。真机上可以使用性能面板的“Trace”功能导出详细数据。
一个简单的自我验证方法:在优化前后分别滚动到列表的第100条,感受手指离开屏幕后页面是否还继续滑动一段距离。卡顿的列表滚动生硬,优化的列表滚动有惯性。