web 开发之 —— 列表的分页

列表的分页是 web 开发中非常常见的一个需求,目前我接触过的分页有一下几类:

  • 静态列表
  • 动态无序列表
  • 动态有序列表

静态列表

常见于企业的时间轴列表中,它的列表内容几乎是不变的,因此使用 page + count 进行分页就可以了

动态无序列表

这里的 无序 指的是这个列表除了 动态的分数 来作为排序规则之外,没有其它的额外规则,比如说不能根据创建时间排序。常见于一些 UGC 产品的首页信息流中,它的列表顺序是根据热度时刻(每 n 秒排序一次,或者通过操作缓存的 score 实时变更)变化的,这种列表的分页在每次获取数据的时候传递一个参数:seenIds 它是一个 你看过的文章的 id 的数组,这样就可以去重了

理论上动态无序列表如果想要根据热度来严格排序,就不能分页了,只能一次取出所有的数据,就想知乎的热榜一样,而知乎的问题页面的答案列表,其实并不需要严格的按照加权票数来排序的,因此可以使用 seenIds 来获取分页数据

使用seenIds获取分页数据有性能瓶颈,我们后面再讲

动态有序列表

这里的 有序 指这个列表可以依据数据库中的某个字段来排序,通常会是 创建时间(或者自增 id ),这种列表涉及的场景最多,我们主要讲这个列表。动态有序列表有两种:

使用 minId 获取动态降序列表分页

根据某字段从大到小排列,如QQ空间的好友动态,是根据创建时间由大到小排列,由于向这个列表中插入的新数据 创建时间 只会更大,因此在分页获取时,只需要传一个 minId 的参数就可以了,告诉后端 你看过的最小的数据的 id,这样就不会出现重复数据

使用 maxId 获取动态升序列表分页

根据某字段从小到大排列,如简书评论列表,根据创建时间由小到大排列,同理认为这种情况下传递一个 maxId 给后端就可以了,但是它有一个问题就是:向列表中插入的新数据会在列表的底部,这个时候假设这样一个场景:

  1. 每页获取10条评论
  2. 评论共有11条
  3. 在只获取了第一条的情况下,创建一条新评论
  4. 创建新评论后,不刷新页面,将新的评论 push 到评论列表底部
  5. 加载下一页的评论

那么这个时候,你拿不到任何数据,因为你的 maxId 是你创建的那条评论的 id,除非你有办法在计算 maxId 的时候,将你自己新加的评论的 id 排除掉,而即使你把自己新加的评论的 id 在计算 maxId 的时候排除掉,你在获取下一页数据的时候,又会把自己新加的评论获取到,这样就 重复

因此使用 maxId 获取动态升序列表需要解决两个问题:

  1. 不要让自己创建的数据的 id 参与到 maxId 的计算中
  2. 在获取到列表数据的时候,要去重
使用 page + count 获取动态升序列表分页

既然使用 maxId 获取动态列表需要去重,那么为什么不直接使用 page + count 来获取呢?使用 page + count 来获取,也会出现数据重复,但只需要处理去重就行了,不需要计算 maxId 了,但其实使用这种方式还有另外一个问题,那就是假设如下场景:

  1. 用户打开页面时,评论共有 11 条
  2. 用户每页获取 10 条数据,且用户没有点击获取更多
  3. 过了很久之后,后台数据里的评论已经有 21 条了
  4. 用户点击下一页
  5. 这个时候对获取到的数据去重,会发现什么也没获取到
  6. 假设后台数据变成了 201 条而不是 21 条,那么顺序就乱了

因此使用 page + count 获取动态升序列表的时候有一个问题和一个弊端:

  1. 需要去重
  2. 当数据增长极快的时候,使用 page + count 无法保证顺序,并且无法获取到足量的有效数据

综上: 获取动态升序列表我们还是得使用 maxId 来分页


用代码解决问题(仅动态有序列表的前端部分)

  1. 假设我们把所有的评论存储在 comments 数组里
