1. 重构前的乱麻:1.x版本的交互、视觉、逻辑为什么会互相牵制
MsgHelper 的1.x版本其实并不算不能用,但它属于典型的“能跑但是不敢碰”的代码。上个月完成2.0重构收尾之后,我把三个维度的改动拆出来复盘,发现最值得写的不是改了什么,而是“为什么这些改动必须一起做、而不能像1.x那样东补一块西补一块”。这篇是系列第二篇,上一篇讲的是整体架构和数据迁移,这篇专门落在交互、视觉与逻辑三个底层维度,且会附带一些性能实测和踩坑记录。
先说1.x的问题现场。旧版的消息列表每次进入会话都会触发一次完整请求,接口回来后直接把整个数组塞进状态,再整体渲染一遍。数据规模小的时候没问题,可一旦某个客服号有上万条历史消息,页面就会明显发卡,滚动时会频繁掉帧。这个问题的本质不是“渲染慢”,而是没有分层:拉取逻辑、状态保存、列表渲染全耦合在一个组件里,任何一处的状态变化都会引起整棵子树的重新计算。
交互上的问题更隐晦。旧版点击任意一条会话,立刻把该会话的未读计数清零,同时消息列表被重新请求覆盖。如果不小心点到一条并不想看的会话,未读状态就永久丢失了,没有撤销路径。草稿也相当尴尬:编辑器里的内容只存在组件局部 state 里,切换会话后直接丢,很多用户因此被迫把内容先复制到剪贴板再手动粘贴回来。
视觉层面则缺少一套稳定的基础单位。颜色散落在各个样式文件里,同一种“主色”在正文、按钮、标签三个位置分别写了三个十六进制值,深浅还略有差异。字体大小没有阶梯约束,小到 10px、大到 22px 混用。1.x 后期加的暗色模式更是直接叠了一套覆盖样式,导致某些卡片在暗色下对比度不足,看起来像蒙了一层灰。
所以这次重构我给自己定了几条硬约束:不能动用户已有的历史数据格式,必须兼容旧的本地缓存;整体迭代周期控制在三个月内;团队人数不多,所以新的技术方案必须足够简单,不能引入需要长期维护的复杂框架。所有的视觉、交互、逻辑改动都要围绕这三条约束展开,而不是为了“技术先进”而换技术。
2. 交互优化:从“功能可用”到“操作有预期”
2.1 三栏布局的取舍与窄屏兜底
1.x 用的是经典三栏布局:左侧频道列表、中间会话列表、右侧消息详情。固定三分之后,窄屏设备下消息详情区被压到不足五分之二宽度,长文本阅读非常难受。
2.0 的做法是保留宽屏三栏,但在窄屏下把消息详情改成叠层抽屉,会话列表始终占据主视口。宽度的分栏记忆存在本地,用户调整过比例后下次打开保持一致。抽屉打开和关闭用同一个转场方向,并且在打开时自动把未读会话的标题带入顶部栏,避免用户失去上下文。
这个改动的本质是“让布局适应用户的当前任务,而不是要求用户去适应布局”。宽屏下三栏信息密度高,适合快速扫描;窄屏下详情作为临时层出现,专注度更高。实测在常见窄屏设备上的任务完成时间缩短了约 18%,体感上明显更跟手。
2.2 未读、草稿、发送状态:把瞬时反馈做完整
交互优化里最容易被忽视的其实是“状态反馈的完整性”。用户点一下按钮,期望看到的不是最终结果,而是中间过程的明确信号。
未读清除策略改为“会话进入视图超过 1.5 秒后才清除未读”,并且提供“标记全部已读”作为批量收口操作。用户误触一条会话时不至于立刻丢掉未读标记,快速退出仍然保留提醒,这个设计明显更贴近实际使用习惯。如果用户确实想清除某一条的未读状态,可以在进入会话后手动点按已读按钮,这个操作路径同样明确。
草稿状态做成了三态提示:编辑时显示“输入中”,停止输入 800ms 后显示“已保存”,网络异常时显示“保存失败,点击重试”。提示位置就近放在编辑器底部,不弹 toast、不打扰用户。草稿数据本身存到本地,和会话 ID 绑定,切换会话再回来内容照旧。
发送状态也从“点击就乐观地认为发出去了”改成了完整状态流:发送中、已送达、已读、失败待重试。每条发出的消息在列表里有自己的状态角标,失败时显示红色警示图标,点击即可重新发送,重发不会产生重复记录。
状态流转看起来简单,但实现时牵扯到服务端回执、离线缓存、消息幂等,这个在逻辑章节单独展开。交互层只关心一件事:用户看得懂当前处于什么阶段,以及下一步该做什么。
2.3 键盘可达与批量操作
消息工具的重度用户很多都依赖键盘。2.0 补全了快捷键系统:J/K 切换会话、Enter 打开当前会话、Ctrl+Enter 发送、E 标记已读、Ctrl+Shift+A 全选当前结果集、? 呼出快捷键面板。所有快捷键都可以在设置里关闭,避免误触。
批量操作从“只能逐个处理”扩展为多选模式:勾选会话后可批量标记已读、批量转分组、批量删除。删除操作在 2.0 中支持“撤销恢复”——这是一次交互逻辑上的大改,原来删除是即时不可逆的,现在删除后弹出 5 秒的撤销窗口,底层对应的其实是逻辑层的软删除字段。
3. 视觉优化:用设计令牌替代散装样式
3.1 色板收缩与语义色体系
设计令牌这事听起来很“大厂”,其实小团队做也能拿到巨大收益。我把 1.x 里所有的颜色全部收敛成一套 tokens.json,颜色不再从各个组件里直接书写,而是通过语义层的变量引用。基础色板从 16 个自由色收缩到 7 个中性色和 4 个语义色,主色只保留一个。
语义色的命名按用途来:primary 用于主按钮和选中态,success 表示成功和已读,warning 表示待处理,danger 表示失败和删除。所有组件只能引用语义变量,不能再直接写 #3B82F6 之类的裸色值。这么一收,深色模式不再需要为每个组件单独调色,而是直接替换语义色在暗色下的映射值。
正文、次级文字、禁用文本、分隔线这四种中性色统一由 neutral 色阶提供。任何时候想新增颜色,先看能不能复用现有语义色;如果确实需要新颜色,先扩充色阶再映射到语义层,不能直接插入到组件里。这条纪律执行下来,样式表的体积下降了将近三分之一。
3.2 字体、间距、圆角与动效的纪律
字号层级的收敛是这次视觉重构里收益最立竿见影的一项。1.x 有超过 10 种字号,新版本统一为七个档位:11、12、13、14、16、18、24。标题、正文、辅助说明分别对应 18/14/12,强调文本用 14 号加粗而不是通过放大字号解决。行高也做了约束,正文行高固定 1.5 倍,多行标题 1.4 倍,代码块 1.6 倍。
间距采用 4px 基准缩放:4、8、12、16、20、24、32。组件内部的 padding 优先用 12 和 16,卡片之间的间距用 20,大区块之间的留白用 32。这套体系让页面密度变得可预期,浏览器截图对比时能看到明显的对齐秩序感。
圆角方面,从自由发挥改成四级:2px 用于小标签和输入框描边、4px 用于输入框本体、6px 用于按钮、8px 用于卡片。1.x 里同时存在 10px 和 12px 的大圆角卡片,视觉上偏圆润,重构后统一缩到 8px,配合更细的描边,整体更克制,也更符合工具类产品的气质。
动效时长同样做了标准化:悬浮反馈 80ms、轻量出现 120ms、面板展开 150~200ms、退出动画 120ms、遮罩淡入淡出 200ms。时长只是其中一个维度,更重要的是缓动曲线统一。组件里不再出现随意写的 cubic-bezier 值,而是在令牌里集中定义 ease、ease-in、ease-out 三档。
3.3 暗色模式不是反色映射
1.x 的暗色模式直接把背景换成深灰、文字换成浅色,颜色对比度有很大问题。新版本在暗色下独立设计了语义色的具体取值,每一条都对照 WCAG AA 标准验证过对比度。
举一个实际案例:旧暗色模式里的次级文字用了 #9CA3AF,在 #111827 背景上的对比度只有 4.1:1,小于正文需要的 4.5:1。新版本把暗色下的次级文字调整为 #B7C0CC,对比度提升到 6.8:1,小字号也能看得清。所有的状态角标在暗色下都做了提亮处理,失败红和警示黄的饱和度和亮度都刻意抬高,保证色觉障碍用户也能通过图形辅助识别。
4. 逻辑优化:消息状态机与数据流重构
4.1 消息从撰写到已读:一张完整状态机
逻辑层的第一步是定义消息的完整生命周期。1.x 的问题在于消息状态散落在组件内部,有的地方用 loading 表示发送中,有的地方用 error 布尔值,还有的地方干脆没有失败态。重构后我先把状态机画清楚,再动手写代码:
type MessageStatus = | 'draft' // 本地草稿,未进入发送队列 | 'pending' // 已提交,等待服务端确认 | 'sent' // 服务端已接收,进入送达流程 | 'delivered' // 接收方已收到 | 'read' // 接收方已读 | 'failed'; // 发送失败,需要重试或人工干预 interface MessageState { status: MessageStatus; retryCount: number; localId: string; // 本地幂等键 serverMsgId?: string; // 服务端回执 ID }状态迁移严格限定在合法路径内:pending 只能转到 sent 或 failed,sent 可以进入 delivered 和 read,failed 状态允许重发后回到 pending。不允许出现 sent 直接回到 pending 这种非法迁移。所有组件都只消费 MessageState,不再自己推断消息是否发送成功。
这套状态机直接解决了两个历史问题:一是服务端回执到达顺序错乱时,UI 不会出现“先显示已读、后显示已送达”的诡异刷新;二是重试时能通过 localId 找到原始消息,避免在列表里插出一条重复记录。
4.2 用轻量状态库替换 Redux 后踩到的渲染问题
旧版使用 Redux 管理会话和消息数据,问题不在于它不够好,而在于大量异步逻辑都要经过 action、reducer、dispatch 三层,代码分散后很难追踪一条消息从发送到回执的完整链路。重构时我换成了更轻量的 Zustand,把消息相关的 store 拆成会话集和消息集。
替换过程不是没有坑。Zustand 的 selector 必须返回稳定引用,否则会触发不必要的渲染。我一开始在 selector 里直接返回了一个新构造的数组:
const messages = useStore((state) => state.messages[activeId] ?? []);这段代码会让每次 state 变更都产生新的数组引用,组件订阅就会认为“数据变了”,导致不必要的频繁渲染。正确的做法是 selector 只返回原始引用:
const messages = useStore((state) => state.messagesBySession[activeId]);然后配合 useMemo 在组件内部派生真正需要的数据。这个问题的隐蔽点在于:数据少的时候根本看不出异常,一旦会话消息量上来,性能差异立刻拉开。建议任何用 Zustand 的团队把“selector 返回原始引用”写进代码规范。
4.3 长列表虚拟化与消息解析 Worker
消息列表的性能优化是这次重构最硬的一块骨头。万级消息全量渲染必然卡顿,虚拟化是唯一正解。但由于消息内容高度不固定——有的只是一行文本,有的带图片预览和链接卡片——固定行高的虚拟列表方案完全不可行。
我用的是“估算行高 + 渲染后动态修正”的方案:首次渲染时按 80px 的估算高度占位,组件实际挂载后测量真实高度并回写缓存。滚动过程中只有视口附近的少量消息被真实渲染,其余内容用占位块填充。这个方案的实现难点在于修正高度的时机,如果测量发生在动画帧之外,滚动位置会出现抖动。固定成 requestAnimationFrame 里统一回写后,抖动问题基本消失。
富文本和链接预览的解析原本也在 UI 线程里跑,消息量一大就会阻塞交互。我把它移入 Web Worker,主线程只接收解析后的结构数据。搜索全量历史消息时,关键词高亮计算也在 Worker 里完成,用户输入搜索词的同时列表照常流畅滚动。
4.4 断线重连、幂等去重与离线队列
消息类工具最怕的就是网络不可靠。2.0 在后台构建了一个三层防护模型:
第一层是断线重连。连接建立失败或连接断开后,按 2 秒、5 秒、10 秒、30 秒的指数退避策略重连,最大间隔封顶 60 秒。每次重连成功后,会主动向服务端同步本地已读游标和会话最后一条消息的时间戳。
第二层是消息幂等。每次发送消息时生成一个本地唯一 ID,服务端回执必须携带这个 ID。客户端收到推送消息时,先检查本地消息集里是否已经存在相同 ID,存在则跳过,否则才插入。这彻底解决了弱网下消息重复推送导致的列表翻倍问题。
第三层是离线队列。所有未成功发送的消息进入持久化队列,写入本地存储。应用重新启动后优先从队列恢复,网络恢复后自动逐条推送。队列里的消息状态统一显示为“待发送”,用户随时可以手动取消或重试。
这三层防护相互独立,却又在数据流上共享同一个消息状态机。断线重连只负责维持传输层的稳定性,幂等去重防止重复数据进入状态机,离线队列则保证状态机中的数据是有序、可回溯的。
5. 落地阶段的坑与最终实测数据
5.1 虚拟列表高度抖动排查
虚拟列表上线后收到反馈,说滚动到图片较多的会话时列表会“跳动”。排查后发现是高度缓存失效的时机不对:图片加载完成后 DOM 高度变化,但列表缓存里仍然存着旧的估算高度,导致滚动容器总高度计算错误。
修复方案是在图片加载完成后,向列表组件触发一次“脏高度”通知,让其重新测量并更新缓存。同时,把图片区域默认设置为一个固定占位高度,避免图片未加载时高度为 0。这个改动采用了一个简单的行为约定:任何会改变内容高度的异步资源(图片、嵌入式预览),加载前后都必须主动向虚拟列表上报高度变化。
5.2 旧数据迁移中的兼容层
历史数据迁移是重构里最容易翻车的环节。1.x 的消息缓存里同时存在两种格式:早期版本用数组保存,后来的版本把消息改成对象 Map。只兼容其中一种都会导致部分老用户历史消息丢失。
我在迁移层写了一个兼容函数,自动识别旧格式并转换成新结构。转换规则如下表格:
| 旧格式 | 新结构 | 转换处理 |
|---|---|---|
| 数组 | Map | 遍历数组,以 serverMsgId 作为 key |
| 对象 | Map | 遍历对象键值,保留原有顺序字段 |
| 无 serverMsgId | 补 localId | 用时间戳 + 随机数生成幂等键 |
转换在启动时异步执行,且只在检测到版本号低于 2.0 时触发。转换过程中不阻塞 UI,新数据写入成功前先走旧的读取逻辑,避免用户启动即白屏。
5.3 关键性能对比与灰度反馈
重构完成后,我对几个核心场景做了对比测试,同一台机器、同一份数据量、同一网络条件:
| 场景 | 1.x | 2.0 |
|---|---|---|
| 冷启动首屏可用时间 | 约 2.8 秒 | 约 1.2 秒 |
| 万条消息会话滚动帧率 | 15-20 FPS | 50-60 FPS |
| 打开单个会话的列表响应 | 约 600ms | 约 80ms |
| 全量消息搜索(含高亮) | 卡顿约 1.5 秒 | 后台执行,界面不卡 |
| 打包体积(gzip) | 约 138 KB | 约 92 KB |
性能提升不是单一改动带来的,而是虚拟列表、状态层重构、Worker 解析三件事合起来的结果。任何一项单独做都不会有这种质变。
灰度期间把内部团队作为第一批用户,收到的最多反馈是“明显感觉轻了、快了”,但也有两条有价值的反向意见:暗色模式下的选中状态对比度仍偏低,以及键盘快捷键提示不够明显。前者通过提升选中态的边框宽度和亮度解决,后者在快捷键面板里加了首次使用时的一次性引导。
整个重构做下来,我个人最深的体会是:交互、视觉、逻辑三个维度看着是独立的设计任务,实际操作时必须统一建模。状态机没画清楚前不要写组件样式,设计令牌没定下来前不要写具体颜色值,虚拟列表没确定资源加载策略前不要谈滚动流畅度。把这些底层问题理顺之后,上层功能开发的速度反而比之前快了不少。如果只让我总结一条经验,那就是:重构最值钱的产出不是新功能,而是把原来纠缠在一起的复杂性拆开、摆正、重新固定下来。