瀑布流組件陷入商品重復怪圈?我是如何用心一解的!
背景
某天我們公司小程序收到線上反饋,在商品列表頁面為什么我劃著劃著劃著,就會出現一些重復商品......
在講這個問題之前,先講一下我們是如何實現瀑布流組件的
瀑布流組件
什么是瀑布流組件
如圖所示下方商品列表就采用了瀑布流的布局,視覺表現為參差不齊的多欄布局。
如何實現一個瀑布流組件
下面簡單寫一下實現瀑布流的思路,左右兩列布局,根據每一列的高度來判斷下次插入到哪一列中,每次插入列中需重新計算高度,將下一個節點插入短的哪一列中,如下圖所示:
下面代碼示例(僅展示思路)
// 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、隊列、遞歸等,在日常開發中幾乎充斥在我們項目的每一個角落,但是如何使用好這些”點“值得我們深思的。