uniapp虚拟列表手写实战:打造高性能无限滚动列表组件

2026-07-28 0 342

在做社区信息流时,用户滑到上千条内容后页面明显掉帧,滚动时甚至能感觉到延迟。产品要求列表必须支持图文混排和高度不固定的卡片,传统的scroll-view一次性渲染几百条DOM节点直接撑爆内存。后来参照一些源码自己实现了一套虚拟列表,把同时存在的DOM节点控制在20个以内,滑动体验才回到丝滑状态。

uniapp虽然组件市场里有现成的虚拟列表插件,但直接引进来有时并不契合业务样式,而且出了问题不好排查。不如自己写一个,核心代码不到一百五十行,理解了以后还能随意扩展。这篇文章把写这个组件的全过程梳理出来,从原理到动态高度处理,最后给一个可以直接在页面里用的完整例子。

虚拟列表到底干了什么

长列表性能瓶颈的根源在于DOM节点太多。列表里有一千条数据,页面就要创建一千个节点,每个节点都占用内存和布局计算资源。小程序环境里这个消耗更明显,因为渲染层和逻辑层分离,频繁的setData还会带来序列化开销。

虚拟列表的思路很直接:只渲染当前看得见的那一小部分数据。假设屏幕能显示10条,那就只创建10个DOM节点。当用户滚动时,根据滚动位置计算出应该显示哪些数据,然后复用已有的节点,把新的数据填充进去,同时调整节点的位置来模拟长列表效果。

具体来说需要做三件事:监听滚动事件获取滚动距离;根据滚动距离和每项高度计算“当前该显示哪几条数据”;把这几条数据渲染出来,并用一个占位容器撑出总高度,让滚动条看起来像是完整列表。核心变量只有三个——visibleData(当前可见的数据片段)、startIndex(起始索引)、totalHeight(总占位高度)。

固定高度的起步版本

先从一个最简单的版本开始:列表每一项高度固定为100px。这样计算起始索引只需要用scrollTop除以100取整。代码量很少,但足以展示虚拟列表的工作流。

在uniapp的pages目录下新建一个页面,模板结构如下:

<template>
    <scroll-view 
        scroll-y 
        @scroll="onScroll" 
        :style="{ height: '100vh' }"
    >
        <!-- 占位容器,撑出总高度 -->
        <view :style="{ height: totalHeight + 'px', position: 'relative' }">
            <!-- 可见区域容器,通过transform偏移 -->
            <view :style="{ transform: `translateY(${offsetY}px)` }">
                <view 
                    v-for="(item, index) in visibleData" 
                    :key="item.id" 
                    style="height: 100px; border-bottom: 1px solid #eee;"
                >
                    {{ item.text }}
                </view>
            </view>
        </view>
    </scroll-view>
</template>

这里外层用scroll-view作为滚动容器,设定一个固定高度(通常是页面高度)。里面套一个占位view,高度设置为totalHeight——也就是所有数据的总高度。再里面的view通过transform: translateY向下偏移,确保可见的那几条数据刚好出现在屏幕正确的位置上。

脚本部分的逻辑:

export default {
    data() {
        return {
            allData: [],       // 全部数据
            visibleData: [],   // 当前可见数据
            startIndex: 0,     // 第一个可见项的索引
            offsetY: 0,        // 偏移量
            totalHeight: 0,    // 总占位高度
            itemHeight: 100,   // 固定高度
            visibleCount: 0,   // 一屏能显示的条数
        };
    },
    mounted() {
        this.allData = this.generateData(1000); // 生成1000条模拟数据
        const sysInfo = uni.getSystemInfoSync();
        this.visibleCount = Math.ceil(sysInfo.windowHeight / this.itemHeight) + 2; // 多缓存2条
        this.totalHeight = this.allData.length * this.itemHeight;
        this.updateVisibleData(0);
    },
    methods: {
        generateData(count) {
            const list = [];
            for (let i = 0; i < count; i++) {
                list.push({ id: i, text: `这是第 ${i + 1} 条数据` });
            }
            return list;
        },
        onScroll(e) {
            const scrollTop = e.detail.scrollTop;
            this.updateVisibleData(scrollTop);
        },
        updateVisibleData(scrollTop) {
            // 计算起始索引
            const start = Math.floor(scrollTop / this.itemHeight);
            this.startIndex = Math.max(0, start);
            // 截取可见数据
            this.visibleData = this.allData.slice(
                this.startIndex,
                this.startIndex + this.visibleCount
            );
            // 偏移量使得第一条可见数据正好在滚动位置的顶部
            this.offsetY = this.startIndex * this.itemHeight;
        }
    }
};

