前端虚拟列表——uniapp小程序实战实现(手搓版)

前言:

  1. 对于成千上万条数据,思维定式肯定是分页。分页获取数据防止一次获取的数据量过大。这是第一个层次。但忽略了一个问题:获取数据的时候压力是变小了,那渲染呢?难道渲染成千上万个DOM吗?渲染压力肯定会非常大。在加上实际开发中肯定不只是像Demo那样简单的渲染个1、2、3、4。像笔者这里,就需要渲染每一项的名称以及图片。而且也试过了,在微信开发者工具的模拟器上压根跑不起来,直接罢工不显示了
  2. 为什么要自己实现?不是有现成的三方组件吗?你以为我不想吗?我试过使用导入vue-virtual-scroller,但是uniapp用不了(至少我遇到的情况是明明装了包,但是运行到模拟器时或说找不到这个包)。我想这可能是要导入最新的包,但是npm install --save vue-virtual-scroller@next只适合在Vue3中,偏偏遇到的项目是Vue2的。没办法,只能自己手搓一个
  3. 这篇文章总结了这两天在实现过程中所踩的坑。可实现一行显示一个,也可以实现一行实现多个(技术背景:uniapp+Vue2+wx小程序)

虚拟列表的原理:

  1. 首先是相对定位与绝对定位的使用(子绝父相)

    最外层的盒子使用相对定位,内层需要一个可视区域的盒子 + 一个占位使用的盒子,占位盒子是由所有要渲染的信息形成的(当然不是真的把所有信息都放在这个占位盒子中,这样干的话就违背了虚拟列表的本意;此处要表达的是占位盒子的高度是所有信息的高度总和),可视区域的盒子高度固定(多少你随意,但也不要太随意,后续讲),需要使用绝对定位,通过在用户滚动过程中改变可视区域盒子的top值,从而模拟滚动的效果

  2. 滚动效果是模拟出来了,那内容渲染呢?

    这里使用到了数组的截取。假设所有内容是一个ArrAll[100],而视图区域要渲染的内容是一个ArrTemp[10],最开始时截取ArrAll的前十项放入ArrTemp进行显示,后续根据用户的滚动距离截取其他项放入ArrTemp中进行替换显示,配合前面的top定位,就能实现滚动改变内容的效果。而可视区域内的DOM始终是那么多个(假设是12个吧),在滚动过程中,内容始终都在这12DOM来回切换,有原先的成千上万个DOM变成现在的12个,大大减少了要渲染的DOM的数量,极大提高了渲染效率

一行显示一项:

开发中踩的坑:

原理看着是挺简单,甚至自己写个Demo,感觉没啥可说的,但是到了实际需求开发中,漏洞百出

容器的选择:

由于笔者开发使用的是uni-app,所以就使用了自带的scroll-view作为虚拟列表的容器。刚好这个容器还自带了scrollToLower事件,后续可以在这里补充请求下一页的逻辑

如果是在PC端开发,div也具备滚动等功能,只不过uni-app中的view不具备这个功能,但刚刚好有个scroll-view

滚动时的处理逻辑:

通过监听scroll-viewscroll滚动事件,实时拿到用户滚动的距离,再拿着这个距离去更新视图区域的top值,以及更新要渲染的内容截取的起点与终点

vue 复制代码
	<scroll-view scroll-y class="viewport" @scroll="handleScroll" :style="{ height: 8 * itemHeight + 'vw' }" @scrolltolower="onScrollToLower">
      <!-- 占位盒子 -->
      <view class="placeholder" :style="{ height: totalHeight + 'vw' }"></view>
      <!-- 可视区域 -->
			<view class="list" :style="{ top: topValue + 'vw' }">
        <!-- 渲染的每一项 -->
				<view v-for="(item, index) in showData" :key="item.id" class="item" :style="{ height: itemHeight + 'vw' }">
					xxx渲染的内容xxx
				</view>
			</view>
  </scroll-view>

	computed: {
		// 计算出要渲染的数据(每次截取几项进行渲染)
		showData() {
			return this.articleList.slice(this.startIndex, this.endIndex)
		}
	},
  methods:{
    handleScroll(e) {
  			// 单位px
  			const { scrollTop } = e.detail
        this.startIndex = Math.floor(scrollTop / this.itemHeight)
  			this.endIndex = this.startIndex + this.count
  			this.topValue = scrollTop
  	},
  }

