移动端的列表页加载,一般是下拉加载更多,当翻页较多时,页面渲染节点太多,就会造成页面卡顿。需要对长列表进行优化,达到无限下拉。在微信小程序的扩展组件有一个recycle-view实现的长列表,支付宝没有长列表组件,需要我们自己开发。

实现原理

长列表渲染节点过多造成卡顿。

实际上我们只需要渲染可视区域即可,对视口以外的部分,可以用padding或者一个指定高度的view来合并代替。监听页面滚动,修改渲染部分。这样不管我们怎么滚动,页面中渲染的节点数量是固定的。实际上考虑到用户感受,前后页面也进行渲染。

此时的问题就是计算了。列表还是那个列表,我们要根据scrollTop计算从哪个到哪个元素显示,计算顶部和底部高度。另外,列表的元素高度可能不固定,要传入函数动态计算。

组件设计

模板我们设计成这样,通过具名slot传入元素。外部传入list用于计算,同时通过recycle-item-{{index}}的slot来传入列表元素渲染。通过控制渲染的index,来控制渲染部分,同时通过pt和pb的值控制上下空间。

微信的recycle-view还有一个recycle-item组件,这就造成了一个问题,为了在渲染时保持recycle-view和recycle-item的同步,又造了一个公用的对象来实现手动触发渲染。这里我们就在一个组件渲染,不用单独处理。

另外设计一个header和footer的slot,用于在渲染项前后紧接一些东西。

1
2
3
4
5
6
7
8
9
10
11
<scroll-view scroll-y class="{{className}}" style="{{style}}" trap-scroll upper-threshold="{{upperThreshold}}" lower-threshold="{{lowerThreshold}}" onScroll="onScroll" onScrollToUpper="onScrollToUpper" onScrollToLower="onScrollToLower" catchTouchMove="{{stopPropagation}}">
<view style="width: 100%; height: {{pt}}px;"></view>
<slot name="recycle-header"></slot>
<block a:for="{{list}}" a:key="{{index}}">
<view class="recycle-item-wrapper" a:if="{{index >= startIndex && index <= endIndex}}">
<slot name="recycle-item-{{index}}"></slot>
</view>
</block>
<slot name="recycle-footer"></slot>
<view style="width: 100%; height: {{pb}}px;"></view>
</scroll-view>

页面初始化时,startIndex和endIndex就是list的第一个和最后一个。

监听onScroll事件,对list遍历,计算每个元素的高度,计算起始位置并渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import {
rpx2px,
} from '../../utils/util'
const app = getApp()
const THROTTLE_TIME = 500
const BEFORE_PAGE = 3
const AFTER_PAGE = 3

Component({
data: {
pt: 0,
pb: 0,
},
props: {
list: [],
height: 0,
onItemHeight: 0,
className: '',
style: '',
upperThreshold: 100,
lowerThreshold: 100,
onScroll: null,
onScrollToUpper: null,
onScrollToLower: null,
stopPropagation: true,
},
didMount () {
this.init()
},
didUpdate (prevProps) {
if (this.props.list !== prevProps.list || this.props.list.length !== prevProps.list.length) {
if (!prevProps.list || prevProps.list.length === 0) {
this.init()
}
}
},
didUnmount () {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
},
methods: {
init () {
// 初始化,刚开始加载数量少,直接把startIndex和endIndex设置为数组的首尾索引。
const { systemInfo } = app.globalData
const screenHeight = systemInfo.screenHeight
const list = this.props.list
this.scrollTop = 0
this.lastScrollTop = 0
this.timer = null
this.screenHeight = screenHeight
let totalHeight = 0
for (let i = 0; i < list.length; i++) {
totalHeight += this.getItemHeight(list[i], i)
}
this.totalHeight = totalHeight
this.setData({
pt: 0,
pb: 0,
startIndex: 0,
endIndex: list.length - 1
})
},
render (e) {
// 计算组件高度,设置pt,pb,startIndex,endIndex
// throttle
},
getItemHeight (item, index) {
// 计算元素高度
const h = this.props.onItemHeight
switch (typeof h) {
case 'number':
case 'string':
return rpx2px(h)
case 'function':
return rpx2px(h(item, index))
}
},
onScroll (e) {
// 监听滚动事件,渲染
this.render(e)
this.props.onScroll && this.props.onScroll(e)
},
onScrollToUpper () {
this.props.onScrollToUpper && this.props.onScrollToUpper()
},
onScrollToLower () {
this.props.onScrollToLower && this.props.onScrollToLower()
},
},
})

渲染计算

从头开始计算组件高度,跟scrollTop以及屏幕高度对比,将scrollTop的位置当做当前屏幕顶部,scrollTop + screenHeight当做屏幕底部。然后一项项比对高度即可。

scroll设置节流。为了滚动时减少白屏,可以向前和向后多渲染几个屏幕高度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
render (e) {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
setTimeout(() => {
const lastScrollTop = this.scrollTop
const scrollTop = e.detail.scrollTop
if (Math.abs(scrollTop - lastScrollTop) < 50) return false

const screenHeight = this.screenHeight
const list = this.props.list
let top
let bottom
let startIndex
let endIndex
let totalHeight

top = 0
bottom = 0
totalHeight = 0
for (let i = 0; i < list.length; i++) {
const ih = this.getItemHeight(list[i], i)
if (startIndex == null) {
if (totalHeight < scrollTop - BEFORE_PAGE * screenHeight) {
top += ih
totalHeight += ih
} else {
startIndex = i
totalHeight += ih
}
} else if (endIndex == null) {
if (totalHeight < scrollTop + screenHeight + screenHeight * AFTER_PAGE) {
totalHeight += ih
} else {
endIndex = i - 1
bottom += ih
}
} else {
bottom += ih
}
}
if (endIndex == null) {
endIndex = list.length - 1
}

this.scrollTop = scrollTop
this.totalHeight = totalHeight
this.setData({
pt: top,
pb: bottom,
startIndex,
endIndex,
})
}, THROTTLE_TIME)
},

效果

可以看到,下拉或上拉时,页面渲染的元素数量超过一定数量就不再增加,而是在上方和下方改变一个view的高度。

问题优化

  1. 快速滑动时会出现白屏现象

因为渲染元素有限,快速滑动时,滑动速度超过计算和渲染速度,导致看到上方和下方白色的view。暂时没有什么好的办法,只能增加向前和向后渲染的屏幕数量。

  1. 优化计算

上拉或下拉时,重新计算了整个数组。其实可以缓存上次计算的结果,判断是向上还是向下滑动,去向前或向后继续计算。但是触发计算时,不一定移动了整数个元素高度,这样计算可能会造成累加误差。后续考虑从这个思路继续优化。

  1. 顶部和底部元素渲染

其实一般列表的顶部和底部是用于下拉的图标和加载中的图标。这些内容应该是在scroll-view外部的,这样进入页面的时候,定位到元素的第一个,而不是header部分。这个可以通过套一层scroll-view来实现,当内部滚动到边界,继续滚动触发外部scroll-view。然后外部scroll-view在touchend时再回弹。

这里我的下拉刷新是页面刷新,上拉加载显示的是骨架屏,所以放在scroll-view内部即可。骨架屏放到footer里。

  1. 元素高度不确定

这里其实元素是可以展开的,展开时高度未知。这里我就忽略了展开的情况。不过因为向前向后多渲染了几屏,而展开项最多一个也不会太长,就直接忽略了。

实际上这里是可以拿到展开项的索引,然后获取页面节点高度,异步更新计算值。