这个版本跑起来,不管allData里有一千条还是一万条,页面实际渲染的DOM始终只有visibleCount个(大概十二三个)。滚动时根据scrollTop实时更新可见数据,配合偏移量让元素位置看起来自然。

但固定高度的限制太强了。真实的业务里,列表项高度往往因为图片尺寸、文字长度、内边距等因素各不相同。接下来就要解决动态高度的问题。

动态高度的核心难点

高度不固定时,不能再用scrollTop / itemHeight来推算当前可见的起点。因为每项高度都不一样,你需要知道每一项的具体高度才能累加出总高度,也才能算出滚动到某个位置时对应到第几条数据。

解决方式是在数据里提前计算好每一项的高度,或者渲染后通过DOM获取真实高度并缓存下来。后者更灵活,也更能适应运营动态内容。实现思路是这样的:给每项数据绑一个预估高度(比如先猜一个150px),然后当这一项被渲染出来之后,通过uni.createSelectorQuery获取它在屏幕上的真实高度,存到一个高度缓存对象里。下一次滚动计算时,就使用缓存里的实际高度来定位。

但是,在渲染后才获取高度意味着:你的列表初始状态是基于预估高度计算的,存在一些位置偏差。随着用户滚动,越来越多的项被实际渲染,缓存越来越全,定位就越来越精准。这个“渐进式精准”的过程在实际使用中几乎无感知,因为偏差通常只有几十像素,肉眼很难察觉。

完整动态高度虚拟列表组件

下面是一个可以直接放到components目录下使用的虚拟列表组件,支持动态高度、无限滚动,并且通过scroll-tolower事件触发数据追加。

// components/VirtualList/VirtualList.vue
<template>
    <scroll-view 
        scroll-y 
        @scroll="handleScroll" 
        @scrolltolower="$emit('loadMore')"
        :style="{ height: height }"
    >
        <view :style="{ height: totalHeight + 'px', position: 'relative' }">
            <view :style="{ transform: `translateY(${offsetY}px)` }">
                <view 
                    v-for="(item, idx) in visibleData" 
                    :key="item._key" 
                    :ref="'item-' + (startIndex + idx)"
                >
                    <slot :item="item" :index="startIndex + idx"></slot>
                </view>
            </view>
        </view>
    </scroll-view>
</template>