那如果你直接这么粗暴的干,会有两个问题:

  1. 滚动时页面一直闪屏(当然,也可能不会,如果你只是简单的Demo渲染1、2、3、4之类的,我是因为要渲染图片,所以闪屏很明显)
  2. 滚动到某一处后往回拉,当回拉回到scroll-view的顶部时,屏幕会一整个闪一下后再重新渲染最开始的内容
减少滚动时的闪屏:
  1. 第一个滚动时闪屏是由于滚动是实时发生的,对于小部分的滚动,完全没必要实时更新,可以给加上一个偏移量
vue 复制代码
    const startOffset = scrollTop - (scrollTop % this.itemHeight)
    this.topValue = startOffset
  1. 对于第二个回滚到最顶部会一整个屏幕空白后再重新渲染是因为那一秒滚动到顶部的动作的scrollTop值是负的,所以只需要做一个特判
vue 复制代码
  handleScroll(e) {
    const { scrollTop } = e.detail
    if( scrollTop < 0) return 
    xxx后续操作xxx
  }

高度错位问题:

这里的高度错位是指拉到最底部时占位盒子和可视区域盒子在同一水平高度上。高度错位问题可以说是虚拟列表的核心和灵魂,一旦高度错位了,虚拟列表就会出现bug。会出什么bug呢?大致情况就是明明提示你已经滚动到最底部了,但是你发现还可以接着往下滚动获取内容。这里可以看一张图:

可以看到,滚动到最底部的时候消息提示我没有更多数据了,但是我再往上拉还是有数据的。审查元素,此时发现占位盒子的底部已经跑到上面去了,正常应该是滚动到底部时,占位盒子的底部应该和视图区域的底部在同一水平线上。大致就是下面的效果: 只要你发现滚动到底部看似到底部了,但还能接着往下滚的时候多半就是错位了

错位问题,在一行显示一项的虚拟列表中比较不会容易出错,但是在一行显示多个的时候就要小心,这个可以看后续

如何减少错位问题?

约定每一项的高度都用itemHeight来表示,消息总条数是total,那么占位盒子高度totalHeight = itemHeight * total,把虚拟列表高度也和itemHeight关联起来!!!比如你可以让viewHeight = itemHeight * 8

vue 复制代码
		<scroll-view scroll-y class="viewport" @scroll="handleScroll" :style="{ height: 8 * itemHeight + 'vw' }" @scrolltolower="onScrollToLower">
			<!-- 占位盒子 -->
      <view class="placeholder" :style="{ height: totalHeight + 'vw' }"></view>
      <!-- 可视区域 -->
			<view class="list" :style="{ top: topValue + 'vw' }">
        <!-- 渲染的每一项 -->
				<view v-for="(item, index) in showData" :key="item.id" class="item" :style="{ height: itemHeight + 'vw' }">
					xxx渲染的内容xxx
				</view>
			</view>
		</scroll-view>
造成错位问题原因:
  1. 不要使用margin,众所周知,margin是外边距,这就导致了你明明设置好了viewHeight,但由于margin把它撑大了,这就导致滚动到底部时发生错位。如果你觉得每一项都黏在一起不好,可以改成padding,使用内边距,这样一来,viewHeight就还是那个viewHeight(像前面所展示的错位图片就是由于margin引起的)
  2. 如果你是一行展示一个的开发场景,基本上注意这个问题就不会发生错位了。那如果你是一行显示多个的情况,可以接着往下看

其他优化:

  1. 由于笔者所遇到的开发场景是需要渲染每一项的图片的,所以可以加一个图片懒加载。但是,u-image默认就自带了懒加载功能,所以可以省去这一步hh
  2. 模拟提前加载数据

前面假设虚拟列表的高度等于8 * itemHeight,但你每一次准备的数据可以占多一点点(比如说每一页返回的数据可以为12条),这样多出的四条就会先放置在下面,当用户下拉时就能模拟实现数据预加载的效果

  1. 当滚动到底部加载下一页时,可以给个loading啊,防抖啊之类的

一行显示多项:

