前言
本篇主要介绍小程序列表的基础使用,再到到小程序长列表的优化分段加载,再到最后的虚拟列表,一步步展开,从列表到优化,也许能带来一些收获
基础使用与问题
列表 list 基本使用,当碰到长列表的时候,setData 都可能会失败报错(限制大小),可以通过一次性减少对象中的属性数量做分页,但终归治标不治本,只能前面数据比较少的时候有用,多了也会出现问题
基础使用如下所示,适合以处理最新消息为主的列表,不时刷新列表页场景
//.js
loadMore() {
const list = []
for (let idx = 1; idx <= 20; idx++) {
list.push({
name: '信息' + idx
})
}
this.setData({
list: this.data.list.concat(list)
})
}
//.wxml
<view wx:for="{{list}}" wx:key="index" class="flex-col">
<text class="item-view">内容:{{item.name}}</text>
</view>
局部渲染解决长列表问题
解决上述长列表问题,可以采用更新局部状态的方式来解决,可以应对大多数问题,缺点就是逻辑稍微多一些,但也有限
微信小程序中 setData 可以使用 [key] 的方式来渲染局部内容,而不更新其他没有变动的内容
this.setData({
[key]: value
})
利用局部渲染特性,应用到数组指定节点,可以优化长列表,而列表dom本身状态复用这个是微信本身优化好的,不需要我们额外考虑
loadMore() {
const list = []
for (let idx = 1; idx <= 20; idx++) {
list.push({
name: '信息' + idx
})
}
const data = this.data.list
const newIndex = data.length
this.setData({
[`list[${newIndex}]`]: list
})
}
//.wxml
<view wx:for="{{list}}" wx:key="index" class="flex-col">
<block wx:for="{{item}}" wx:key="index">
<text class="item-view">内容:{{item.name}}</text>
</block>
</view>
ps:如果不是分页加载,而是一次性加载很多,可以将数据转化为分页加载的情况,每次下滑从原始数据中追加新的数据即可实现类似效果,小程序一次性 setData 数量过多必然会非常卡顿,即使不渲染仍然很卡,所以后面的虚拟列表逻辑他就不能直接用了
虚拟列表
平时写的列表,大概就是前面两种,基础不优化,局部渲染优化
虚拟列表和上面的逻辑完全不一样,虚拟列表逻辑是使用少量的节点,以此仅仅渲染一个屏幕或者多一些的内容(会预渲染屏幕外面一些元素),减少整个屏幕上的 dom 节点,以此为手段来减少(setData)setState 单次渲染的数量, 以达到提升渲染性能的问题
有人会说,无论是小程序、react、vue都有设置 key 的手段优化列表呀,这不是一个东西哈,设置 key 是对于虚拟列表更新方便的,实际该多少 dom 还是多少dom,dom 一多不用说了吧😂
虚拟列表实现中存在的问题
虚拟列表一定会存在一个问题,就是无法更好优化自适应的高度的情况,可以参考其他平台高效的复用布局,基本上都是要自己设置宽高的,否则布局就会错乱,问题在哪里呢?
指定高度布局的情况
复用布局算是优化布局比较细的一种实现方式,在计算一个屏幕占用多少个 item 的问题上,开发者给了指定的高度后,可以很轻松提前计算出当前屏幕以及屏幕外的item情况,也能更好调节屏幕内外的 item 复用以及更新问题
自适应布局的情况
使用自适应后,无法提前计算好实际 dom 占用的高度,因此就会出现需要渲染完毕后,才能获取实际高度,这样界面上到底应该出现多少个 item 则无法以此准确预估出来,那么界面一次初始化多少item呢,不同情况天差地别,一次创建多太多,每次渲染性能开销变大,一次太少只能不停等待渲染完毕后新增,即使中立平衡,可能无可避免会造成一些不好的性能问题,那么最好的解决方案就是学习其他平台,让用户给定高度(等高、变高,提前计算好给视图)
因此,目前虚拟列表使用的是指定高度布局情况
ps:可以看出虚拟列表方案更趋向于设定数据预驱动ui变化,默认的自适应则是 dom 自身根据内容变化
虚拟列表的具体实现方案
由于小程序 setData 数量过大就会卡顿(仅仅传参也会),似乎不太需要这种方案,其分次渲染看着更好一些,既使用上面的局部渲染来实现,因此就没必要引入虚拟列表了,除非要节约dom的内存占用(大可不必),但那也需要将虚拟计算的和讯逻辑放到 modal 中,实际用着也挺难受的
下面就以 react 为例,我们实现虚拟列表方案
先看下图,我们就以下图为例讲解虚拟列表
实现整体逻辑
- 虚拟列表主要渲染用户看到的那一屏,其他的内容忽略,利用占位元素忽略
- 为了保证用户滑动时出现白屏等,为了更好体验,一般会预渲染上下各一屏的内容(假设蓝色的是预渲染,黄色是屏幕显示内容),可以调节预渲染的内容
- 除了预渲染和渲染的位置的地方,使用空白占位的方式,避免使用过多dom节点,影响性能
- 实际顶部放置dom或者设置偏移会造成滚动位置发生改变,回调滚动函数,容易形成回调地狱,直接处理也很不圆滑,本篇通过变换 transfrom 来更新位置不影响位置布局的方式来实现类似效果
- 利用 react 特性对外暴露方法,可以动态更新子节点
- 如果是定高,直接用乘法计算对应位置,如果是动态高度,用户主动传递每个item高度,可以通过预计算 + 对于索引位置通过二分法等查询手段查询实际对应的索引值,本篇不多介绍,只介绍定高
中间节点复用变换实现逻辑
一些原生平台更新复用节点位置的方式是提前设置不同类型 item 放入缓存池,根据 item 类型从缓存池拿,不够就创建,然后铺盘整个屏幕(包括预渲染),当元素离开我们的渲染区域(预渲染),则回收,需要增加渲染元素时则从缓存池拿,每次滑动到指定 item 时,根据 item实时位置更新指定 item 元素显示内容即可
上面那种方式实现起来比较复杂,细节很多,本篇不多介绍,如果要实现也是通过另一篇文章编写,本篇介绍另一种较为简单的实现方式
通过更新中间渲染、预渲染元素(virtualList)元素的方式实现内容更新替换,配合 transform 位移实现错觉复用效果(变换瞬间,中间list整体下移,元素上移,实现无缝移位)
react编写的虚拟列表逻辑
实现逻辑如下所示
import { useEffect, useRef, useState } from "react";
type RecycleViewType<T> = {
list: T[];
scrollHeight: number;
itemHeight: number;
renderNode: (item: T, index: number) => React.ReactNode;
};
export const RecycleView = <T extends Record<string, any>>(
props: RecycleViewType<T>
) => {
const sRef = useRef<HTMLDivElement | null>(null);
const [virtualList, setVirtualList] = useState<any[]>([]);
const [topHeight, setTopHeight] = useState<number>(0);
const [endHeight, setEndHeight] = useState<number>(props.scrollHeight);
const preRadio = useRef<number>(1); //预渲染前后屏百分比,默认上下预渲染一屏幕,屏幕较大内容较多可以设置半屏
const startIndex = useRef<number>(0);
const totalLength = useRef<number>(0);
useEffect(() => {
//保存一下lenght,避免来回计算了
totalLength.current = props.list.length;
onUpdateVirtual(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
//滚动后的更新方法
const onUpdateVirtual = (isForceUpdate = false) => {
const height = props.scrollHeight;
const itemHeight = props.itemHeight;
const scrollTop = sRef.current!.scrollTop;
//查看当前滚动位置,定位到索引元素
let currentIndex = Math.floor(scrollTop / itemHeight);
//优化滚动,避免不必要的计算
if (!isForceUpdate && currentIndex === startIndex.current) return;
startIndex.current = currentIndex;
//计算要渲染的渲染开始、结束位置
const radio = preRadio.current;
let start = Math.floor((scrollTop - height * radio) / itemHeight);
if (start < 0) {
start = 0;
}
const tLength = totalLength.current;
let end = Math.ceil((scrollTop + height + height * radio) / itemHeight);
if (end > tLength) {
end = tLength - 1;
}
//从列表获取内容信息
const newVirtualList: any[] = [];
for (let idx = start; idx <= end; idx++) {
newVirtualList.push(props.list[idx]);
}
//更新结果
setTopHeight(start * itemHeight);
setVirtualList(newVirtualList);
setEndHeight(tLength * itemHeight - (end + 1) * itemHeight);
};
return (
<div
id="scroll-view"
style={{
overflowY: "auto",
width: "auto",
height: props.scrollHeight,
}}
ref={sRef}
onScroll={() => onUpdateVirtual()}
>
<div
style={{
display: "flex",
flexDirection: "column",
width: "auto",
transform: `translateY(${topHeight}px)`,
}}
>
{virtualList.map((item, index) => (
<div key={index} style={{ height: props.itemHeight }}>
{props.renderNode(item, index)}
</div>
))}
{/* //不加上滚动条会显示有问题,甚至有些场景无法继续滚动 */}
<div style={{ height: endHeight }} />
</div>
</div>
);
};
使用如下,效果上没啥问题
<RecycleView
list={record}
scrollHeight={900}
itemHeight={50}
renderNode={(item) => (
<div
style={{
height: 50,
display: "block",
placeContent: "center",
}}
>
<div className="item-view">内容:{item.name}</div>
</div>
)}
/>
ps:对于上拉加载和下拉刷新可以在上下添加两个 dom 元素,通过 IntersectionObserver 添加监听,当滑动到屏幕的时候根据条件触发上拉、下拉后续逻辑即可,这里就不多介绍了
最后
本篇主要是介绍一些巧妙的实现方式(并不是很深入),也方便学习时间,有些场景有更好的解决方案,这里暂时就不多讨论了