瀑布流組件陷入商品重復(fù)怪圈?我是如何用心一解的!
背景
某天我們公司小程序收到線上反饋,在商品列表頁面為什么我劃著劃著劃著,就會出現(xiàn)一些重復(fù)商品......
在講這個問題之前,先講一下我們是如何實(shí)現(xiàn)瀑布流組件的
瀑布流組件
什么是瀑布流組件
如圖所示下方商品列表就采用了瀑布流的布局,視覺表現(xiàn)為參差不齊的多欄布局。
如何實(shí)現(xiàn)一個瀑布流組件
下面簡單寫一下實(shí)現(xiàn)瀑布流的思路,左右兩列布局,根據(jù)每一列的高度來判斷下次插入到哪一列中,每次插入列中需重新計算高度,將下一個節(jié)點(diǎn)插入短的哪一列中,如下圖所示:
下面代碼示例(僅展示思路)
// dataList 就是我們整個的商品卡片列表的數(shù)據(jù) ,用戶滑動到底部會加載新一頁的數(shù)據(jù) 會再次觸發(fā) 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')
// 取下一頁數(shù)據(jù)
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>
當(dāng)用戶滾動到底部的時候會加載下一頁的數(shù)據(jù),dataList 會發(fā)生變化,組件會監(jiān)聽到 dataList 的變化來執(zhí)行 dataRender,dataRender 中會去計算左右兩列的高度,哪邊更短來插入哪邊,循環(huán) list 來完成整個列表的插入。
商品重復(fù)的原因
乍一看上面代碼寫的很完美,但是卻忽略 DOM 渲染還需要時間,代碼中使用了 for await 保證異步循環(huán)有序進(jìn)行,并且保證數(shù)據(jù)變化后 DOM 能渲染完成后獲取到新的列高,這樣卻導(dǎo)致了 DOM 渲染的比較慢。DOM 在沒有加載完成的情況下,用戶再次滑動到底部會再次加載新的一頁數(shù)據(jù),導(dǎo)致 watch 又會被觸發(fā),dataRender 會再次被執(zhí)行,相當(dāng)于會存在多個 dataRender 同時在執(zhí)行。但是 dataRender 中使用到了全局的 leftDataList、rightDataList 和 lastIndex ,如果多個 dataRender 同時執(zhí)行的話就會到數(shù)據(jù)錯亂,lastIndex 錯亂會導(dǎo)致商品重復(fù),leftDataList 和 rightDataList 錯亂會導(dǎo)致順序問題。
下面用偽代碼講述一下之間的關(guān)系
// 正常情況代碼會像如下情況去走
list = [1,2,3,4,5]
// 數(shù)組執(zhí)行完成后
lastIndex = 5
// 加載下一頁數(shù)據(jù)后
list = [1,2,3,4,5,6,7,8,9,10]
list.slice(lastIndex, list.length) // [6,7,8,9,10]
但是如果 dataRender 同時執(zhí)行 大家都共用同一個 lastIndex ,lastIndex 并不是最新的,就會變成下面這種情況
list.slice(lastIndex, list.length) // [1,2,3,4,5,6,7,8,9,10]
同理順序錯亂也是這種情況
解決方案
出現(xiàn)這個問題的原因是存在多個 dataRender 同時執(zhí)行,那我們只需想辦法在同一時間只能有一個在執(zhí)行就可以了。
方法一(復(fù)雜,不推薦):標(biāo)記位大法
看著這個方法相信大部分人經(jīng)常把它用作防抖節(jié)流,例如不想讓某個按鈕頻繁點(diǎn)擊導(dǎo)致發(fā)送過多的請求、點(diǎn)擊的時候讓某個請求完全返回結(jié)果后才能再次觸發(fā)下次請求等。因此我們這里的思路也是控制異步任務(wù)的次數(shù),在一個 dataRender 完全執(zhí)行完成之后才能執(zhí)行另一個 dataRender ,在這里我們首先添加一個全局標(biāo)記 fallLoad, 在最后一個節(jié)點(diǎn)渲染完才可以執(zhí)行 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')
// 判斷是最后一個節(jié)點(diǎn)
if((tempList.length - 1) === i) {
fallLoad.value = true
}
}
lastIndex.value = newList.length
}
這樣的話會丟棄掉用戶快速滑動時觸發(fā)的 dataRender ,只有在 DOM 渲染完成后再次觸發(fā)新的請求時才會再次觸發(fā)。但是這樣可能會存在另外一個問題,有部分的 dataRender 被丟棄掉了,同時用戶把所有的數(shù)據(jù)都加載完成了,沒有新的數(shù)據(jù)來觸發(fā) watch ,這就導(dǎo)致部分商品的數(shù)據(jù)準(zhǔn)備好了但在頁面上沒有渲染,因此我們還需要針對這種情況再去做單獨(dú)處理, ,我們可以額外加一個狀態(tài)來判斷 rightDataList + leftDataList 的總數(shù)是否等于 dataList,不等于的時候可以再觸發(fā)一次 dataRender ......
其實(shí)我們這種場景其實(shí)已經(jīng)不太適合用標(biāo)記位大法,強(qiáng)行使用只會讓代碼變成一座“屎山”,但是其實(shí)在我們?nèi)粘I(yè)務(wù)中,添加標(biāo)記位是一種很實(shí)用的方法,比如給某個按鈕添加 loading ,防止某些事件、請求頻繁執(zhí)行等。
方法二(優(yōu)雅,推薦):Promise + 隊(duì)列 大法
由于我們并不能丟棄異常情況觸發(fā)的 dataRender, 那我們只能讓 dataRender 有序的執(zhí)行。
我們重新整理思路,首先我們先把復(fù)雜的問題簡單化。拋開我們的業(yè)務(wù)場景,dataRender 就可以當(dāng)做一個異步的請求,然后問題就變成了在同一時間我們收到了多個異步的請求,我們怎么讓這些異步請求自動、有序執(zhí)行。
經(jīng)過上面的推導(dǎo)我們拆解出以下幾個關(guān)鍵點(diǎn):
- 我們需要一個隊(duì)列,隊(duì)列中存儲每個異步任務(wù)
- 當(dāng)把這個任務(wù)添加到這個隊(duì)列中的時候自動執(zhí)行第一個任務(wù)
- 我們需要使用 promise.then() 來保證任務(wù)有序的執(zhí)行
- 當(dāng)存隊(duì)列中在多個異步任務(wù)的時候,怎么在執(zhí)行完成第一個之后再去自動的執(zhí)行后續(xù)的任務(wù)
第一次執(zhí)行的時機(jī)其實(shí)我們是知道,那我們需要現(xiàn)在解決的問題是執(zhí)行完成第一個后怎么去自動執(zhí)行后續(xù)的請求?
- 使用循環(huán),可參考瀑布流組件中的 for await of 確保每次異步任務(wù)的執(zhí)行,這里就不過多闡述了,這么寫代碼不太優(yōu)雅
- 使用遞歸,在每個 promise.then 中遞歸下一個 promise
通過這幾點(diǎn)關(guān)鍵點(diǎn)我們寫出使用遞歸的方案的代碼:
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
每次調(diào)用 add 方法會往隊(duì)列中添加經(jīng)過特殊包裝過的異步任務(wù),并且只有在只有在沒有正在執(zhí)行中的任務(wù)的時候才開始執(zhí)行 execute 方法。在每次執(zhí)行異步任務(wù)時會從隊(duì)列中 shift ,利用 promise.then 并且遞歸調(diào)用該方法,實(shí)現(xiàn)有序并且自動執(zhí)行任務(wù)。在封裝在這方法的過程中同樣也使用到了我們的標(biāo)記位大法 inProgress ,來保證我們正在執(zhí)行當(dāng)前隊(duì)列時,突然又進(jìn)來新的任務(wù)而導(dǎo)致隊(duì)列執(zhí)行錯亂。
調(diào)用方法如下:
const queue = new asyncQueue()
watch(() => props.dataList, async (newVal, oldVal) => {
queue.add(() => dataRender(newVal))
}, {
immediate: true,
deep: true
})
通過上述代碼我們就可以,讓我們的每一個異步任務(wù)有順序的執(zhí)行,并且讓每一個異步任務(wù)執(zhí)行完成以后自動執(zhí)行下一個,完美的達(dá)到了我的需求。
其實(shí)這個方法不僅適用于當(dāng)前場景,我們很多的業(yè)務(wù)場景都會遇到這種情況,會被動接受多個請求,但是這些請求還要有序的執(zhí)行,我們都可以使用這種方法。
下面我簡單列舉了兩種其他的場景:
- 比如某個按鈕用戶點(diǎn)擊了多次,但是我們要讓這些請求有序的執(zhí)行并且依次拿到這些請求返回的數(shù)據(jù)
- 某些高頻的通信操作,我們不能丟棄用戶的每次通信,而是需要用這種隊(duì)列的方式,自動、有序的執(zhí)行
總結(jié)
上述的這些“點(diǎn)” ,標(biāo)記位、promise、隊(duì)列、遞歸等,在日常開發(fā)中幾乎充斥在我們項(xiàng)目的每一個角落,但是如何使用好這些”點(diǎn)“值得我們深思的。