data: {
  comments: [],
  maxId: 0
}

// 我们在计算 maxId 的时机,就不能是在获取下一页时,因为这个时候计算的 maxId,肯定是新添加的评论的 id
// 所以这个 maxId 要在获取到分页数据时来计算

// 创建一条新评论
api.post('comment/create', { data }).then((comment) => {
  this.comments.push(comment)
})
// 获取分页数据
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
  // 删除重复的数据
  this.comments = this.comments.filter(_ => resIds.indexOf(_.id) === -1)
  // 将新数据 merge 到旧数据里
  this.comments = this.comments.concat(resComments)
  // 这里是将老的数据删除,而不是不使用新的数据,因为新的数据更有价值
})
  1. 假设我们把分页获取到的评论存储在 comments 数组里,把自己发表的评论放在 newComments 里
data: {
  comments: [],
  newComments: []
}

// 这种情况下我们不需要维护一个 maxId 变量,因为在获取数据的时候就算就可以了

// 创建一条新评论
api.post('comment/create', { data }).then((comment) => {
  this.newComments.push(comment)
})
// 获取分页数据
api.get('comment/list', {
  maxId: comments[comments.length - 1].id
}).then((resComments) => {
  // 将 response 的 id 存到一个 Array 里
  const resIds = resComments.map(_ => _.id)
  // 数据去重
  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')
}

通过上面的代码我们已经可以处理之前提出的两个问题,但实际业务并不是这么简单,它会更复杂,比如评论中会有子评论,而子评论也是一个 动态升序列表,接下来我们就来处理这种复杂情况

  1. 每个主评论都有一个子评论列表

这是一个常见的场景,基于这种场景,我们需要在上面两种解决方法中选一个,我认为我们应该选第一种,因为第二种是需要依赖重排序和计算属性的,而维护一个 newComments 和维护一个 maxId 的代价基本相同

// store.js
const state = {
  comments: [],
  maxId: 0
}

const mutations = {
  SET_MAIN_COMMENTS (state, comments) {
    const formatComments = comments.map(item => {
      // 假设子评论的 key 是 children_comments
      const childrenComment = item.children_comments
      // 在每个 comment 的数据里维护一个 __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. 复杂业务下的多态

通过上面的代码,我们已经使用 vuex 讲评论列表的数据层抽象出来了,我们可以再思考一下在一个大型 web 应用中评论层的数据该如何聚合,我们可以使用多态来处理所有的 动态有序列表

假设一个文章页面,在通过接口获取数据时,会返回文章的同时返回评论列表的第一页数据,一般情况下,我们都是这样处理一个页面的数据的:

// 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 })
}

如果我们使用多态的方式存储评论列表,那我们就可以这么做:

// ... 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 来控制不同的数据表,就可以实现一个简单而强大的评论体系了

  1. 与使用多态后的问题:精彩评论和评论重排序

前端的评论列表的数据层使用了多态后,当有特异性需求的时候就需要有特殊的操作,比如说:
* 文章页面要有评论列表,还要有精彩评论列表,如简书
* 答案列表不仅支持按照分数排序,还要支持按照创建时间重排序,如知乎

因为还没有没有接触过这样的需求,所以暂时不对这种情况进行讨论,以后再说吧╮(╯▽╰)╭

seenIds 的性能瓶颈

当我们提到使用 seenIds 做参数来去重的时候,你就应该想到了当 seenIds 无限增长时,对整个系统意味着什么,他会遇到两个问题:

  1. 如果 API 是一个 GET 请求,那么 seenIds 会被拼在 request url 后面,而 request url 是有长度限制的,因此当数据量太大,并且用户一直翻页的时候,就会报 http error 了
  2. 就算没有报 http error,当传递给后端的 seenIds 过多时,对后端来说这就是一个问题,具体的逻辑可能是这样的:
    1. DB 层前面肯定需要一个 Cache 层,所以我们获取评论列表时其实优先会去从缓存里获取
    2. 每条评论又有子评论,每条评论的点赞量、回复量又是动态的,并且评论在可编辑的情况下,导致每个主评论都是单独存储的,并不会将一篇文章下的所有评论存储在同一个 key 里
    3. 评论列表是动态的,因此也不能每次都去从数据库拿,所以可以将文章下的评论列表的 id 存成一个缓存,由于列表是动态无序的,而是通过某个综合的评分来排序,因此使用 Redis 的有序集合(sorted set)来存储 id 的数组
    4. 每次获取下一页数据时,都把这个有序集合的里的所有数据拿出来,filter 一下,再取出几条(一段 PHP 的 filter 代码:array_slice(array_diff($ids, $seen), 0, $take)),那在 seenIds 的 length 非常的大,并且数据量也非常大的时候,就会有性能瓶颈(未实践)

为了解决性能问题,使用其它方式来获取分页数据(未实践)

  1. 因为 seenIds 的性能瓶颈问题,使用了 minId 来获取下一页数据
  2. 这里的 minId 其实不是一个 id,而是一个 score,后端的 Cache 层依然还是有序集合
  3. 同一个有序集合的 score 是可以重复的,因此这里使用 score 会有两种获取分页数据的方式:
    1. 传给后端 minScore,然后获取小于这个 score 的数据,按照 score 从高到低的 N 条,那么当你看过的某条数据的 score 降低的时候,你就会重复看到这条数据。它不仅会导致重复,还会导致相同 score 的数据不会被你看到
    2. 传给后端 minScore (看过的最小的 score)和 minIds(看过的最小 score 的 id 列表), 然后通过 Redis 的 ZRANGEBYSCORE 操作有序集合,拿到列表里score >= minScore 的 X 条数据(X = minIds.length + N)resIds,然后 filter 这个 resIds,将看过的 minIds 过滤掉,就只剩下 N 条数据了,就算这样也会重复,但它至少更多的情况下能够获取到相同 score 的数据

使用 sinceId 优化动态降序列表

我们在获取动态降序列表时使用了 minId 来处理,其实我们还可以再传一个 sinceId 来优化业务层,它表示你获取到的第一条数据的 id(其实在这种情况下就是 maxId)

  1. 将 sinceId 传递给后端,后端可以判断在这个 id 之后又有几条新的数据被创建了,这样在翻页的时候,就可以提示用户:有几条新动态,QQ空间就是这样做的
  2. 当用户刷新列表(refresh)的时候,可以传递一个 sinceId 给后端,这样后端就不需要发送多余的数据给前端,前端也可以实现一个“上次看到这里”的功能,就像知乎的推荐信息流页面
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,504评论 4 365
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,898评论 1 300
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,218评论 0 248
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,322评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,693评论 3 290
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,812评论 1 223
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,010评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,747评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,476评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,700评论 2 251
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,190评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,541评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,206评论 3 240
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,129评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,903评论 0 199
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,894评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,748评论 2 274

推荐阅读更多精彩内容

  • 人生总是那么短暂,一生中那些美好的时光总是稍纵即逝,尽管我们怎么想抓住都抓不住,最后所有的美好都将会成为回忆。 是...
    乖乖的瑷文阅读 261评论 13 4
  • “这次我一定要离婚!”女人红着眼冲进门,咬牙切齿、斩钉截铁地说。 屋子里,她的哥哥正在盛饭,被突如其来的一嗓子吓得...
    路乔阅读 676评论 0 4
  • 四季的风依然 吹开梅的心扉 风韵悄然綻立枝头 忽然 下了一场雪 雪拥枝头 梅吻雪洁 白天寒夜衷肠互诉 累了,雪给梅...
    攀屹阅读 139评论 0 1
  • 桂花香 早上,家人告诫:今...
    0桂花香0阅读 132评论 1 2