纯函数重构会话列表:筛选、排序与不可变数据实战
会话列表这个东西,看起来简单,做起来烦。我做过好几个带消息模块的项目,每次一开始都觉得"不就是个列表嘛,filter 一下 sort 一下完事",结果做到后面全是 bug:筛选完排序乱了、排序完原数组被改了导致别处渲染异常、切换筛选条件时列表闪一下又跳回去。后来我把整个会话列表的处理逻辑重写成一组纯函数,问题基本消失了。这篇就聊聊我是怎么用纯函数把筛选、排序和不可变数据这三件事串起来的,适合正在做 IM、客服系统、通知中心这类带会话列表的开发者参考,前端后端都能用得上,思路是通用的。
1. 为什么会话列表值得单独抽一层纯函数
很多人第一反应是:会话列表不就是个数组吗,直接在组件里 list.filter().sort() 不就行了。我一开始也这么干,直到项目里出现了这些情况。
第一个问题是同一份数据被多处修改。会话列表在侧边栏渲染一次,在未读计数里用一次,在搜索框里又过滤一次。如果每个地方都直接操作原数组,A 处排完序,B 处拿到的顺序就变了,C 处再筛选一下,A 的渲染直接错乱。这种 bug 特别难查,因为你看单个模块的代码都是对的,问题出在共享了同一个引用。
第二个问题是筛选和排序的顺序耦合。先筛选再排序,和先排序再筛选,结果理论上一样,但性能差很多。如果列表有几千条会话,先排序全部再筛选,等于白排了一大半。而如果逻辑散落在各处,你根本控制不了执行顺序。
第三个问题是测试困难。带副作用的函数没法单独测,你得 mock 一堆状态、模拟渲染环境。而纯函数输入确定、输出确定,给一组数据进去,断言输出就行,几行代码搞定。
所以我的做法是:把会话列表的所有变换逻辑抽成一个独立的模块,里面全是纯函数。所谓纯函数,说白了就三条——相同输入永远得到相同输出、不修改传入的参数、不依赖外部可变状态。听起来像教科书定义,但落到会话列表这个场景,它带来的直接好处是:任何地方拿到原始列表,都能自己算出一份独立的、符合当前需求的视图,互不干扰。
这里有个关键认知要先建立:原始会话数据是"源",筛选排序后的列表是"派生"。源只有一份,派生可以有无数份。纯函数就是那个把源变成派生的转换器,转换过程不碰源。这个心智模型一旦立住,后面所有设计都顺了。
我见过不少项目把"当前显示的列表"当成状态存起来,筛选条件一变就去改这个状态。这就是典型的把派生数据当源数据,迟早出问题。正确的做法是:只存原始列表和筛选条件,显示列表每次现算。现算会不会慢?后面会讲缓存和 memo,先把这个结构定下来。
2. 筛选逻辑怎么设计才不会被条件组合拖垮
会话列表的筛选条件通常不止一个:按关键词搜、按未读筛、按类型(单聊/群聊)筛、按标签筛、按时间范围筛。如果写成一堆 if-else 嵌套,条件一多就爆炸,加一个新维度要改一大片。
2.1 把每个筛选条件拆成独立的谓词函数
我的做法是每个筛选维度写成一个独立的谓词函数,输入是单条会话,输出是布尔值。比如关键词匹配、未读判断、类型判断各写一个。这样每个函数职责单一,好测好改。
注意这里用了柯里化的写法:matchKeyword(keyword) 返回一个函数,这个函数再去判断单条会话。为什么要这样?因为筛选条件是在运行时才知道的,柯里化让"配置条件"和"执行判断"分成两步,组合起来特别灵活。
2.2 用组合器把谓词拼起来
有了独立的谓词,接下来把它们组合成一个总的判断函数。最简单的组合就是"全部满足"(AND 关系):
every 保证所有条件都满足才保留。如果将来有"满足任意一个"的需求,换成 some 就行。这种组合方式的好处是加维度不用改主逻辑,只要多传一个谓词进去。
2.3 筛选顺序对性能的实际影响
前面提到筛选和排序的顺序问题,这里展开说。假设列表有 5000 条会话,排序是 O(n log n),筛选是 O(n)。如果先排序再筛选,排序要处理 5000 条,筛选再扫一遍。如果先筛选再排序,假设筛选后剩 200 条,排序只处理 200 条,差距是数量级的。
所以我的固定顺序是:先筛选,后排序。这个顺序在纯函数链里天然成立,因为 filter 返回新数组,sort 再处理这个新数组,写起来就是链式调用,不会搞反。
提示:筛选条件里如果有"开销大"的判断(比如正则匹配、跨字段模糊搜索),尽量放在谓词链的后面,让便宜的条件先短路掉大部分数据。
every是从左到右执行的,前面的谓词返回 false 就直接跳过后面的了。
2.4 一个容易忽略的坑:空关键词和空条件的处理
我踩过的坑是:关键词为空字符串时,includes('') 永远返回 true,看起来没问题,但如果没做 if (!keyword) return true 的短路,每条会话都要跑一遍字符串匹配,纯属浪费。同理,类型为 null 或 undefined 时也要短路。这些短路判断看着啰嗦,但在大数据量下能省不少 CPU。
还有一个坑是筛选条件的"空值语义"。比如 onlyUnread 传 false 和传 undefined,语义上都是"不筛未读",但代码里如果写成 if (onlyUnread === false) 就会漏掉 undefined 的情况。统一用真值判断(if (!onlyUnread) return true)更稳。
3. 排序:多字段、稳定性和不可变的三个关键点
排序是会话列表里最容易出问题的地方,因为需求往往很复杂:置顶的排最前,然后按最后消息时间倒序,时间相同的按未读数排。这三个要求叠在一起,坑就来了。
3.1 多字段排序的比较器怎么写
JavaScript 的 Array.prototype.sort 接受一个比较函数,返回负数表示 a 在前,正数表示 b 在前,0 表示相等。多字段排序的本质是逐字段比较,前面的字段分出胜负就不看后面的:
这个比较器逻辑很直白,但有几个细节要注意。第一,布尔值比较不要用减法,true - false 虽然等于 1,但可读性差,用 a.pinned ? -1 : 1 更清楚。第二,时间戳相减得到的是毫秒差,直接返回没问题,但如果时间字段是字符串(比如 ISO 格式),要先转成时间戳再比,字符串比较在跨时区时会出错。
3.2 稳定排序到底稳不稳
"稳定排序"指的是:相等的元素在排序后保持原来的相对顺序。这在会话列表里很重要——如果两条会话时间完全一样,你希望它们保持原来的顺序,而不是每次排序都跳来跳去。
好消息是,现代 JavaScript 引擎(V8、SpiderMonkey 等)的 Array.prototype.sort 从 ES2019 开始保证稳定。坏消息是,如果你的比较器写得有问题,比如该返回 0 的时候返回了非 0,稳定性也救不了你。
我遇到过一个真实案例:比较器里用了 a.id - b.id 作为最后的兜底,结果 id 是字符串(比如 "conv_10" 和 "conv_9"),相减得到 NaN,sort 遇到 NaN 的行为是未定义的,列表顺序随机乱跳。后来改成 a.id.localeCompare(b.id) 才稳定。所以兜底比较一定要用类型匹配的方式,数字用减法,字符串用 localeCompare。
3.3 不可变排序:为什么不能直接 sort
这是重灾区。Array.prototype.sort 是原地排序,它会直接修改原数组。如果你把原始会话列表直接 sort 了,所有引用这个列表的地方都会受影响。
[...originalList] 是浅拷贝,对于排序来说足够了,因为我们只是调整元素顺序,不修改元素本身。但要注意,浅拷贝只复制了引用,如果排序过程中修改了元素内部属性,原元素还是会被改。排序只读不写,所以没问题。
如果项目里用 TypeScript,可以配合 readonly 类型让编译器帮你把关:
readonly Conversation[] 表示这个数组不能被修改,你一旦写 list.sort() 编译器就报错。这个约束在团队协作里特别有用,能挡住很多手滑。
3.4 排序性能:大数据量下的取舍
5000 条以内的会话列表,直接排序完全没问题,几十毫秒的事。但如果你的场景是几万条(比如企业级客服系统),每次筛选都全量排序就有点吃力了。
我的优化思路是分层排序:置顶会话通常很少,单独维护一个置顶列表,排序时只排非置顶部分,最后拼接。这样排序的数据量小很多。另一个思路是增量排序,新消息到达时只调整这一条的位置,而不是全量重排。不过增量排序逻辑复杂,容易出 bug,除非性能真的扛不住,否则我倾向于全量重排,简单可靠。
注意:不要过早优化。我见过有人为了"性能"把排序写成手动维护的有序插入,结果插入逻辑有 bug,列表顺序偶尔错乱,排查花了两天。全量排序 + memo 缓存,在绝大多数场景下够用了。
4. 不可变数据:不只是"不改原数组"那么简单
不可变数据这个词被说烂了,但真正理解它在会话列表里的价值,需要从"数据流"的角度看。
4.1 不可变带来的可预测性
假设你的应用里有三个地方用到会话列表:侧边栏、搜索结果、未读角标。如果列表是可变的,A 处改了顺序,B 处和 C 处都会受影响,而且你很难追踪是谁改的。如果列表是不可变的,每个地方拿到的都是独立的快照,A 处的操作不会影响 B 处,出了问题只需要看 A 处的代码。
这就是引用透明的好处:同一个输入,永远得到同一个输出,不依赖也不影响外部状态。调试的时候,你可以把任意一个中间结果打印出来,它不会因为你继续往下执行而改变。
4.2 浅拷贝够不够用
前面排序用了浅拷贝,筛选用的 filter 本身也返回新数组,所以数组层面是安全的。但会话对象本身呢?如果某个操作修改了 conv.unreadCount,那所有引用这个对象的列表都会看到变化。
我的原则是:列表变换只读不写元素。筛选、排序、分组这些操作都不应该修改会话对象。如果确实需要更新某条会话(比如标记已读),那应该走"替换"而不是"修改":
{ ...c, unreadCount: 0 } 创建了一个新对象,原对象不动。这样任何持有旧列表的地方都不会受影响,只有拿到新列表的地方才会看到更新。这个模式在 React 状态管理里是标配,但很多人只在组件里这么做,忘了在工具函数里也保持一致。
4.3 用结构共享降低拷贝成本
有人会担心:每次都拷贝,数据量大时内存和性能扛不住。这个担心在会话列表场景下基本不成立,因为浅拷贝只复制引用,不复制对象内容。一个 5000 条的数组,浅拷贝就是复制 5000 个指针,微秒级的事。
真正需要注意的是深层嵌套结构。如果会话对象里有嵌套的 lastMessage 对象,你改 lastMessage.content 时如果直接改,还是会污染原对象。这时候要么手动逐层拷贝,要么用 structuredClone(现代浏览器和 Node 都支持)做深拷贝。但深拷贝开销大,我的建议是尽量保持数据结构扁平,会话对象里不要嵌套太深,需要嵌套的地方用 id 引用而不是内联对象。
4.4 和状态管理库的配合
如果你用 Redux、Zustand 这类状态管理库,不可变数据是它们的核心要求。纯函数天然契合:reducer 就是纯函数,输入旧状态和 action,输出新状态。会话列表的筛选排序逻辑放在 selector 里,selector 也是纯函数,从 store 里读原始列表,算出派生列表。
配合 reselect 这类库做 memo,只有 list、filters、sortKey 变化时才重新计算,否则返回缓存结果。这样既保证了不可变,又避免了重复计算。
5. 把筛选、排序、不可变串成一条流水线
前面几块单独讲完了,现在把它们串起来。我的做法是定义一个 buildConversationView 函数,输入原始列表和视图配置,输出最终的显示列表:
这个函数是纯的:不修改 list,不依赖外部状态,相同输入相同输出。调用方拿到结果后随便用,不用担心影响别人。
5.1 流水线的顺序为什么不能反
有人可能想先排序再筛选,觉得"反正结果一样"。结果确实一样,但性能不一样,前面算过。更重要的是,先筛选后排序在语义上更清晰:筛选是"选出关心的",排序是"给选出的排个序"。反过来读起来别扭,维护的人容易搞混。
如果将来要加分组(比如按日期分组),顺序应该是:筛选 → 排序 → 分组。分组依赖排序结果,所以排序必须在分组前。这条流水线的顺序是有依赖关系的,不能随意调换。
5.2 缓存与 memo:什么时候该加
纯函数的好处之一是容易缓存,因为相同输入必然相同输出。但不是所有纯函数都值得缓存。我的判断标准是:计算量大 + 调用频繁才加缓存。
会话列表的筛选排序,如果列表大(几千条以上)且每次渲染都调用,那就值得 memo。实现方式很简单,缓存上一次的输入和输出,输入没变就直接返回:
注意这里用的是引用相等(===)判断,所以要求输入是不可变的——如果输入被原地修改了,引用没变但内容变了,缓存就会返回错误结果。这又回到了不可变的重要性:不可变是缓存正确性的前提。
5.3 测试:纯函数的最大红利
纯函数最好测,没有之一。给一组输入,断言输出,不需要 mock,不需要环境。我一般会覆盖这几类用例:
| 测试类型 | 输入 | 期望 |
|---|---|---|
| 空列表 | [] |
返回 [] |
| 无筛选条件 | 完整列表 | 返回排序后的完整列表 |
| 关键词筛选 | 含关键词的列表 | 只返回匹配项 |
| 多条件组合 | 关键词+未读 | 返回同时满足的项 |
| 排序稳定性 | 时间相同的两条 | 保持原相对顺序 |
| 不可变性 | 任意列表 | 原列表未被修改 |
最后一条"不可变性"测试特别重要,写法也简单:
跑一遍确认原数组没被动过。这个测试能挡住 90% 的"不小心改了原数组"的 bug。
6. 实战中踩过的几个坑和应对
理论讲完了,说几个我实际踩过的坑,都是文档里不会写的。
6.1 筛选条件对象引用变化导致的无限重渲染
在 React 里,如果筛选条件对象每次渲染都新建({ keyword: '' }),memo 的引用比较永远不相等,缓存失效,每次渲染都重算。更糟的是,如果这个对象作为 useEffect 的依赖,还会触发无限循环。
解决办法是保持筛选条件对象的引用稳定,用 useMemo 包一层,或者把条件拆成基本类型分别传。我现在的习惯是筛选条件用扁平的基本类型参数,不用对象,从根上避免这个问题。
6.2 时间戳精度导致的排序抖动
有次线上反馈说会话列表偶尔会"跳",两条会话顺序不稳定。查了半天发现是时间戳精度问题:后端返回的时间戳是秒级,同一秒内到达的多条消息时间戳相同,排序时相对顺序取决于原始数组顺序,而原始数组顺序又取决于接口返回顺序,接口顺序偶尔变,列表就跳。
解决办法是加一个稳定的兜底排序键,比如消息 id 或会话 id。时间相同时按 id 排,顺序就固定了。这个兜底键必须是全局唯一且不变的,别用可能重复的字段。
6.3 置顶会话的排序陷阱
置顶会话要排最前,这个逻辑本身简单,但有个坑:置顶会话之间也要排序。我见过有人只做了"置顶的排前面",置顶会话内部顺序是乱的。正确做法是比较器里先比 pinned,置顶的之间继续比时间,非置顶的之间也比时间。前面给的比较器就是这么写的,pinned 相同的情况下会继续往下比。
6.4 空列表和单元素列表的边界
sort 对空数组和单元素数组是安全的,直接返回。但筛选逻辑里如果有 list[0] 这种访问,空列表就会报错。我的习惯是所有函数开头先处理空列表,if (!list || list.length === 0) return [],省得后面到处判空。
6.5 国际化下的字符串排序
如果会话标题是多语言的,localeCompare 要指定 locale,否则不同环境排序结果可能不一致:
不指定的话,浏览器默认 locale 可能不同,导致同一份数据在不同用户那里顺序不一样。这个坑在跨国产品里特别常见。
7. 从纯函数到工程化:怎么落地到项目里
最后聊聊怎么把这套东西落地。我的建议是单独建一个模块,比如 conversation-view.js,里面只放纯函数,不引入任何框架依赖。这样前端框架换了、后端语言换了,逻辑还能复用(思路复用,代码重写)。
模块的导出大概是这样:
调用方只依赖这几个函数,不关心内部实现。测试文件单独写,覆盖前面列的用例。如果团队用 TypeScript,把类型定义也放进去,Conversation、FilterConditions、SortKey 这些类型统一定义,各处引用同一份,避免类型漂移。
性能监控方面,可以在 buildConversationView 里加一个耗时打点,超过阈值(比如 50ms)就上报。这样线上如果列表变卡,能第一时间发现是筛选排序的问题,而不是盲目猜。
我个人在实际项目里的体会是:纯函数不是银弹,但它把"数据变换"这件事的复杂度降到了最低。会话列表的筛选排序本身不难,难的是在复杂的应用状态里保持行为可预测。纯函数 + 不可变数据这套组合,本质上是用一点拷贝开销换来了可预测性和可测试性,这笔买卖在绝大多数业务场景下都是划算的。真正需要极致性能的地方(比如每秒处理几万条消息的推送系统),再考虑用可变数据结构做优化,但那是另一个话题了。
相关推荐
基于Python+Open CV的手势识别算法设计
伴随着人工智能时代的到来,人机交互的领域也逐渐成为研究的一大方向;其中,手势识别是人机交互领域的一项关键技术,自此手势识别也迎来了一波高潮,近几年无论是在消费领域、电子领域、数码领域、家电领域甚至汽车领域上,都能或多或少的见到手势识别的身影。 手势识别在设计智能高效的人机界面方面具有至关重要的作用, 目前手势识别已应用到手语识别、智能监控、到虚拟现实等各个领域,手势识别的原理都是利用各种传感器(例如红外、摄像头等)对手部的形态进行捕捉并进行建模,形成一个模型信息的序列帧,而后将这些信息序列转换为机器能够识别的相对应指令(例如打开、切换菜单、移动等)来完成控制。 采用Python的集成开发环境Pycharm进行本次课程设计,在Pycharm中进行需要库(模块)的下载,调取电脑摄像头,按帧读取摄像头采集到的头像,形态学处理,图像旋转(由于摄像头采集到的图像是镜像,需要用cv2.flip将摄像头采集到的图像进行处理),选取图片中固定位置作为手势输入,用红线画出手势识别框,基于hsv的肤色检测,进行高斯滤波,找出轮廓,求出图像中手势的凹凸点,手指间角度求取。
ROS-CV-ImageConverter:将ROS图像转换为OpenCV(cv :: Mat)类型的图像
ROS-CV图像转换器 依存关系 cv_bridge(ROS软件包)
计算机视觉图像处理-CV2与图像插值算法
用最近邻插值与双线性插值原理完成图像缩放 最近邻插值,是指将目标图像中的点,对应到源图像中后,找到最相邻的整数点,作为插值后的输。缺点:使用最近邻插值算法处理图像放大时,在图象中可能出现明显的块状效应 双线性插值,在两个方向上做三次线性插值。使用双线性插值算法处理图像放大时,在图象中可能出现马赛克 代码 import cv2 img = cv2.imread(’./xiabang.jpg’, cv2.IMREAD_UNCHANGED) #缩放比例 scale_percent = 0.3 width = int(img.shape[1] * scale_percent) height = int
基于水平集CV模型的图像分割(Matlab代码)
基于水平集CV模型的图像分割(Matlab代码),全套代码
导向滤波(guide_filter)的python实现
导向滤波(guide_filter)的python实现,可以结合暗通道;理论实现比较好的图像去噪效果。

