上拉加载 VS 虚拟列表? uni-app长列表性能优化实战


theme: condensed-night-purple
highlight: a11y-light

上拉加载 or 虚拟列表

在项目开发中经常会遇到渲染后端返回1k+以上数据的长列表的业务需求,大多数情况下首选方案是通过后端返回分页字段进行分页加载优化:

如下图实现网易☁️歌单列表无限加载:

Nov-26-2022 21-00-44.gif

然而某些情况下,后端必须一次性返回全部数据,如果将这些数据同时渲染到页面,会出现非常明显的等待时间⌛️,数据量越大等待时间就越长,显然对于用户体验非常不友好,那么这种情况可以考虑通过虚拟列表来进行列表优化:

这是一个1000+首歌左右的返回每首歌全部数据的网易云歌单数据列表,小带宽服务器光是返回接口全部数据就花了12s左右😨,如果再等待全部图片加载完毕会造成更长的等待时间和大量的流量消耗😅

截屏2022-11-27 上午9.39.55.png

遇到这种情况作为前端首先要和后端进行沟通,比如本接口的Size高达1.7MB,其中返回的大多数字段在数据列表渲染中其实都是非必要字段,优化冗余字段并进行数据压缩可以大幅度加快接口响应速度⚡️⚡️⚡️

截屏2022-11-27 上午9.35.55.png

接下来考虑数据渲染问题,显然一次性将所有数据同时渲染显示是不切实际的,那么思考下🤔,将数据进行切割后根据页面滚动高度分批进行渲染每次只加载可视区域内的数据是否可以加快页面加载过程?

显然这个方案是可行的✌️,这就是接下来虚拟列表的实现思路🎉

下图为虚拟列表+图片懒加载优化后的1000+长列表,获取到接口数据的同时可视区内数据就已经渲染完毕,快速滑动加载新数据也不会出现明显加载延迟:

Nov-26-2022 21-19-53.gif

实现一个简单的虚拟列表

⚠️本文实现的虚拟列表能够正常运行的前提是列表项高度相同

思路🤔

截屏2022-11-27 上午10.51.41.png

  • 获取起始和结束索引,起始索引Math.floor(scrollTop / itemHeight),结束索引startIdx + showNum
  • 从原生数据中截取可视区域数据list.slice(startIdx, endIdx)
  • 计算偏移量offset = scrollTop - (scrollTop % itemHeight)
  • 监听scroll事件,获取到scrollTop并实时计算可视区域高度
  • 注意可视区域高度应略大于列表组件高度,撑出滚动条,但不应设置过大造成加载数据过多

实现✍️

demo中通过document.getElementsByClassName获取scrollTop
uni-app项目应使用e.detail.scrollTop获取scrollTop

虚拟列表Demo

Nov-27-2022 10-28-32.gif

这里实现的虚拟列表是列表项固定高度,想要实现动态高度列表项可以参考这篇文章👇
「前端进阶」高性能渲染十万条数据(虚拟列表)

优化(骨架屏)

虽然虚拟列表可以优化页面渲染速度,但接口一次性返回大量数据难免会出现较长等待时间,可以考虑使用骨架屏优化用户等待体验:

Nov-27-2022 12-31-13.gif

实现上拉加载

除了特殊情况外,大多数情况下长列表优化首选上拉加载,根据后端返回的分页字段对数据进行分页处理

如下图根据more字段判断是否请求下一页

截屏2022-11-27 上午11.01.29.png

思路🤔

一般实现分页逻辑需要定义一个pageData对象,记录页面分页数据配置项📝,每次发送请求时传递pageData对应信息

// PageData大致数据类型,可根据需求拓展
interface PageData {
    init: boolean,  // 初始化
    loading: boolean,  // loading
    more: boolean,  // 是否有下一页
    limit: number,  // 单次分页返回数据条数
    before?: number,  // 分页参数,取list最后一条数据updateTime
    page?: number,  // 页数
    ...
}
  • 判断是否已经是最后一页?防止重复请求空数据
  • 判断当前是否正处于loading状态,防止频繁请求接口
  • 请求接口返回分页数据
  • 判断是否为第一页
  • 生成或更新数据列表list
  • 更新pageData状态
  • 根据返回分页字段判断是否有下一页

接下来对页面滑动高度进行监听,uni-app通过onReachBottom监听上拉触底事件,触底更新分页数据

实现✍️

uni-app(伪代码)

import { ref, reactive } from 'vue';

interface List {
    id: number,
    name: string,
    picUrl: string,
    ...
}

interface PageData {
    init: boolean,
    loading: boolean,
    more: boolean,
    limit: number,
    before: number,
}