按照上面的思路,只要不踩高度错位的坑,基本很快就能实现虚拟列表的效果。但是,当我交过去预览效果的时候,要求实现一行显示多个的效果,因为一行显示一个,滑倒明年去才滑得完。好,有道理,我改!

可头痛死我了,首当其冲是高度错位问题。随之而来的是一行显示多个时,每一项的尺寸问题。我如何保证既能一行显示多个,又保证占位盒子高度和视图区域高度不错位

移动端适配:

一开始一行显示多个想到的是组件库自带的LayOut布局容器,比如说我让它一行显示3个,就可以:

vue 复制代码
<u-row gutter="16">
  <u-col span="4">
    <view class="demo-layout bg-purple-light"></view>
  </u-col>
</u-row>

但是随之而来另一个问题,水平方向上我是保证了一行显示多个,可以我怎么知道占位盒子高度和虚拟列表区域高度呢?因为高度是虚拟列表实现的核心点,所以获取高度是没得跑的 参照前面的思路,原先在一行显示一项那里,占位盒子高度和虚拟列表高度都是和itemHeight关联起来的。如果我还坚持使用组件的LayOut布局,我为了保证占位盒子高度和虚拟列表高度和itemHeight关联起来,我就得知道组件的LayOut布局给了每一项的宽高是多少。机型不同的情况下,LayOut布局会动态给每一项动态的宽高。难道在每个机型都获取一下LayOut布局给每个item的尺寸吗? 你往深一点想,LayOut布局动态分配宽高是如何实现的? 答案是移动端适配 !!! 所以完全可以在页面进入的时候获取屏幕宽度(因为我这里用的是vw),然后设置itemHeight(因为项目中每个item都是正方形,所以设置itemHeight也行,设置itemWeight也行,反正最后的widthheight属性都会设置成一样的) itemHeight设置成多少完全取决于你的需求,如果你想一行显示三个,就可以:

vue 复制代码
		<scroll-view scroll-y class="viewport" ref="viewport" @scroll="handleScroll" :style="{ height: 8 * itemHeight + 'vw' }" @scrolltolower="onScrollToLower">
			<view class="placeholder" :style="{ height: totalHeight + 'vw' }"></view>
			<view class="list" :style="{ top: topValue + 'vw' }">
				<view v-for="(item, index) in showData" :key="item.id" class="item" :style="{ height: itemHeight + 'vw' }">
          xxx具体渲染内容xxx
        </view>
			</view>
		</scroll-view>

  onLoad() {
		// 获取屏幕信息(单位px)
		const {
			screenWidth,
			screenHeight,
			safeAreaInsets: { top, bottom }
		} = uni.getSystemInfoSync()
    // 保存屏幕高度
		this.screenWidth = screenWidth
		this.bottom = bottom
		// 移动端适配(单位vw),每个itemHeight占屏幕宽度的1/3,然后再进行换算,换算成以vw为单位
		this.itemHeight = ((screenWidth / 3) * 100) / screenWidth
		// 获取数据列表
		this.getList()
	},

  // 获取数据列表
  async getList() {
			const res = await 网络请求...
			this.articleList = res
			// 占位盒子的高度(已适配),由于itemHeight在前面已是vw为单位,所以这里算出来的totalHeight也是以vw为单位
      // 行数:原先有n项,就有n行;但现在一行显示3个,就得除以3.记得向上取整(很好理解嘛,行数不能是小数)
			this.totalHeight = this.itemHeight * Math.ceil(this.articleList.length / 3)
		},
  • 在模板中,无论是设置高度还是动态设置top值,记得把单位换成vw
  • 到了这一步,解决了不管在什么机型下都能一行显示三个的效果,但是此时你会发现每一项都是黏在一起的,因为它们没有间距。如果想加间距,还是老问题,用padding而不是margin!!!

样式参考代码(具体的可以结合实际需求,但是定位和内边距是核心):

vue 复制代码
.viewport {
  width: 100%;
  position: relative;
  .list {
    width: 100%;
    height: auto;
    position: absolute;
    top: 0;
    left: 0;
    display: flex;
    justify-content: start;
    flex-wrap: wrap;

    .item {
      padding: 10rpx;
      border-top: 1px solid #c2c2c2;
    }
  }
}

开发中踩的坑:

有关屏幕滚动时闪屏等问题解决方案和前面"一行显示一个"是一样的,此处不赘述

