列表的分頁是 web 開發(fā)中非常常見的一個需求,目前我接觸過的分頁有一下幾類:
- 靜態(tài)列表
- 動態(tài)無序列表
- 動態(tài)有序列表
靜態(tài)列表
常見于企業(yè)的時間軸列表中,它的列表內(nèi)容幾乎是不變的,因此使用 page + count 進行分頁就可以了
動態(tài)無序列表
這里的 無序 指的是這個列表除了 動態(tài)的分?jǐn)?shù) 來作為排序規(guī)則之外,沒有其它的額外規(guī)則,比如說不能根據(jù)創(chuàng)建時間排序。常見于一些 UGC 產(chǎn)品的首頁信息流中,它的列表順序是根據(jù)熱度時刻(每 n 秒排序一次,或者通過操作緩存的 score 實時變更)變化的,這種列表的分頁在每次獲取數(shù)據(jù)的時候傳遞一個參數(shù):seenIds 它是一個 你看過的文章的 id 的數(shù)組,這樣就可以去重了
理論上動態(tài)無序列表如果想要根據(jù)熱度來嚴(yán)格排序,就不能分頁了,只能一次取出所有的數(shù)據(jù),就想知乎的熱榜一樣,而知乎的問題頁面的答案列表,其實并不需要嚴(yán)格的按照加權(quán)票數(shù)來排序的,因此可以使用 seenIds 來獲取分頁數(shù)據(jù)
使用seenIds獲取分頁數(shù)據(jù)有性能瓶頸,我們后面再講
動態(tài)有序列表
這里的 有序 指這個列表可以依據(jù)數(shù)據(jù)庫中的某個字段來排序,通常會是 創(chuàng)建時間(或者自增 id ),這種列表涉及的場景最多,我們主要講這個列表。動態(tài)有序列表有兩種:
使用 minId 獲取動態(tài)降序列表分頁
根據(jù)某字段從大到小排列,如QQ空間的好友動態(tài),是根據(jù)創(chuàng)建時間由大到小排列,由于向這個列表中插入的新數(shù)據(jù) 創(chuàng)建時間 只會更大,因此在分頁獲取時,只需要傳一個 minId 的參數(shù)就可以了,告訴后端 你看過的最小的數(shù)據(jù)的 id,這樣就不會出現(xiàn)重復(fù)數(shù)據(jù)
使用 maxId 獲取動態(tài)升序列表分頁
根據(jù)某字段從小到大排列,如簡書的評論列表,根據(jù)創(chuàng)建時間由小到大排列,同理認(rèn)為這種情況下傳遞一個 maxId 給后端就可以了,但是它有一個問題就是:向列表中插入的新數(shù)據(jù)會在列表的底部,這個時候假設(shè)這樣一個場景:
- 每頁獲取10條評論
- 評論共有11條
- 在只獲取了第一條的情況下,創(chuàng)建一條新評論
- 創(chuàng)建新評論后,不刷新頁面,將新的評論 push 到評論列表底部
- 加載下一頁的評論
那么這個時候,你拿不到任何數(shù)據(jù),因為你的 maxId 是你創(chuàng)建的那條評論的 id,除非你有辦法在計算 maxId 的時候,將你自己新加的評論的 id 排除掉,而即使你把自己新加的評論的 id 在計算 maxId 的時候排除掉,你在獲取下一頁數(shù)據(jù)的時候,又會把自己新加的評論獲取到,這樣就 重復(fù) 了
因此使用 maxId 獲取動態(tài)升序列表需要解決兩個問題:
- 不要讓自己創(chuàng)建的數(shù)據(jù)的 id 參與到 maxId 的計算中
- 在獲取到列表數(shù)據(jù)的時候,要去重
使用 page + count 獲取動態(tài)升序列表分頁
既然使用 maxId 獲取動態(tài)列表需要去重,那么為什么不直接使用 page + count 來獲取呢?使用 page + count 來獲取,也會出現(xiàn)數(shù)據(jù)重復(fù),但只需要處理去重就行了,不需要計算 maxId 了,但其實使用這種方式還有另外一個問題,那就是假設(shè)如下場景:
- 用戶打開頁面時,評論共有 11 條
- 用戶每頁獲取 10 條數(shù)據(jù),且用戶沒有點擊獲取更多
- 過了很久之后,后臺數(shù)據(jù)里的評論已經(jīng)有 21 條了
- 用戶點擊下一頁
- 這個時候?qū)Λ@取到的數(shù)據(jù)去重,會發(fā)現(xiàn)什么也沒獲取到
- 假設(shè)后臺數(shù)據(jù)變成了 201 條而不是 21 條,那么順序就亂了
因此使用 page + count 獲取動態(tài)升序列表的時候有一個問題和一個弊端:
- 需要去重
- 當(dāng)數(shù)據(jù)增長極快的時候,使用 page + count 無法保證順序,并且無法獲取到足量的有效數(shù)據(jù)
綜上: 獲取動態(tài)升序列表我們還是得使用 maxId 來分頁
用代碼解決問題(僅動態(tài)有序列表的前端部分)
- 假設(shè)我們把所有的評論存儲在 comments 數(shù)組里
data: {
comments: [],
maxId: 0
}
// 我們在計算 maxId 的時機,就不能是在獲取下一頁時,因為這個時候計算的 maxId,肯定是新添加的評論的 id
// 所以這個 maxId 要在獲取到分頁數(shù)據(jù)時來計算
// 創(chuàng)建一條新評論
api.post('comment/create', { data }).then((comment) => {
this.comments.push(comment)
})
// 獲取分頁數(shù)據(jù)
api.get('comment/list', { maxId: this.maxId }).then((resComments) => {
// 將 response 的 id 存到一個 Array 里
const resIds = resComments.map(_ => _.id)
// 計算 maxId
this.maxId = resComments[resComments.length - 1].id
// 刪除重復(fù)的數(shù)據(jù)
this.comments = this.comments.filter(_ => resIds.indexOf(_.id) === -1)
// 將新數(shù)據(jù) merge 到舊數(shù)據(jù)里
this.comments = this.comments.concat(resComments)
// 這里是將老的數(shù)據(jù)刪除,而不是不使用新的數(shù)據(jù),因為新的數(shù)據(jù)更有價值
})
- 假設(shè)我們把分頁獲取到的評論存儲在 comments 數(shù)組里,把自己發(fā)表的評論放在 newComments 里
data: {
comments: [],
newComments: []
}
// 這種情況下我們不需要維護一個 maxId 變量,因為在獲取數(shù)據(jù)的時候就算就可以了
// 創(chuàng)建一條新評論
api.post('comment/create', { data }).then((comment) => {
this.newComments.push(comment)
})
// 獲取分頁數(shù)據(jù)
api.get('comment/list', {
maxId: comments[comments.length - 1].id
}).then((resComments) => {
// 將 response 的 id 存到一個 Array 里
const resIds = resComments.map(_ => _.id)
// 數(shù)據(jù)去重
this.newComments = this.newComments.filter(_ => resIds.indexOf(_.id) !== -1)
// merge
this.comments = this.comments.concat(resComments)
})
// 然后我們需要一個 computed 來合并 comments 和 newComments,并且使用 lodash 進行排序
showComments () {
return _.orderBy(this.comments.concat(this.newComments), 'id', 'ASC')
}
通過上面的代碼我們已經(jīng)可以處理之前提出的兩個問題,但實際業(yè)務(wù)并不是這么簡單,它會更復(fù)雜,比如評論中會有子評論,而子評論也是一個 動態(tài)升序列表,接下來我們就來處理這種復(fù)雜情況
- 每個主評論都有一個子評論列表
這是一個常見的場景,基于這種場景,我們需要在上面兩種解決方法中選一個,我認(rèn)為我們應(yīng)該選第一種,因為第二種是需要依賴重排序和計算屬性的,而維護一個 newComments 和維護一個 maxId 的代價基本相同
// store.js
const state = {
comments: [],
maxId: 0
}
const mutations = {
SET_MAIN_COMMENTS (state, comments) {
const formatComments = comments.map(item => {
// 假設(shè)子評論的 key 是 children_comments
const childrenComment = item.children_comments
// 在每個 comment 的數(shù)據(jù)里維護一個 __maxId
return Object.assign(item, {
__maxId: childrenComment[childrenComment.length - 1].id
})
})
// 和之前相同的操作
const resIds = formatComments.map(_ => _.id)
state.maxId = formatComments[formatComments.length - 1].id
state.comments = state.comments.filter(_ => resIds.indexOf(_.id) === -1)
state.comments = state.comments.concat(data)
},
SET_SUB_COMMENTS (state, { comments, parentId }) {
let parentComment = null
let parentIndex = 0
states.comments.forEach((item, index) => {
if (item.id === parentid) {
parentComment = item
parentIndex = index
}
})
if (!parentComment) {
return
}
const resIds = comments.map(_ => _.id)
// 操作一下 __maxId 即可
states.comments[index].__maxId = comments[comments.length - 1].id
states.comments[index].children_comments = parentComment.children_comments.filter(_ => resIds.indexOf(_.id) === -1)
states.comments[index].children_comments = states.comments[index].children_comments.concat(comments)
},
CREATE_MAIN_COMMENT (state, comment) {
state.comments.push(comment)
},
CREATE_SUB_COMMENT (state, { parentId, comment }) {
state.comments.forEach((item, index) => {
if (item.id === parentId) {
state.comments[index].children_comments.push(comment)
}
})
}
}
const action = {
async getMainComments ({ state, commit }, { noteId }) {
const data = await api.getComments({
noteId,
maxId: state.maxId
})
commit('SET_MAIN_COMMENTS', data)
},
async getSubComments ({ state, commit }, { parentId }) {
const comments = await api.getChildrenComments({
parentId,
maxId: state.comments.filter(_ => _.id === parentId)[0].__maxId
})
commit('SET_SUB_COMMENTS', { comments, parentId })
},
async createMainComment ({ commit }, data) {
const comment = await api.createMainComment(data)
commit('CREATE_MAIN_COMMENT', comment)
},
async createSubComment ({ commit }, { parentId, data }) {
const comment = await api.createMainComment({ parentId, data })
commit('CREATE_SUB_COMMENT', { parentId, comment })
}
}
- 復(fù)雜業(yè)務(wù)下的多態(tài)
通過上面的代碼,我們已經(jīng)使用 vuex 講評論列表的數(shù)據(jù)層抽象出來了,我們可以再思考一下在一個大型 web 應(yīng)用中評論層的數(shù)據(jù)該如何聚合,我們可以使用多態(tài)來處理所有的 動態(tài)有序列表
假設(shè)一個文章頁面,在通過接口獲取數(shù)據(jù)時,會返回文章的同時返回評論列表的第一頁數(shù)據(jù),一般情況下,我們都是這樣處理一個頁面的數(shù)據(jù)的:
// store.js
const state = {
note: {
comments: []
}
}
const mutations = {
SET_NOTE (state, data) {
state.note = data
}
}
const action = {
async getNote ({ commit }, { id }) {
const note = await api.getNote(id)
commit('SET_NOTE', note)
}
}
// page-component.vue
async asyncData ({ store, route }) {
await store.dispatch('note/getNote', { id: route.params.id })
}
如果我們使用多態(tài)的方式存儲評論列表,那我們就可以這么做:
// ... state & mutations
const action = {
async getNote ({ commit }, { id }) {
return await api.getNote(id)
}
}
// page-component.vue
async asyncData ({ store, route }) {
const note = await store.dispatch('note/getNote', { id: route.params.id })
store.commit('note/SET_NOTE', note)
store.commit('comment/SET_MAIN_COMMENT', note.comments)
}
然后后端也將評論功能做成一個 service,增刪改查使用同一套接口,傳遞一個 type 來控制不同的數(shù)據(jù)表,就可以實現(xiàn)一個簡單而強大的評論體系了
- 與使用多態(tài)后的問題:精彩評論和評論重排序
前端的評論列表的數(shù)據(jù)層使用了多態(tài)后,當(dāng)有特異性需求的時候就需要有特殊的操作,比如說:
* 文章頁面要有評論列表,還要有精彩評論列表,如簡書
* 答案列表不僅支持按照分?jǐn)?shù)排序,還要支持按照創(chuàng)建時間重排序,如知乎
因為還沒有沒有接觸過這樣的需求,所以暫時不對這種情況進行討論,以后再說吧╮(╯▽╰)╭
seenIds 的性能瓶頸
當(dāng)我們提到使用 seenIds 做參數(shù)來去重的時候,你就應(yīng)該想到了當(dāng) seenIds 無限增長時,對整個系統(tǒng)意味著什么,他會遇到兩個問題:
- 如果 API 是一個 GET 請求,那么 seenIds 會被拼在 request url 后面,而 request url 是有長度限制的,因此當(dāng)數(shù)據(jù)量太大,并且用戶一直翻頁的時候,就會報 http error 了
- 就算沒有報 http error,當(dāng)傳遞給后端的 seenIds 過多時,對后端來說這就是一個問題,具體的邏輯可能是這樣的:
- DB 層前面肯定需要一個 Cache 層,所以我們獲取評論列表時其實優(yōu)先會去從緩存里獲取
- 每條評論又有子評論,每條評論的點贊量、回復(fù)量又是動態(tài)的,并且評論在可編輯的情況下,導(dǎo)致每個主評論都是單獨存儲的,并不會將一篇文章下的所有評論存儲在同一個 key 里
- 評論列表是動態(tài)的,因此也不能每次都去從數(shù)據(jù)庫拿,所以可以將文章下的評論列表的 id 存成一個緩存,由于列表是動態(tài)無序的,而是通過某個綜合的評分來排序,因此使用 Redis 的有序集合(sorted set)來存儲 id 的數(shù)組
- 每次獲取下一頁數(shù)據(jù)時,都把這個有序集合的里的所有數(shù)據(jù)拿出來,filter 一下,再取出幾條(一段 PHP 的 filter 代碼:
array_slice(array_diff($ids, $seen), 0, $take)),那在 seenIds 的 length 非常的大,并且數(shù)據(jù)量也非常大的時候,就會有性能瓶頸(未實踐)
為了解決性能問題,使用其它方式來獲取分頁數(shù)據(jù)(未實踐):
- 因為 seenIds 的性能瓶頸問題,使用了 minId 來獲取下一頁數(shù)據(jù)
- 這里的 minId 其實不是一個 id,而是一個 score,后端的 Cache 層依然還是有序集合
- 同一個有序集合的 score 是可以重復(fù)的,因此這里使用 score 會有兩種獲取分頁數(shù)據(jù)的方式:
- 傳給后端 minScore,然后獲取小于這個 score 的數(shù)據(jù),按照 score 從高到低的 N 條,那么當(dāng)你看過的某條數(shù)據(jù)的 score 降低的時候,你就會重復(fù)看到這條數(shù)據(jù)。它不僅會導(dǎo)致重復(fù),還會導(dǎo)致相同 score 的數(shù)據(jù)不會被你看到
- 傳給后端 minScore (看過的最小的 score)和 minIds(看過的最小 score 的 id 列表), 然后通過 Redis 的
ZRANGEBYSCORE操作有序集合,拿到列表里score >= minScore的 X 條數(shù)據(jù)(X = minIds.length + N)resIds,然后 filter 這個 resIds,將看過的 minIds 過濾掉,就只剩下 N 條數(shù)據(jù)了,就算這樣也會重復(fù),但它至少更多的情況下能夠獲取到相同 score 的數(shù)據(jù)
使用 sinceId 優(yōu)化動態(tài)降序列表
我們在獲取動態(tài)降序列表時使用了 minId 來處理,其實我們還可以再傳一個 sinceId 來優(yōu)化業(yè)務(wù)層,它表示你獲取到的第一條數(shù)據(jù)的 id(其實在這種情況下就是 maxId)
- 將 sinceId 傳遞給后端,后端可以判斷在這個 id 之后又有幾條新的數(shù)據(jù)被創(chuàng)建了,這樣在翻頁的時候,就可以提示用戶:有幾條新動態(tài),QQ空間就是這樣做的
- 當(dāng)用戶刷新列表(refresh)的時候,可以傳遞一個 sinceId 給后端,這樣后端就不需要發(fā)送多余的數(shù)據(jù)給前端,前端也可以實現(xiàn)一個“上次看到這里”的功能,就像知乎的推薦信息流頁面