<script>
export default {
    props: {
        // 数据源
        items: { type: Array, default: () => [] },
        // 容器高度,如 '100vh' 或 '600px'
        height: { type: String, default: '100vh' },
        // 预估每项高度(像素)
        estimateHeight: { type: Number, default: 120 },
        // 缓冲数量(上下各多渲染几项)
        bufferCount: { type: Number, default: 3 },
    },
    emits: ['loadMore'],
    data() {
        return {
            visibleData: [],
            startIndex: 0,
            offsetY: 0,
            totalHeight: 0,
            heightCache: {}, // key: index, value: 测量后的高度
        };
    },
    watch: {
        items: {
            handler(newVal) {
                this.updateTotalHeight();
                this.updateVisibleData(this.currentScrollTop || 0);
            },
            immediate: true,
        },
    },
    methods: {
        // 更新总高度(基于缓存中已知高度和预估高度)
        updateTotalHeight() {
            let total = 0;
            for (let i = 0; i < this.items.length; i++) {
                total += this.heightCache[i] || this.estimateHeight;
            }
            this.totalHeight = total;
        },
        // 通过滚动位置找到起始索引
        getStartIndex(scrollTop) {
            if (scrollTop <= 0) return 0;
            let accumulated = 0;
            for (let i = 0; i < this.items.length; i++) {
                const h = this.heightCache[i] || this.estimateHeight;
                accumulated += h;
                if (accumulated > scrollTop) {
                    return i;
                }
            }
            return this.items.length - 1;
        },
        // 更新可见数据
        updateVisibleData(scrollTop) {
            if (!this.items.length) return;
            const start = this.getStartIndex(scrollTop);
            const buffer = this.bufferCount;
            const startIndex = Math.max(0, start - buffer);
            this.startIndex = startIndex;
            
            // 计算需要显示的条数:从startIndex起,逐项累加高度直到填满一屏+缓冲
            let covered = 0;
            let endIndex = startIndex;
            const containerHeight = this.getContainerHeight();
            while (covered < containerHeight + this.estimateHeight * buffer && endIndex < this.items.length) {
                covered += this.heightCache[endIndex] || this.estimateHeight;
                endIndex++;
            }
            // 截取可见数据
            this.visibleData = this.items.slice(startIndex, endIndex).map((item, i) => {
                // 保证每项有唯一的key
                return { ...item, _key: item.id || (startIndex + i) };
            });
            
            // 计算偏移量:startIndex之前所有项的高度之和
            let offset = 0;
            for (let i = 0; i < startIndex; i++) {
                offset += this.heightCache[i] || this.estimateHeight;
            }
            this.offsetY = offset;
            
            // 在下一帧测量刚刚渲染的节点的实际高度
            this.$nextTick(() => {
                this.measureHeights(startIndex, this.visibleData.length);
            });
        },
        getContainerHeight() {
            // 简单处理,从props取数字,或使用固定值
            const match = this.height.match(/(d+)/);
            return match ? parseInt(match[1]) : 600;
        },
        // 测量实际高度并更新缓存,同时重新计算总高度和偏移
        measureHeights(fromIndex, count) {
            const queries = [];
            for (let i = 0; i < count; i++) {
                const index = fromIndex + i;
                if (this.heightCache[index] !== undefined) continue; // 已缓存则跳过
                queries.push(this.getNodeHeight(index));
            }
            Promise.all(queries).then(() => {
                this.updateTotalHeight();
                // 重新修正偏移量,因为预估高度可能不准
                let offset = 0;
                for (let i = 0; i < this.startIndex; i++) {
                    offset += this.heightCache[i] || this.estimateHeight;
                }
                this.offsetY = offset;
            });
        },
        getNodeHeight(index) {
            return new Promise((resolve) => {
                const query = uni.createSelectorQuery().in(this);
                query.select(`#item-${index}`).boundingClientRect((rect) => {
                    if (rect && rect.height > 0) {
                        this.heightCache[index] = rect.height;
                    }
                    resolve();
                }).exec();
            });
        },
        handleScroll(e) {
            const scrollTop = e.detail.scrollTop;
            this.currentScrollTop = scrollTop;
            this.updateVisibleData(scrollTop);
        },
    },
};
</script>

组件接收items数据源,通过插槽让父页面自定义每一项的展示内容。核心计算放在getStartIndex里,根据缓存高度遍历累加找到滚动位置对应的索引。measureHeights则负责在渲染后使用createSelectorQuery获取真实高度,更新缓存后重新计算总高度和偏移量,消除预估误差。

使用时,父页面只需要引入组件并传入数据:

