web 開發(fā)之 —— 列表的分頁

列表的分頁是 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è)這樣一個場景:

  1. 每頁獲取10條評論
  2. 評論共有11條
  3. 在只獲取了第一條的情況下,創(chuàng)建一條新評論
  4. 創(chuàng)建新評論后,不刷新頁面,將新的評論 push 到評論列表底部
  5. 加載下一頁的評論

那么這個時候,你拿不到任何數(shù)據(jù),因為你的 maxId 是你創(chuàng)建的那條評論的 id,除非你有辦法在計算 maxId 的時候,將你自己新加的評論的 id 排除掉,而即使你把自己新加的評論的 id 在計算 maxId 的時候排除掉,你在獲取下一頁數(shù)據(jù)的時候,又會把自己新加的評論獲取到,這樣就 重復(fù)

因此使用 maxId 獲取動態(tài)升序列表需要解決兩個問題:

  1. 不要讓自己創(chuàng)建的數(shù)據(jù)的 id 參與到 maxId 的計算中
  2. 在獲取到列表數(shù)據(jù)的時候,要去重
使用 page + count 獲取動態(tài)升序列表分頁

既然使用 maxId 獲取動態(tài)列表需要去重,那么為什么不直接使用 page + count 來獲取呢?使用 page + count 來獲取,也會出現(xiàn)數(shù)據(jù)重復(fù),但只需要處理去重就行了,不需要計算 maxId 了,但其實使用這種方式還有另外一個問題,那就是假設(shè)如下場景:

  1. 用戶打開頁面時,評論共有 11 條
  2. 用戶每頁獲取 10 條數(shù)據(jù),且用戶沒有點擊獲取更多
  3. 過了很久之后,后臺數(shù)據(jù)里的評論已經(jīng)有 21 條了
  4. 用戶點擊下一頁
  5. 這個時候?qū)Λ@取到的數(shù)據(jù)去重,會發(fā)現(xiàn)什么也沒獲取到
  6. 假設(shè)后臺數(shù)據(jù)變成了 201 條而不是 21 條,那么順序就亂了

因此使用 page + count 獲取動態(tài)升序列表的時候有一個問題和一個弊端:

  1. 需要去重
  2. 當(dāng)數(shù)據(jù)增長極快的時候,使用 page + count 無法保證順序,并且無法獲取到足量的有效數(shù)據(jù)

綜上: 獲取動態(tài)升序列表我們還是得使用 maxId 來分頁


用代碼解決問題(僅動態(tài)有序列表的前端部分)

  1. 假設(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ù)更有價值
})
  1. 假設(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ù)雜情況

  1. 每個主評論都有一個子評論列表

這是一個常見的場景,基于這種場景,我們需要在上面兩種解決方法中選一個,我認(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 })
  }
}
  1. 復(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)一個簡單而強大的評論體系了

  1. 與使用多態(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)意味著什么,他會遇到兩個問題:

  1. 如果 API 是一個 GET 請求,那么 seenIds 會被拼在 request url 后面,而 request url 是有長度限制的,因此當(dāng)數(shù)據(jù)量太大,并且用戶一直翻頁的時候,就會報 http error 了
  2. 就算沒有報 http error,當(dāng)傳遞給后端的 seenIds 過多時,對后端來說這就是一個問題,具體的邏輯可能是這樣的:
    1. DB 層前面肯定需要一個 Cache 層,所以我們獲取評論列表時其實優(yōu)先會去從緩存里獲取
    2. 每條評論又有子評論,每條評論的點贊量、回復(fù)量又是動態(tài)的,并且評論在可編輯的情況下,導(dǎo)致每個主評論都是單獨存儲的,并不會將一篇文章下的所有評論存儲在同一個 key 里
    3. 評論列表是動態(tài)的,因此也不能每次都去從數(shù)據(jù)庫拿,所以可以將文章下的評論列表的 id 存成一個緩存,由于列表是動態(tài)無序的,而是通過某個綜合的評分來排序,因此使用 Redis 的有序集合(sorted set)來存儲 id 的數(shù)組
    4. 每次獲取下一頁數(shù)據(jù)時,都把這個有序集合的里的所有數(shù)據(jù)拿出來,filter 一下,再取出幾條(一段 PHP 的 filter 代碼:array_slice(array_diff($ids, $seen), 0, $take)),那在 seenIds 的 length 非常的大,并且數(shù)據(jù)量也非常大的時候,就會有性能瓶頸(未實踐)

為了解決性能問題,使用其它方式來獲取分頁數(shù)據(jù)(未實踐)

  1. 因為 seenIds 的性能瓶頸問題,使用了 minId 來獲取下一頁數(shù)據(jù)
  2. 這里的 minId 其實不是一個 id,而是一個 score,后端的 Cache 層依然還是有序集合
  3. 同一個有序集合的 score 是可以重復(fù)的,因此這里使用 score 會有兩種獲取分頁數(shù)據(jù)的方式:
    1. 傳給后端 minScore,然后獲取小于這個 score 的數(shù)據(jù),按照 score 從高到低的 N 條,那么當(dāng)你看過的某條數(shù)據(jù)的 score 降低的時候,你就會重復(fù)看到這條數(shù)據(jù)。它不僅會導(dǎo)致重復(fù),還會導(dǎo)致相同 score 的數(shù)據(jù)不會被你看到
    2. 傳給后端 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)

  1. 將 sinceId 傳遞給后端,后端可以判斷在這個 id 之后又有幾條新的數(shù)據(jù)被創(chuàng)建了,這樣在翻頁的時候,就可以提示用戶:有幾條新動態(tài),QQ空間就是這樣做的
  2. 當(dāng)用戶刷新列表(refresh)的時候,可以傳遞一個 sinceId 給后端,這樣后端就不需要發(fā)送多余的數(shù)據(jù)給前端,前端也可以實現(xiàn)一個“上次看到這里”的功能,就像知乎的推薦信息流頁面
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 人生總是那么短暫,一生中那些美好的時光總是稍縱即逝,盡管我們怎么想抓住都抓不住,最后所有的美好都將會成為回憶。 是...
    乖乖的璦文閱讀 390評論 13 4
  • “這次我一定要離婚!”女人紅著眼沖進門,咬牙切齒、斬釘截鐵地說。 屋子里,她的哥哥正在盛飯,被突如其來的一嗓子嚇得...
    路喬閱讀 777評論 0 4
  • 四季的風(fēng)依然 吹開梅的心扉 風(fēng)韻悄然綻立枝頭 忽然 下了一場雪 雪擁枝頭 梅吻雪潔 白天寒夜衷腸互訴 累了,雪給梅...
    攀屹閱讀 196評論 0 1
  • 桂花香 早上,家人告誡:今...
    0桂花香0閱讀 172評論 1 2

友情鏈接更多精彩內(nèi)容