纯函数整理会话列表:不可变数据与多级排序实战
1. 从一个真实场景说起:会话列表为什么值得单独抽一个纯函数
做过即时通讯、客服系统、协同办公这类产品的人,大概率都碰过同一个坑:会话列表的逻辑一开始写得特别随意,谁在线就把谁排前面,谁发了新消息就把谁顶上去,未读的加个红点,置顶的再往上提一提。功能跑起来没问题,可一旦需求开始叠加——要支持免打扰、要支持多端同步、要支持按标签筛选、要支持搜索历史会话——代码就会迅速烂成一团。
我印象很深的一次,是接手一个客服工作台项目。会话列表的排序规则散落在三个地方:WebSocket 收到新消息时改一次顺序,用户点击某个会话时改一次顺序,页面初始化拉取历史数据时又排一次。三处逻辑各写各的,结果就是同一个会话在不同时机的位置对不上,用户反馈“刚聊过的客户怎么跑到下面去了”。排查了两天才发现,是其中一处排序漏掉了置顶优先级的判断。
这件事之后我就定了个规矩:会话列表的筛选和排序,必须收敛成一个纯函数。输入是原始会话数组加上当前的筛选条件、排序偏好,输出是一个全新的、排好序的数组。整个过程不碰 DOM、不发请求、不改原数组、不读全局变量。这样一来,逻辑只有一份,测试好写,多端复用也方便。
这篇内容就是围绕这个思路展开的。我会把“纯函数整理会话列表”这件事拆开讲透:为什么一定要用不可变数据、筛选和排序的规则怎么设计、排序的稳定性为什么关键、性能瓶颈在哪里、以及我在实际项目里踩过的那些坑。适合正在做 IM、客服系统、消息中心、通知列表的前端或全栈同学参考,也适合任何需要处理“带多条件的列表排序”场景的开发者。
2. 为什么必须是纯函数:不可变数据背后的真实收益
2.1 纯函数的定义落到会话列表上到底是什么
纯函数这个概念,教科书上的定义是“相同输入必然得到相同输出,且没有副作用”。放到会话列表这个场景里,它意味着几件很具体的事。
第一,函数不能修改传入的会话数组。你不能用 sort 直接对原数组排序,因为 JavaScript 的 Array.prototype.sort 是原地排序,会改变原数组。第二,函数不能依赖外部状态,比如不能去读 window.currentUserId 或者某个全局的 store,用户 ID、当前时间、置顶配置这些都得作为参数传进来。第三,函数不能产生副作用,不能在里面发请求、写缓存、改 DOM。
我见过不少人觉得“不就是排个序嘛,搞这么严格干嘛”。但恰恰是这种“随手改一下原数组”的习惯,导致了大量难查的 bug。举个真实的例子:某个会话对象被两个组件同时引用,A 组件调用了排序函数,原数组被改了,B 组件拿到的数据顺序莫名其妙就变了,然后 B 组件基于错误的顺序做了渲染,红点位置全乱。这种问题在开发环境不一定复现,线上偶发,排查成本极高。
2.2 不可变数据带来的三个实际好处
好处一:状态可追溯。 每次筛选排序都产生一个新数组,旧数组原封不动。这意味着你可以随时回滚到任意一个历史状态。在 Redux 或者类似的状态管理里,这直接对应时间旅行调试。用户反馈“刚才还好好的,点了一下就乱了”,你可以把操作前后的两个状态都打出来对比,问题一目了然。
好处二:引用比较高效。 React 这类框架判断组件要不要重新渲染,靠的是引用是否变化。如果你原地修改数组,引用没变,框架认为数据没变,就不重新渲染,界面和数据对不上。而每次返回新数组,引用必然变化,渲染判断准确。这个坑我在早期用 React 做消息列表时踩过,明明数据更新了界面不动,查了半天才发现是原地 sort 惹的祸。
好处三:并发安全。 现代前端越来越强调并发渲染,多个更新可能交错进行。如果排序函数会修改共享的原数组,两个并发更新就可能互相干扰。纯函数每次操作自己的输入,输出独立的新数组,天然避免了这类竞争问题。
2.3 一个反例:原地排序引发的血案
我拿一段简化代码说明问题。假设会话数组长这样:
如果排序函数写成这样:
调用之后,conversations 本身的顺序也被改了。如果这个数组是从缓存里拿的,缓存就被污染了。下次另一个页面拿到这个缓存,顺序已经不是你预期的原始顺序。更隐蔽的是,如果这个数组被 Object.freeze 冻结过,sort 会直接抛错,而且报错信息不一定指向真正的问题源头。
正确的写法是先复制再排序:
一行 [...list] 的差别,决定了这个函数是不是纯的。别小看这一行,它是整个方案的地基。
3. 筛选规则怎么设计:从业务需求倒推数据结构
3.1 先搞清楚要筛什么
会话列表的筛选条件,不同产品差别很大,但归纳下来无非几类。第一类是状态筛选,比如只看未读、只看置顶、只看免打扰。第二类是类型筛选,比如只看群聊、只看单聊、只看机器人会话。第三类是关键词筛选,按会话名称或最近消息内容搜索。第四类是时间筛选,比如只看今天的、只看最近七天的。
这些条件往往是叠加的,用户可能同时要求“未读 + 群聊 + 关键词包含‘订单’”。所以筛选函数的输入应该是一个条件对象,而不是一堆散落的布尔参数。条件对象的好处是可扩展,以后加新条件不用改函数签名。
3.2 筛选条件的组合逻辑要明确
这里有个容易含糊的地方:多个条件之间是“与”还是“或”?绝大多数场景是“与”,即所有条件都要满足。但类型筛选内部是“或”,比如 types: ['group', 'single'] 表示群聊或单聊都算。这个层级关系必须在实现时想清楚,否则用户会发现筛选结果不符合直觉。
我的做法是把筛选拆成一组独立的判定函数,每个函数负责一个维度,最后用 every 组合:
这种写法的好处是每个条件独立可测,加新条件只是多 push 一个判定函数,不影响已有逻辑。而且 filter 本身返回新数组,天然满足不可变要求。
3.3 关键词筛选的性能陷阱
关键词筛选看起来简单,实际上最容易出性能问题。如果会话数量上千,每次输入一个字符就全量遍历一遍,输入框会明显卡顿。我试过在一个两千会话的测试数据集上,每敲一个字母都触发一次全量 filter,输入延迟能到 200 毫秒以上,体验很差。
解决办法有两个方向。一是防抖,输入停止 200 到 300 毫秒后再执行筛选,这个在 UI 层做。二是预建索引,把会话名称和最近消息提前转成小写并缓存,避免每次筛选都做 toLowerCase。如果数据量特别大,还可以考虑把关键词匹配换成更高效的算法,比如先按首字符分桶,但大多数业务场景防抖加缓存就够了。
提示:关键词筛选一定要对大小写做统一处理,否则用户搜“Order”搜不到“order”,会被当成 bug 反馈。中文场景还要考虑是否支持拼音首字母,这个需求很常见,但实现成本不低,要提前和产品确认。
4. 排序规则才是重头戏:多级优先级与稳定性
4.1 会话列表的排序通常不是单一维度
如果只是按最后消息时间倒序,那太简单了。真实的会话列表排序,几乎都是多级优先级。我总结了一个比较通用的优先级模型,从高到低大致是:置顶优先、然后按是否有未读、再按最后消息时间、最后按会话 ID 兜底。
为什么要有“会话 ID 兜底”?因为如果两个会话的最后消息时间完全相同(比如系统批量推送的消息,时间戳一样),没有兜底规则的话,排序结果就不稳定,每次渲染顺序可能不一样,用户会看到列表在跳动。加上 ID 比较,保证结果确定。
4.2 排序稳定性为什么不能忽视
JavaScript 的 Array.prototype.sort 在现代引擎里已经是稳定排序了,也就是说相等元素的相对顺序会保持。但“稳定”的前提是你的比较函数对相等元素返回 0。如果你写的比较函数在相等时返回了非 0 的值,稳定性就无从谈起。
更麻烦的是,很多人在比较函数里写了不满足传递性的逻辑。比如“置顶的排前面,未读的排前面,但如果既置顶又免打扰就排后面”,这种规则如果没理清楚,可能出现 A 排在 B 前、B 排在 C 前、但 C 又排在 A 前的循环,排序结果就完全乱了。所以比较函数的每一条规则都必须是清晰的全序关系,不能有模糊地带。
我的经验是,把比较逻辑写成一条条独立的、有明确优先级的判断,每一条都返回确定的 -1 或 1,只有全部相等才返回 0。这样既保证了稳定性,也方便单测覆盖。
4.3 排序偏好应该可配置
不同用户对会话列表的排序偏好不一样。有人希望未读的永远在最上面,有人希望严格按时间,置顶除外。所以排序规则不应该写死,而应该由外部传入一个偏好配置。
然后比较函数根据这个配置动态决定优先级顺序。这样同一套代码可以服务不同用户,也方便做 A/B 测试。实现上就是把上面的比较函数参数化,把每个优先级判断包在 if (preference.xxx) 里。注意配置本身也要作为参数传入,不能从全局读,否则又破坏了纯函数特性。
5. 把筛选和排序串起来:完整实现与参数计算
5.1 主函数的整体结构
有了筛选和排序两块,主函数就是把它们串起来。输入是原始列表、筛选条件、排序偏好、以及一些上下文(比如当前用户 ID、当前时间),输出是处理好的新数组。
注意这里 filter 已经返回了新数组,理论上再 sort 不会影响原数组。但为了保险,我还是习惯再复制一次。多一次浅拷贝的开销很小,换来的是绝对不会污染上游数据,这笔账划算。
5.2 上下文参数该传什么
context 这个参数容易被忽略,但它很关键。会话列表的排序有时依赖当前用户,比如“我发出的最后一条消息”和“对方发出的最后一条消息”可能有不同的展示优先级。还有“当前时间”用于计算“今天”“昨天”这类分组。这些都不能从函数内部去读,必须显式传入。
把 now 传进来而不是在函数里调 Date.now(),还有一个好处是测试时可以固定时间,让测试结果可复现。这是纯函数带来的额外收益,很多人一开始想不到。
5.3 性能实测与优化取舍
我在一个包含 1500 个会话的数据集上做过实测。纯函数版本的完整流程(筛选加排序)在普通笔记本上大约耗时 8 到 15 毫秒,这个量级对于一次渲染来说完全可以接受。但如果每次收到新消息都全量重算,高频消息场景下累积起来就有点吃力。
优化思路是分层处理。筛选结果可以缓存,只有筛选条件变化时才重新筛选。排序则在新消息到达时增量更新,把变化的会话移到正确位置,而不是全量重排。不过增量更新会破坏纯函数的简洁性,所以我的建议是:先用纯函数全量处理,把正确性做扎实,等真的遇到性能瓶颈再考虑增量优化。过早优化往往得不偿失。
| 数据规模 | 全量筛选+排序耗时 | 建议策略 |
|---|---|---|
| 100 以内 | 1 毫秒以内 | 全量处理,无需优化 |
| 100 到 1000 | 3 到 10 毫秒 | 全量处理,配合防抖 |
| 1000 到 5000 | 10 到 50 毫秒 | 缓存筛选结果,排序增量更新 |
| 5000 以上 | 50 毫秒以上 | 虚拟列表加索引,考虑分页 |
6. 常见问题与排查技巧实录
6.1 排序结果和预期不一致怎么查
这是最高频的问题。排查顺序我一般是这样的:先确认比较函数是否满足传递性,随便造三个会话,两两比较一遍,看有没有矛盾。再确认是否有 undefined 或 null 参与比较,比如某个会话的 lastMessageTime 缺失,undefined - 100 得到 NaN,比较函数返回 NaN,排序行为就未定义了。最后确认是否有多处排序逻辑在打架,比如组件里又排了一次。
注意:任何参与比较的字段都要保证有默认值。时间戳默认 0,字符串默认空串,布尔默认 false。别指望数据一定完整,线上什么脏数据都有。
6.2 筛选后列表为空但明明有数据
这种问题通常是条件组合出了错。比如 types 传了 ['group'],但数据里的类型字段是 'GROUP' 大写,includes 匹配不上。或者关键词筛选时没做大小写统一。还有一种情况是时间范围传反了,start 大于 end,自然什么都筛不出来。我的习惯是在筛选函数里加一个开发环境的断言,条件明显非法时直接报错,而不是静默返回空数组。
6.3 列表渲染时顺序跳动
前面提过,根因通常是排序不稳定。两个会话比较结果相等,但底层排序算法在相等时顺序不确定。解决办法就是加兜底比较字段,通常是 ID。另外要检查数据源本身是否稳定,如果每次拉取数据的顺序都不一样,那排序前最好先按 ID 做一次归一化。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 排序结果乱 | 比较函数不满足传递性 | 检查每条规则是否返回确定值 |
| 筛选结果为空 | 条件组合错误或类型不匹配 | 打印条件对象和样本数据对比 |
| 原数组被修改 | 直接用了 sort 或 splice | 检查是否先做了浅拷贝 |
| 列表顺序跳动 | 排序不稳定 | 增加 ID 兜底比较 |
| 输入关键词卡顿 | 全量筛选无防抖 | 加防抖和字段缓存 |
| 多端顺序不一致 | 排序规则未统一 | 把规则收敛到同一个纯函数 |
6.5 几个我踩过的坑
第一个坑是浅拷贝不够深。[...list] 只复制了数组本身,里面的会话对象还是共享引用。如果排序过程中修改了会话对象的某个字段,原数据还是会被污染。所以纯函数里绝对不能修改元素对象,只能读。要改就返回新对象。
第二个坑是把排序偏好存在了闭包外。有次我把用户偏好放在模块级变量里,函数直接读它。结果用户切换偏好后,旧函数实例还拿着旧偏好,排序不更新。后来改成偏好必须作为参数传入,问题消失。
第三个坑是忽略了时区。时间筛选如果按“今天”来算,不同时区的“今天”边界不一样。跨时区产品必须把时区作为上下文传进来,用统一的时区计算边界,否则用户会发现筛选结果差一天。
7. 测试与维护:让这套逻辑长期可靠
7.1 纯函数最大的红利就是好测试
因为输入输出完全确定,测试写起来非常直接。给定一组会话和条件,断言输出数组的 ID 顺序即可。不需要 mock 任何东西,不需要启动浏览器,跑得飞快。
我一般会覆盖这几类用例:置顶优先级、未读优先级、时间倒序、多条件筛选叠加、空列表、单元素列表、字段缺失的脏数据。这几类覆盖到,基本就不会出大问题。
7.2 规则变更时的维护策略
会话列表的排序规则是会变的,产品经理今天说未读优先,明天可能改成免打扰的沉底。纯函数的好处是规则变更只改一个地方。我的做法是把每条优先级规则写成独立的比较器,用数组管理优先级顺序,改规则就是调整数组顺序或者增删比较器,不动主流程。
这样即使规则频繁调整,代码结构依然清晰,测试也只需要针对变化的比较器补充用例。
7.3 跨端复用的注意点
纯函数不依赖任何平台 API,理论上可以直接在 Web、小程序、React Native 甚至服务端复用。但要注意语言差异,如果多端语言不同,就得各写一份,这时候保持逻辑一致就靠测试用例对齐。我的经验是把测试用例的输入输出整理成一份共享的 JSON 数据,各端都用这份数据跑测试,保证行为一致。
最后分享一个我个人的习惯:每次会话列表相关的问题修完,我都会回头看看是不是某个规则没收敛到纯函数里。十有八九,问题都出在“某处偷偷绕过了纯函数直接改了数据”。把这条守住,会话列表这块的稳定性会有质的提升。