<template>
    <VirtualList 
        :items="feedList" 
        height="100vh" 
        estimateHeight="200"
        @loadMore="fetchMore"
    >
        <template v-slot:default="{ item, index }">
            <view style="padding: 20px; border-bottom: 1px solid #f0f0f0;">
                <image :src="item.avatar" style="width: 40px; height: 40px; border-radius: 50%;"></image>
                <text>{{ item.nickname }}</text>
                <view>{{ item.content }}</view>
                <image v-if="item.image" :src="item.image" mode="widthFix" style="width: 100%;"></image>
            </view>
        </template>
    </VirtualList>
</template>

<script>
import VirtualList from '@/components/VirtualList/VirtualList.vue';
export default {
    components: { VirtualList },
    data() {
        return {
            feedList: [],
            page: 1,
        };
    },
    methods: {
        loadData(page) {
            // 模拟接口请求
            return new Promise((resolve) => {
                setTimeout(() => {
                    const list = [];
                    for (let i = 0; i < 20; i++) {
                        list.push({
                            id: (page - 1) * 20 + i,
                            avatar: 'https://picsum.photos/100/100',
                            nickname: `用户${Math.random().toString(36).substr(2, 6)}`,
                            content: `这是第${page}页第${i}条动态,内容长度随机。`,
                            image: i % 3 === 0 ? 'https://picsum.photos/600/300' : '',
                        });
                    }
                    resolve(list);
                }, 500);
            });
        },
        async fetchMore() {
            this.page++;
            const newData = await this.loadData(this.page);
            this.feedList = [...this.feedList, ...newData];
        },
    },
    async mounted() {
        this.feedList = await this.loadData(1);
    },
};
</script>

这样,一个完整的动态高度无限滚动信息流就跑起来了。无论数据加到多少条,屏幕上始终只维持十几个DOM节点,性能表现稳定。加上@loadMore事件,在滚动到底部时自动加载下一页,体验和原生列表几乎一样。

优化细节与坑点

几个在实践中踩过的点值得单独提一下。

高度缓存策略。 只缓存已经测量到的项。对于从未渲染过的项,使用预估高度。预估高度设置得越接近真实平均值,初始时的布局晃动就越小。通常取一个略大于大部分项的高度值可以让内容不至于被截断。

ref的名称需要精确对应。measureHeights中我们使用#item-${index}选择器,前提是在模板里给每个可见项设置了对应的id。这里要注意uni.createSelectorQuery在小程序端对动态ref的支持限制,确保选择器能拿到节点。

scroll-view的throttle。 多个平台对scroll事件的触发频率不一致,有些平台可能每秒触发几十次。直接在事件回调里截取数据、操作DOM可能会造成卡顿。建议在回调里做一个简单的节流,比如每16ms(一帧)更新一次。上面代码没有加节流,但在滚动不频繁时影响不大,可以根据实际平台情况自行补上。

数据更新时的处理。items发生变化(比如内容增删)时,高度缓存可能需要部分失效。如果只是追加新数据,原有缓存依然有效,只需要重新计算总高度和可见区域即可。如果中间插入或删除数据,缓存的索引会错位,此时最好清空缓存重新测量。这种场景较少见,可以根据业务权衡是否处理。

可以扩展的方向

这个组件作为基础版本能满足大部分长列表场景,但可以根据需求进一步强化:加上下拉刷新功能,在scroll-view外层套一个refresh容器即可;结合骨架屏,在数据加载时展示占位卡片;对于图片密集的列表,可以加入懒加载,只加载进入可视区的图片。这些扩展都不影响虚拟列表本身的逻辑,各司其职。

手写虚拟列表的过程,本质上是在处理“位置计算”和“DOM复用”两个核心问题。一旦这两块搞清楚了,后续无论用什么框架,遇到长列表优化都可以迅速反应出解决思路。

uniapp虚拟列表手写实战:打造高性能无限滚动列表组件
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uniapp虚拟列表手写实战:打造高性能无限滚动列表组件 https://www.taomawang.com/web/uniapp/2435.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务