IndexedDB 聊天记录分页与搜索

Jul 27·6 min
AI 生成的摘要
商业 IM 软件 Web 端用 IndexedDB 缓存聊天记录。记录本地分页、HTTP 校准,以及搜索旧消息后怎样重新组织展示窗口。

商业 IM 软件的 Web 工作台需要往上翻聊天记录,也需要搜索后跳到某条旧消息。两种操作最后都显示在同一个列表里,取数据的方式却不太一样。

这里用 IndexedDB 存已经拿到的消息,状态层另外保存当前展示的一段。下面记一下分页和搜索是怎么接起来的。

本地缓存和当前窗口是两回事

项目的 Web 使用 IndexedDB 保存消息,状态层另外维护各会话的分页列表。

数据库中有一条消息,不意味着它现在就在页面里;页面正在展示一段旧历史,也不意味着客户端只同步到了那一段。

因此需要区分三个范围:

范围负责什么
本地消息缓存保存浏览器已经获取的消息
当前分页窗口保存当前用于展示的那一段消息
与服务端同步的进度确定还需要补取哪些数据

它们可能用到相同的消息 ID,但用途不同。尤其是在搜索跳转以后,不能把窗口末尾的旧消息直接当作整个会话的最新同步位置。

本地库也要知道会话范围

Web 的消息存储保留 encryptedData,同时把用于查询的 roomidtimestampsourceGroupJid 放在外层。

其中一个复合索引是:

snippet
typescript
['roomid', 'sourceGroupJid', 'timestamp']

它对应的查询需求比较明确:在某个房间、某个来源群范围内,按时间查消息。

索引是跟着查询设计的。如果只按房间取出所有消息,再在每个调用方补来源群过滤,既增加处理量,也容易在某个分支漏掉范围条件。

这里的正文加密与查询索引也体现了取舍:外层索引字段仍然可见。不能因为有 encryptedData,就把这套浏览器缓存描述为端到端加密。

分页不能假设本地数据完整

离线期间的新消息、首次打开的历史范围,都可能还没有进入本地库。

当前 loadRoomMessagesByPage 大致按下面的顺序工作:

  1. 按会话范围和边界读取本地分页。
  2. 如果能构造历史请求,向服务端校准这一段。
  3. 转换远端消息,把新增消息写入本地库。
  4. 再从本地库读取这一页。
  5. 更新分页状态和窗口。

远端校准失败时,会保留前面读到的本地分页结果作为退路。

这里注意一下执行顺序:当前函数是在校准完成或失败之后,才更新分页窗口。它不是读完本地就立即更新界面、同时在后台校准的流程。

这两种做法各有取舍。先展示本地可以减少等待感,但之后的合并可能改变窗口,需要处理滚动位置;等待校准再更新,数据更新更集中,却可能把远端耗时带进这次加载。

如果以后调整为分两次展示,需要连同用户已经滚动、已经切换会话等情况一起处理,不能只把一个赋值语句提前。

用消息边界表达向前和向后

项目中的历史请求会根据方向设置字段:向更早的历史查询使用 beforeOriginId,向更新的消息查询使用 afterOriginId

下面是请求构造中的核心分支:

snippet
typescript
if (boundaryId) {
  if (direction === 'newer') {
    request.afterOriginId = boundaryId
  }
  else {
    request.beforeOriginId = boundaryId
  }
}

它表达的是“从这条消息继续往某个方向取”,比会话持续新增消息时单纯依赖页码,更容易说清楚窗口边界。

但双向字段本身不保证分页正确。服务端是否包含边界消息、返回顺序是什么、同一时间戳如何处理,都需要有一致约定。

本地窗口合并会按消息 ID 去重,再按时间排序。去重能够处理窗口交叠,却不能把没有获取到的消息变出来;边界定义错误造成的遗漏,仍然要从请求和服务端契约上解决。

搜索跳转需要的是上下文

搜索命中一条消息以后,如果只把这条消息塞进页面,用户仍然不知道前后发生了什么。

项目的 loadMessageContextAndUpdateView 会调用本地库加载目标消息附近的上下文,默认上下文大小是 10,然后替换对应会话的分页窗口,并计算两个方向的后续加载状态。

这和“再加载一页历史”有明显区别:普通翻页是在当前窗口边缘延伸;搜索跳转是围绕目标消息重新确定窗口。

当前这个入口直接读取本地上下文。本地没有目标消息时,这个入口还不能自动去服务端补上下文,需要另外处理。

同样,窗口位于旧消息附近时,“下面还有较新的消息”和“整个会话没有同步到最新”是两件事,不能共用一个含义模糊的状态。

hasMore 也是一种判断

分页需要告诉 UI 是否还可以继续加载。当前实现会看本地重新读取的数量,或者远端返回的数量,是否等于请求的 limit

满页说明可能还有下一页,但它不能证明一定有下一页。最后一批刚好填满时,可能要再查询一次,才能知道已经结束。

这本身是可以接受的交互选择,前提是调用方理解这个状态表达的是继续尝试的依据。若要更精确地判断,就需要服务端返回明确的后续游标或结束信息。

我会特别检查满页但已到末尾、窗口有交叠、搜索后双向翻页,以及同一聊天对象不同来源群的切换。这些场景更能反映分页模型是否清楚,而不只是看列表能不能滚动。

后面如果改成先显示本地、再更新远端结果,我会把滚动位置一起处理。否则数据是快了,列表跳一下,使用起来还是不舒服。

最后修改时间: Sep 15
cd ..