想到移动端适配的方案,只是第一步。最头痛的仍然是高度错位问题,这一步一不小心有个地方忘了同步更改基本就会出bug 仍是假设一行显示3个!!!

滚动事件的逻辑处理:

事件参数虽然能拿到距离顶部的滚动距离,但是单位是px,所以统一做换算

vue 复制代码
  // 滚动时触发
  handleScroll(e) {
    // 单位px
    let { scrollTop } = e.detail
    // 单位vw
    scrollTop = (scrollTop * 100) / this.screenWidth
    // 放置上拉至最顶端的闪屏效果
    if (scrollTop < 0) return
    this.startIndex = Math.floor(scrollTop / this.itemHeight)
    this.endIndex = this.startIndex + this.count
    //顶部偏移量(单位vw)
    const startOffset = scrollTop - (scrollTop % this.itemHeight)
    // 移动端适配(单位vw)
    this.topValue = startOffset
  },

看着没什么问题,但是最坑的点就在startIndex这里

回顾一下,startIndex是干嘛的?是用于截取显示的数据的起点下标

现在是一行显示3个,截取的起点在滚动时就不能只是简单粗暴的直接做除法得到了。想象一下,之前一行显示一个的时候,滚动一行的高度,截取的起点会变成加一,ok,没问题;但是现在一行显示3个了,仍然滚动一行的高度,截取的起点还能只是加一吗,显然是不行的,所以应该是:

vue 复制代码
<!-- 一行显示3个在滚动时截取起点下标的变化 -->
this.startIndex = Math.floor(scrollTop / this.itemHeight) * 3
this.endIndex = this.startIndex + this.count

分页加载的逻辑:

常见加载逻辑没什么好讲的,数据追加,页数加一,判断是否有更多数据...在scrollToLower事件中,再次执行一遍获取数据的逻辑即可(如果有更多数据的话) 但如果你考虑过多的话,可能会想,在获取下一页数据的时候,占位盒子的高度是不是应该再加上这一页的高度,那么占位盒子高度应该这样写(看第16行):

vue 复制代码
		async getList() {
			const { data, totalPageNum } = await this.$u.api.getTabByKeyword({
				type: 0,
				page: xxx,
				pageSize: xxx 
			})
			// 页数+1
			this.pageNum++
			// 总页数
			this.total = totalPageNum
			// 是否有更多数据
			this.hasMore = this.page <= totalPageNum
			// 当前渲染的数据(数据追加)
			this.articleList = [...this.articleList, ...data]
			// 当前占位盒子的总高度(高度追加)
			this.totalHeight = this.itemHeight + this.itemHeight * Math.ceil(this.articleList.length / 4)
		},

如果你真是这样想的,那么恭喜,和我一样踩坑了。仔细看代码的执行顺序,在更新totalHeight之前,就已经做过articleList的数据追加了,所以此时articleList.length也会增加,所以应该是(看16行):

vue 复制代码
		async getList() {
			const { data, totalPageNum } = await this.$u.api.getTabByKeyword({
				type: 0,
				page: xxx,
				pageSize: xxx 
			})
			// 页数+1
			this.pageNum++
			// 总页数
			this.total = totalPageNum
			// 是否有更多数据
			this.hasMore = this.page <= totalPageNum
			// 当前渲染的数据(数据追加)
			this.articleList = [...this.articleList, ...data]
			// 当前占位盒子的总高度(高度追加)注意,此处的articleList已经是追加之后的,所以直接这样写就是追加后的高度
			this.totalHeight = this.itemHeight * Math.ceil(this.articleList.length / 3)
		},

其他优化:

  1. 模拟数据预加载

其实思路和之前一样,就是返回的数据偏多一点,也就是pageSize偏大一点。这边建议pageSize在设置的时候设置成每行显示个数的倍数(比如一行3个,pageSize可以设置成15、18...)这么做的原因:主要是服务于Math.ceil(this.articleList.length / 3)这一步的计算,可以让this.articleList.length / 3算出来是整数,不会造成totalHeight的偏差; 对于最后一页可能返回的数据的数量不一定有pageSize这么多,但也无关紧要了,因为它是最后一页,对于totalHeight造成的偏差并不大。当然如果恰好是pageSize这么多那肯定是最好的,毕竟刚刚好

  1. 为了减少用户等待焦虑,可以在获取下一页时添加loading等等
  2. 分页加载可以添加防抖。但我想着既然在获取下一页时添加了uni.ShowLoading具有蒙层效果,就没必要加防抖了

