从微信小程序长列表优化到虚拟列表

843 阅读2分钟

前言

本篇主要介绍小程序列表的基础使用,再到到小程序长列表的优化分段加载,再到最后的虚拟列表,一步步展开,从列表到优化,也许能带来一些收获

基础使用与问题

列表 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 一多不用说了吧😂

案例demo

虚拟列表实现中存在的问题

虚拟列表一定会存在一个问题,就是无法更好优化自适应的高度的情况,可以参考其他平台高效的复用布局,基本上都是要自己设置宽高的,否则布局就会错乱,问题在哪里呢?

指定高度布局的情况

复用布局算是优化布局比较细的一种实现方式,在计算一个屏幕占用多少个 item 的问题上,开发者给了指定的高度后,可以很轻松提前计算出当前屏幕以及屏幕外的item情况,也能更好调节屏幕内外的 item 复用以及更新问题

自适应布局的情况

使用自适应后,无法提前计算好实际 dom 占用的高度,因此就会出现需要渲染完毕后,才能获取实际高度,这样界面上到底应该出现多少个 item 则无法以此准确预估出来,那么界面一次初始化多少item呢,不同情况天差地别,一次创建多太多,每次渲染性能开销变大,一次太少只能不停等待渲染完毕后新增,即使中立平衡,可能无可避免会造成一些不好的性能问题,那么最好的解决方案就是学习其他平台,让用户给定高度(等高、变高,提前计算好给视图)

因此,目前虚拟列表使用的是指定高度布局情况

ps:可以看出虚拟列表方案更趋向于设定数据预驱动ui变化,默认的自适应则是 dom 自身根据内容变化

虚拟列表的具体实现方案

由于小程序 setData 数量过大就会卡顿(仅仅传参也会),似乎不太需要这种方案,其分次渲染看着更好一些,既使用上面的局部渲染来实现,因此就没必要引入虚拟列表了,除非要节约dom的内存占用(大可不必),但那也需要将虚拟计算的和讯逻辑放到 modal 中,实际用着也挺难受的

下面就以 react 为例,我们实现虚拟列表方案

先看下图,我们就以下图为例讲解虚拟列表

image.png

实现整体逻辑

  • 虚拟列表主要渲染用户看到的那一屏,其他的内容忽略,利用占位元素忽略
  • 为了保证用户滑动时出现白屏等,为了更好体验,一般会预渲染上下各一屏的内容(假设蓝色的是预渲染,黄色是屏幕显示内容),可以调节预渲染的内容
  • 除了预渲染和渲染的位置的地方,使用空白占位的方式,避免使用过多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 添加监听,当滑动到屏幕的时候根据条件触发上拉、下拉后续逻辑即可,这里就不多介绍了

最后

本篇主要是介绍一些巧妙的实现方式(并不是很深入),也方便学习时间,有些场景有更好的解决方案,这里暂时就不多讨论了