背景
某天我们公司小程序收到线上反馈,在商品列表页面为什么我划着划着划着,就会出现一些重复商品......
在讲这个问题之前,先讲一下我们是如何实现瀑布流组件的
瀑布流组件
什么是瀑布流组件
如图所示下方商品列表就采用了瀑布流的布局,视觉表现为参差不齐的多栏布局。
如何实现一个瀑布流组件
下面简单写一下实现瀑布流的思路,左右两列布局,根据每一列的高度来判断下次插入到哪一列中,每次插入列中需重新计算高度,将下一个节点插入短的哪一列中,如下图所示:
下面代码示例(仅展示思路)
// dataList 就是我们整个的商品卡片列表的数据 ,用户滑动到底部会加载新一页的数据 会再次触发 watch
watch(() => props.dataList ,(newList) => {
dataRender(newList)
},{
immediate: true,
})
const dataRender = async (newList) => {
// 获取左右两边的高度
let leftHeight: number = await getViewHeight('#left')
let rightHeight: number = await getViewHeight('#right')
// 取下一页数据
const tempList = newVal.slice(lastIndex.value, newVal.length)
for await (const item of tempList) {
leftHeight <= rightHeight ? leftDataList.value.push(item) : rightDataList.value.push(item); //判断两边高度,来决定添加到那边
// 渲染dom
await nextTick();
// 获取dom渲染后的 左右两边的高度
leftHeight = await getViewHeight('#left')
rightHeight = await getViewHeight('#right')
}
lastIndex.value = newList.length
}
<template>
<view>
<view id="left">xxxx</view>
<view id="right">xxxx</view>
</view>
</template>
当用户滚动到底部的时候会加载下一页的数据,dataList 会发生变化,组件会监听到 dataList 的变化来执行 dataRender,dataRender 中会去计算左右两列的高度,哪边更短来插入哪边,循环 list 来完成整个列表的插入。
商品重复的原因
乍一看上面代码写的很完美,但是却忽略 DOM 渲染还需要时间,代码中使用了 for await 保证异步循环有序进行,并且保证数据变化后 DOM 能渲染完成后获取到新的列高,这样却导致了 DOM 渲染的比较慢。DOM 在没有加载完成的情况下,用户再次滑动到底部会再次加载新的一页数据,导致 watch 又会被触发,dataRender 会再次被执行,相当于会存在多个 dataRender 同时在执行。但是 dataRender 中使用到了全局的 leftDataList、rightDataList 和 lastIndex ,如果多个 dataRender 同时执行的话就会到数据错乱,lastIndex 错乱会导致商品重复,leftDataList 和 rightDataList 错乱会导致顺序问题。
下面用伪代码讲述一下之间的关系
// 正常情况代码会像如下情况去走
list = [1,2,3,4,5]
// 数组执行完成后
lastIndex = 5
// 加载下一页数据后
list = [1,2,3,4,5,6,7,8,9,10]
list.slice(lastIndex, list.length) // [6,7,8,9,10]
但是如果 dataRender 同时执行 大家都共用同一个 lastIndex ,lastIndex 并不是最新的,就会变成下面这种情况
list.slice(lastIndex, list.length) // [1,2,3,4,5,6,7,8,9,10]
同理顺序错乱也是这种情况
解决方案
出现这个问题的原因是存在多个 dataRender 同时执行,那我们只需想办法在同一时间只能有一个在执行就可以了。
方法一(复杂,不推荐):标记位大法
看着这个方法相信大部分人经常把它用作防抖节流,例如不想让某个按钮频繁点击导致发送过多的请求、点击的时候让某个请求完全返回结果后才能再次触发下次请求等。因此我们这里的思路也是控制异步任务的次数,在一个 dataRender 完全执行完成之后才能执行另一个 dataRender ,在这里我们首先添加一个全局标记 fallLoad, 在最后一个节点渲染完才可以执行 dataRender,代码改造如下
const fallLoad = ref(true)
watch(() => {
if(fallLoad.value) {
dataRender()
fallLoad.value = false
}
})
const dataRender = async () => {
let i = 0
const tempList = newVal.slice(lastIndex.value, newVal.length)
for await (const item of tempList) {
i++
leftHeight <= rightHeight ? leftDataList.value.push(item) : rightDataList.value.push(item); //判断两边高度,来决定添加到那边
// 等待dom渲染完成
await nextTick();
// 获取dom渲染后的 左右两边的高度
leftHeight = await getViewHeight('#left')
rightHeight = await getViewHeight('#right')
// 判断是最后一个节点
if((tempList.length - 1) === i) {
fallLoad.value = true
}
}
lastIndex.value = newList.length
}
这样的话会丢弃掉用户快速滑动时触发的 dataRender ,只有在 DOM 渲染完成后再次触发新的请求时才会再次触发。但是这样可能会存在另外一个问题,有部分的 dataRender 被丢弃掉了,同时用户把所有的数据都加载完成了,没有新的数据来触发 watch ,这就导致部分商品的数据准备好了但在页面上没有渲染,因此我们还需要针对这种情况再去做单独处理, ,我们可以额外加一个状态来判断 rightDataList + leftDataList 的总数是否等于 dataList,不等于的时候可以再触发一次 dataRender ......
其实我们这种场景其实已经不太适合用标记位大法,强行使用只会让代码变成一座“屎山”,但是其实在我们日常业务中,添加标记位是一种很实用的方法,比如给某个按钮添加 loading ,防止某些事件、请求频繁执行等。
方法二(优雅,推荐):Promise + 队列 大法
由于我们并不能丢弃异常情况触发的 dataRender, 那我们只能让 dataRender 有序的执行。
我们重新整理思路,首先我们先把复杂的问题简单化。抛开我们的业务场景,dataRender 就可以当做一个异步的请求,然后问题就变成了在同一时间我们收到了多个异步的请求,我们怎么让这些异步请求自动、有序执行。
经过上面的推导我们拆解出以下几个关键点:
- 我们需要一个队列,队列中存储每个异步任务
- 当把这个任务添加到这个队列中的时候自动执行第一个任务
- 我们需要使用 promise.then() 来保证任务有序的执行
- 当存队列中在多个异步任务的时候,怎么在执行完成第一个之后再去自动的执行后续的任务
第一次执行的时机其实我们是知道,那我们需要现在解决的问题是执行完成第一个后怎么去自动执行后续的请求?
- 使用循环,可参考瀑布流组件中的 for await of 确保每次异步任务的执行,这里就不过多阐述了,这么写代码不太优雅
- 使用递归,在每个 promise.then 中递归下一个 promise
通过这几点关键点我们写出使用递归的方案的代码:
class asyncQueue {
constructor() {
this.asyncList = [];
this.inProgress = false;
}
add(asyncFunc) {
return new Promise((resolve, reject) => {
this.asyncList.push({asyncFunc, resolve, reject});
if (!this.inProgress) {
this.execute();
}
});
}
execute() {
if (this.asyncList.length > 0) {
const currentAsyncTask = this.asyncList.shift();
currentAsyncTask.asyncFunc()
.then(result => {
currentAsyncTask.resolve(result);
this.execute();
})
.catch(error => {
currentAsyncTask.reject(error);
this.execute();
});
this.inProgress = true;
} else {
this.inProgress = false;
}
}
}
export default asyncQueue
每次调用 add 方法会往队列中添加经过特殊包装过的异步任务,并且只有在只有在没有正在执行中的任务的时候才开始执行 execute 方法。在每次执行异步任务时会从队列中 shift ,利用 promise.then 并且递归调用该方法,实现有序并且自动执行任务。在封装在这方法的过程中同样也使用到了我们的标记位大法 inProgress ,来保证我们正在执行当前队列时,突然又进来新的任务而导致队列执行错乱。
调用方法如下:
const queue = new asyncQueue()
watch(() => props.dataList, async (newVal, oldVal) => {
queue.add(() => dataRender(newVal))
}, {
immediate: true,
deep: true
})
通过上述代码我们就可以,让我们的每一个异步任务有顺序的执行,并且让每一个异步任务执行完成以后自动执行下一个,完美的达到了我的需求。
其实这个方法不仅适用于当前场景,我们很多的业务场景都会遇到这种情况,会被动接受多个请求,但是这些请求还要有序的执行,我们都可以使用这种方法。
下面我简单列举了两种其他的场景:
- 比如某个按钮用户点击了多次,但是我们要让这些请求有序的执行并且依次拿到这些请求返回的数据
- 某些高频的通信操作,我们不能丢弃用户的每次通信,而是需要用这种队列的方式,自动、有序的执行
总结
上述的这些“点” ,标记位、promise、队列、递归等,在日常开发中几乎充斥在我们项目的每一个角落,但是如何使用好这些”点“值得我们深思的。