其他说明:

分页的必要性

不要想着把后端返回的几千上万条数据一次性存下来,该分页还是别偷懒。当然,不是说一次性存下来不可以,但是会有警告:

像笔者这里,后台返回的5k+条数据尝试一次性存下来时会有警告:性能问题。emmm,你搞虚拟列表就是为了解决性能问题的,现在发现在存数据这里就有一个性能问题肯定是忍不了的,所以,还是乖乖分页吧 这是其次,在渲染时也会有明显的延迟(以我的项目需求为例,需要渲染图片),在打开这个项目、接口请求之后,页面是这样的: 在接口已经返回的前提下,大概要等个两秒左右图片才陆陆续续渲染出来。这还只是5k的情况下,保不齐随着数据量的增加,延迟时间也会延长,明显就是影响用户体验的玩意儿,所以,再次强调,要分页!!!

高度为什么不能错位?

因为scrollToLower事件触发时也会触发scroll事件(即滚动到底部事件触发也会触发滚动事件),这一点是可以自行验证的。这就导致了如果高度一旦错位(如下图这种情况),就会一直执行scrollToLower事件和scroll事件,scroll事件内在实时更新偏移量,就导致页面反复横跳

思维转变:

在前天刷到虚拟列表的文章之前,笔者对大量数据的渲染还只是停留在分页获取的层面,一直认为几千几万条数据后端给我做好分页就行了。但是这考虑的浅了,在前言 部分也说了,分页只能保证获取数据的性能变好了,但不能保证渲染时性能也没问题,你总不能直接放几千几万个DOM上去吧,几百个应该是可以的

算是改变了原先的一个思维定式吧,hh,偶尔在掘金刷一刷,收获还挺大

如何实现滚动时能够切换渲染内容但是DOM还是那么多个DOM的效果?

由滚动事件、top动态更新、startIndexendIndex四者配合实现

在滚动过程中,其实无法实现真正意义上的滚动到底部

什么意思? 假如现在占位盒子有1600px这么高

滚动到所谓的底部时,由于内容有高度,所以scrollTop并不会等于1600px

如图,这里只取到了995px

scrollTop指的是滚动过程中距离顶部的距离!!!

完整代码:

这里是一行显示4个

vue 复制代码
<template>
	<view class="container">
    <!-- 其他内容 -->
    <!-- 虚拟列表区域 -->
		<scroll-view scroll-y class="viewport" ref="viewport" @scroll="handleScroll" :style="{ height: 7 * itemHeight + 'vw' }" @scrolltolower="onScrollToLower">
			<view class="placeholder" :style="{ height: totalHeight + 'vw' }"></view>
			<view class="list" :style="{ top: topValue + 'vw' }">
				<view
					v-for="(item, index) in showData"
					:key="item.id"
					class="item"
					:style="{ height: itemHeight + 'vw', width: itemHeight + 'vw' }"
					@click="goDetail(xxx)"
				>
				</view>
			</view>
		</scroll-view>

	</view>
</template>