const list = ref<List[]>([]);
const pageData = reactive<PageData>({   // 分页数据
    init: false,
    loading: false,
    more: true,
    limit: 10,
    before: 0,
})

const getData = async () => {  // 获取列表数据
    if (pageData.more === false) {
        uni.showToast({
            icon: "none",
            title: "没有更多了"
        })
        return
    } else if (pageData.loading === true && list.value) {
        uni.showToast({
            icon: "none",
            title: "请勿频繁触发加载"
        })
        return
    }
    else {
        pageData.loading = true;
        const { list, lasttime, more } = await getDataList({
            limit: pageData.limit,
            before: pageData.before
            ...
        })
        if (pageData.before <= 0) {
            list.value = list;
        } else {
            list.value.push(...list);
        }
        pageData.init = true;
        pageData.loading = false;
        pageData.before = lasttime;
        pageData.more = more;
    }
}

...

const onReachBottom = throttle(() => {  // 上拉触底 节流防止频繁调用接口
    // console.log('到底了')
    getPlayListData()
}, 1000)

getPlayListData()

优化(Hooks)

由于上拉加载列表是一个移动端非常常见的需求,几乎每个列表组件都会用到,每写一个列表都要复制一遍这段代码,因此抽离涉及上拉加载的相关逻辑便可以节省很多重复代码。下面通过Vue3Hooks对相关逻辑进行抽离复用

首先考虑需要暴露出来的参数,比较重要的有4个:

  • listReq 必传 HTTP异步请求函数
  • listStr 必传 接口返回的列表字段并不总是res.list,进行动态字段匹配
  • pageData 必传 分页数据配置项
  • data 可选 额外传入参数 {key: value}形式
// 列表加载Hooks useList
import { ref } from 'vue';
interface PageData {
    init: boolean,
    loading: boolean,
    more: boolean,
    limit: number,
    before?: number,
    page?: number,
}

const useList = (listReq: Function, listStr: string, pageData: PageData, data?:Object):any => {
    const list = ref<any>([]);
    if(!listReq) {
        return new Error('请传入接口调用方法!')
    }else if(!listStr){
        return new Error('请传入接口返回列表字段!')
    }else if(!pageData) {
        return new Error('请传入分页数据配置项!')
    }
    if(data && Object.prototype.toString.call(data) !== '[object Object]') {
        return new Error('额外参数请以对象形式传入')
    }
    const params = {...data}    // 获取携带参数
    const getData = () => {
        if (pageData.more === false) {
            uni.showToast({
                icon: "none",
                title: "没有更多了"
            })
            return
        } else if (pageData.loading === true && list.value) {
            uni.showToast({
                icon: "none",
                title: "请勿频繁触发加载"
            })
            return
        }else{
            pageData.loading = true;
            listReq(params).then((res:any) => {
                const lasttime = res.lasttime
                const more = res.more
                // 判断是否是第一页
                if (pageData.before! <= 0 || pageData.page === 1) {
                    list.value = res[listStr];
                } else {
                    list.value.push(...res[listStr]);
                }
                pageData.init = true;
                pageData.loading = false;
                pageData.before = lasttime;
                pageData.more = more;
            })
        }
    }
    getData()   // 初始化获取接口数据

    return {
        list,
        getData
    }
}

export default useList

使用(伪代码)

import { ref, reactive, computed } from 'vue';

interface PageData {
    init: boolean,
    loading: boolean,
    more: boolean,
    limit: number,
    page: number,
}

const id = ref<number>(0)  // ID
const pageData = reactive<PageData>({
    init: false,
    loading: false,
    more: true,
    limit: 10,
    page: 1,
})
const offset = computed(() => {  // offset
    return (pageData.page - 1) * pageData.limit
})

const getListData = async(params:{id: number, offset: number}) => {
    try{
        const { list, more } = await getUserList(
            params.id,
            pageData.limit,
            params.offset
        )
        return { list, more }
    }catch(e){
        console.log(e)
    }
}
const { list } = useList(getUserList, 'useList', pageData, {offset: offset.value, id: id.value})

动态获取列表高度

实际开发中,列表组件通常不是单独使用而是结合其他组件一起使用:

截屏2022-11-27 下午12.43.19.png

为了美观列表组件高度应该恰好等于屏幕剩余高度,具体实现可以参考之前写的这篇文章👇:

uni-app 微信小程序通过Vue3 Hooks 实现动态填充页面剩余高度

总结

完整的使用场景和源码

接口数据一次性返回 ➡️ 虚拟列表 + 图片懒加载 + 骨架屏
接口数据分页返回 ➡️ 上拉加载 + Hooks

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容