<script>
export default {
	data() {
		return {
			screenWidth: 0, // 屏幕宽度(单位px)
			screenHeight: 0, // 屏幕高度(单位px)
			list: [], // 数据列表
			total: 0, // 列表数据的总条数
			itemHeight: 0, // 每一项的高度
			startIndex: 0, // 开始的下标
			endIndex: 32, // 结束的下标
			totalHeight: 0, // 虚拟列表中的占位总高度
			topValue: 0, // 可视区域距离scroll-view的顶部偏移量
			count: 32, // 可视区每次渲染的条数
			test: 0, // 测试数据
			bottom: 0, // 底部安全距离
			page: process.env.NODE_ENV === 'development' ? 200 : 0, // 开发环境下页数从200开始,方便测试没有更多数据的情况
			hasMore: true // 是否有更多数据
		}
	},
	computed: {
		// 计算出要渲染的数据(每次截取几项进行渲染)
		showData() {
			return this.list.slice(this.startIndex, this.endIndex)
		}
	},
	onLoad() {
		// 获取屏幕信息(单位px)
		const {
			screenWidth,
			screenHeight,
			safeAreaInsets: { top, bottom }
		} = uni.getSystemInfoSync()
		// 暂存屏幕宽度,后续适配使用
		this.screenWidth = screenWidth
		// 移动端适配(单位vw),每一项的高度、宽度大小为屏幕宽度的1/4
		this.itemHeight = ((screenWidth / 4) * 100) / screenWidth
		// 获取数据列表
		this.getList()
	},
	methods: {
		async getList() {
			const { data, totalPage } = await 网络请求({
				type: 0,
				page: this.page,
				pageSize: 60 // 每页条数最好是一行显示个数的倍数(这样除去最后一页,其他每页返回的数据所形成的占位盒子高度都能保证是整数)
			})
			// 页数+1
			this.page++
			// 总页数
			this.total = totalPage
			// 是否有更多数据
			this.hasMore = this.page <= totalPage
			// 当前渲染的数据(数据追加)
			this.list = [...this.list, ...data.rows]
			// 当前占位盒子的总高度(高度追加)注意,此处的list已经是追加之后的,所以直接这样写就是追加后的高度
			this.totalHeight = this.itemHeight * Math.ceil(this.list.length / 4)
			// this.totalHeight = this.itemHeight * Math.ceil(3000 / 4)
		},
		// 滚动时触发
		handleScroll(e) {
			// 单位px
			let { scrollTop } = e.detail
			// 单位vw
			scrollTop = (scrollTop * 100) / this.screenWidth
			// 放置上拉至最顶端的闪屏效果
			if (scrollTop < 0) return
			// 记得*4,之前一行显示一个的时候,Math.floor(scrollTop / this.itemHeight)没问题
			// 但是现在一行显示4个,滚动原先一样的距离,它的起始点应该*4
			this.startIndex = Math.floor(scrollTop / this.itemHeight) * 4
			this.endIndex = this.startIndex + this.count
			//顶部偏移量(单位vw)
			const startOffset = scrollTop - (scrollTop % this.itemHeight)
			// 移动端适配(单位vw)
			this.topValue = startOffset
		},
		// 滚动到底部时触发(滚动到底部会同时触发前面的滚动事件,所以,一定要到底部时占位盒子和最后一项在同一高度上,不然会一直触发scrollToLower和scroll)
		// 所以,在设置item的时候,不要设置margin
		onScrollToLower() {

			if (!this.hasMore) {
				return uni.showToast({
					icon: 'none',
					title: '没有更多记录了'
				})
			}

			uni.showLoading({
				title: '请求中...'
			})

			setTimeout(() => {
				uni.hideLoading()
			}, 500)
			this.getList()
		},
		goDetail(xxx) {
			xxx
		}
	}
}
</script>

后谈:

由于项目问题,所以演示效果就不给大家放上来了

折磨了两天,这个需求总算完成了,这篇文章,算是个回顾吧。打代码就是这样的,兴致来了就一股脑研究,研究出来了所获得的成就感或许是考个高绩点也无法给予的。当然研究不出来就寄hh

不好说这股热情还能延续多久,但至少打代码是门艺术,代码可控且纯粹,相比之下你又发现比其他东西简单...

欢迎大家交流指正!!!

相关推荐
safe0307 分钟前
SPA和SSR
开发语言·前端·javascript·汇编·vue.js·react.js·前端框架
二川bro11 分钟前
“前端兼容——CSS篇”(进阶版)
前端·css
GISer_Jing1 小时前
CSS常见适配布局方式
前端·css·html
llrraa20104 小时前
Chrome异步编程
前端·chrome
follycat5 小时前
Litctf-web
android·linux·前端·网络安全
BLUcoding7 小时前
React03 组件 & Props
前端·javascript·react.js
ZhaiMou7 小时前
Zustand介绍与使用 React状态管理工具
前端·javascript·react.js·前端框架·html·ecmascript·reactjs
静小谢9 小时前
flex常用固定搭配
前端·css·css3
_风中无我。9 小时前
【前端路由模式 之 Vue Router 中的两种路由模式】----个人理解(通俗)版
前端·javascript·vue.js
爱莉希雅&&&10 小时前
web前端边框详解,弹性盒子的使用(仿写购物网页)
前端·css·css3