从724ms到0ms:一次 Vibe Coding 消息列表虚拟化复盘,聊聊复杂需求怎么拆

comsince

comsince

FshareIM Team

飞享 IM 桌面端(electron-vue-chat-pro)收到一条反馈:切到含超长 Markdown 表格(系统通知日报)的会话时,界面会冻结小半秒,即便每会话消息数已经限制在 50 条以内。这篇文章记录的不是"我们做了个虚拟列表",而是从这句模糊反馈到一个可放心合并的实现之间,中间每一步该做、且没有跳过的判断——用 Claude Code 全程执行,人负责拍板方向和最终验收。

全文分两部分:先是复杂需求拆解的通用流程,再是针对性能问题这个具体场景,Vibe Coding 里真正有效的几条方法论。


一、复杂需求怎么拆

用户的原话是"单个消息长度过长,切换到系统通知会很卡,有什么解决方案"——这句话里没有方案,只有症状。下面五步,是从症状走到一个可以放心合并的实现之间,真正跨过的台阶。

1. 别急着开药方,先建一条能复现症状的反馈回路

拿到反馈时最容易的反应是"我大概知道原因",直接开始改代码。这次刻意把第一步锁定为:读代码、量化定位真正的瓶颈,而不是凭经验猜。

读下去发现一个反直觉的结论:50 条消息的上限根本没解决问题,它只是把"爆炸"变成了"更小的爆炸"。真正的开销来自单条消息的内容体积——一条系统通知日报本身就是好几张几十行的表格,50 条里绝大多数根本不在可视区域,却依然被同步解析(marked.parse)、同步渲染,参与了整棵 DOM 树的布局计算。

根因判断:卡的不是"条数",是"单条的 DOM 体积"。这个判断决定了后面所有方案的方向——如果方向判断错了,后面做得再仔细也是在优化一个不存在的瓶颈。

2. 把选择权交还给人,而不是替他做决定

诊断清楚之后,没有直接挑一个"最优解"去实现,而是按性价比给出四个方案,每个都写清楚代价:

方案收益代价
content-visibility: auto立竿见影,一行 CSS要验证与"贴底""历史保位"的兼容性
长消息折叠 + 解析结果缓存产品层面收益大改变消息的默认呈现方式
虚拟列表彻底解决,为放开消息上限铺路贴底、历史保位、图片显示、跨消息图片预览四个功能要全部重做
分帧挂载压住首帧卡顿滚动位置管理变复杂

用户追问"虚拟列表是不是能彻底解决,并且贴底、历史保位这些功能也正常"时,没有含糊地说"能",而是逐项拆开回答:贴底能做但有估算高度的小坑,历史保位是公认最难的部分,图片显示反而因为现有设计(缩略图先行、固定尺寸)天然友好——还主动指出了一个没被问到的真实回归风险:图片预览(n-image-group)依赖 DOM 挂载,虚拟化后会直接失效。

"最彻底"和"当下最划算"经常不是同一个方案。 把成本和收益摆出来,由清楚自己业务优先级的人来选风险等级,比 AI 单方面替他决定更可靠。

3. 便宜的先上,贵的留后手

用户第一轮只选了 CSS 和折叠缓存两项。这两个方案改动局限在 MessageBubble/MarkdownText 两个文件,风险低、回退容易,而且互补——CSS 方案解决"不可见也参与布局",折叠缓存解决"单条消息本身过大"。实现按 TDD 来:先写覆盖折叠、展开、缓存、流式消息不折叠这几种场景的测试,跑红,再写实现,跑绿。

更关键的是验证方式没有停在"跑通单元测试"。单元测试测的是函数行为,测不出"切换会话时主线程冻结了多久"这种运行时现象。于是启动 electron-vite dev 开 CDP 远程调试口,用 Playwright 连上真实登录的实例,注册 PerformanceObserver 抓长任务,再用 git stash同一个运行实例上切换"有修复"和"无修复"两个版本,拿到了下面这组对比:

指标修复前修复后
切换到「系统通知」会话·主线程长任务546–724ms0ms
消息区 DOM 节点数8,860200

验证过程中还抓到一个修复引入的真回归:content-visibility 让视口外的行按估算高度占位,导致"贴底"的 scrollTop = scrollHeight 直接失效,贴底缺口从 0 变成 1146px。这个问题不是靠猜出来的,是验证脚本量出来的——修成逐帧钉底直到高度收敛后,复测降到 1px。

4. 一旦决定做"贵"的方案,把它当成一个独立项目来管理

几轮小修复之后,用户提出要做被搁置的虚拟列表方案。这时候流程明显切换了档位——不再是"改两个文件、跑个测试",而是走了完整的规划-执行-评审闭环。

先问历史:有没有人已经在这上面栽过跟头。 写方案之前,发现本地留着上一次(不同会话)虚拟化尝试的进度台账——那次实现最终被整体丢弃,但记录下了四条具体教训:margin 塌陷导致高度测量偏小、两条互不协调的滚动补偿路径互相打架、切换到消息数恰好相同的会话时状态未刷新、只靠人工交互测试才发现问题。这四条教训被直接写进了新方案的全局约束和具体任务的实现代码里,比如 .cvl-item { display: flow-root } 这一行 CSS,以及专门针对"整组 items 替换后窗口基于新数据计算"写的回归测试。历史教训如果只是读过,不会自动生效;只有变成计划里的显式约束和测试用例,才真正防住了同一个坑。

任务边界画在接口上,不是随手切。 最终方案拆成 9 个任务:纯函数核心、渲染窗口、交互 API、状态持久化(三个并行的独立缓存)、独立图片查看器、集成、端到端验证、版本发布。每个任务在计划里都写清楚"消费什么接口""产出什么接口"——比如虚拟列表组件对外只暴露 scrollToBottom / captureAnchor / restoreAnchor / isAtBottom 四个方法,后面所有任务都只依赖这四个签名,不关心内部怎么实现。

每个任务:实现 + 独立评审,自证不算数。 9 个任务全部走"子代理实现 → 生成 diff → 独立子代理评审(规格符合度 + 代码质量两条判决)→ 有重要问题就派修复 → 重新评审"的循环。这个环节抓到的问题比想象中多:一个引用回调在模板里每次渲染都重新创建,导致 ResizeObserver 反复取消订阅/重新订阅未变化的 DOM 节点;一个图片预览器在切换会话时没有关闭,残留着上一个会话的图片列表。模型选择也有讲究——纯粹的代码转录任务(方案里已写好完整实现)派给便宜模型,涉及多文件整合、需要判断力的任务派给更强的模型,评审强度跟着变更范围和风险走。

全部任务完工后,再做一次"跨任务"的整体评审。 这是最容易被跳过、但价值最大的一步。9 个任务各自的评审都合规,但没有一个任务评审者能看到任务之间组合出来的问题——因为那个问题根本不在任何一个任务的 diff 里。

只有整体评审能看见的 bugscrollToBottom 的钉底循环会手动调用 onScroll()onScrollscrollTop < 10 时会触发"加载更多历史"。在内容不足一屏的短会话里,scrollTop 恒为 0——于是每次切换会话、每条新消息、AI 流式回复的每一个 token,都会误触发一次没有意义的远程历史拉取。这个组合只有在看完整个分支的最终审查里才会浮现,因为它横跨了两个任务的边界。修法只有一行——给触发条件叠加一个"内容确实超出视口"的判断——但能发现它靠的不是运气,是流程里专门留出了这一步。

5. 人类始终握着"高风险动作"的开关

整个执行过程里,所有代码改动都留在工作区,没有一次提交。这是用户明确提的要求:"所有代码先提交,等最后我验证完一起提交。"9 个任务、3 轮修复迭代、几十次 CDP 复测,全部发生在同一份未提交的 diff 里,直到用户自己跑起来验证过、说"没问题",才分批提交、才在被要求时 push。这条边界没有被流程本身的复杂度冲淡——不管中间做了多少自动化验证,最后"这份代码可以进历史记录了"这个判断,始终是人按下的。


二、Vibe Coding 解决性能问题的方法论

性能问题有一个特殊性:它的"正确"不是靠读代码就能确认的,必须靠测量。这部分记录测量这件事具体是怎么做对的——以及做错过一次之后怎么发现并纠正。整个过程是一个循环:症状 → 测量 → 假设 → 修复 → 复测,复测不达标就回到"假设"重新怀疑根因,而不是回到"修复"继续猜。

1. 别信直觉,先测量——而且要挑对指标的量纲

"感觉卡"这种描述换算成机器指标至少有三种候选:帧率、主线程长任务时长、DOM 节点数。这次选的是 PerformanceObserverlongtask 条目——因为用户描述的是"界面冻结",对应的正是主线程被单个任务占满、无法响应交互的那段时间,而不是持续的低帧率。指标选错,测出来的数字会正确但答非所问。

2. 量基线,不要只验证修复后

只测"修复后是不是变快了"是不够的——没有基线,就不知道变快了多少,也无法判断这个改动是不是真的对症。这次的做法是用 git stash同一个正在运行的应用实例上切换有修复和没修复两个版本,各跑一遍完全相同的探针脚本,唯一的变量就是代码本身,排除了机器负载、缓存状态之类的干扰。虚拟列表这条主线上,最后拿到的完整对比是:

指标基线修复后判定
切换长消息会话·长任务546–724ms0ms通过
消息区 DOM 节点数8,860200通过
虚拟化后最大挂载数(336 条历史)< 40通过
历史加载保位漂移(初版)518px不通过
历史加载保位漂移(最终)3–6px工程可接受

3. 单元测试测不出真实浏览器里的时序 bug

jsdom 没有布局引擎,ResizeObserver 是手写的桩,滚动位置的修正逻辑再怎么写单测,也测不出 Chromium 真实的 scroll anchoring 行为、真实的 offsetHeight 测量时序。这次的虚拟列表在单元测试 67/67 全绿、生产构建通过的情况下,第一轮真实环境验证依然测出了 518px 的漂移——单测能保证"逻辑按预期分支走",保证不了"真实浏览器按这个逻辑设想的时序走"。所以任何涉及滚动位置、异步测量、渲染时序的改动,验证环节必须包含一步:用 CDP 连上真实运行的应用,像用户一样点击、滚动、观察 DOM。这一步不能被"测试全绿"替代。

4. 性能修复常常带回归,验证半径要盖过目标指标本身

这次至少两次踩到同一个模式:为了性能加的 content-visibility 破坏了贴底逻辑;为了虚拟化加的锚点补偿一开始也破坏了历史加载的视觉稳定性。性能优化的本质是"让浏览器少做点当下用不上的工作",而"少做工作"几乎总会打破某个隐含依赖"这部分工作已经做完"的下游逻辑。验证的范围不能只盯着改动想解决的那个指标,要覆盖它周边所有依赖同一份状态的功能。

5. 指标卡在临界值时,做受控实验隔离变量,别继续猜

历史加载保位这个指标最有意思——从 518px 修到几 px 之后,还剩 2~6px 的残差,而且在图片密集的历史区域偶尔跳到 13~420px。继续在源码里加逻辑去猜是没有用的,真正定位到根因的是一次零源码改动的运行时对照实验:在 CDP 里直接给容器动态注入 overflow-anchor: none,不改一行源码,同类场景的漂移立刻从两位数 px 收敛到 5px 以内。

真凶是 Chromium 一套原生的 scroll anchoring 机制,会在内容变化时自己尝试保持滚动位置——这套逻辑和手写的锚点补偿逻辑在同时争抢 scrollTop,谁赢是不确定的,漂移方向也因此忽正忽负。这正是"两条互不协调的补偿路径互相打架"这条历史教训的又一次出现,只不过这一次,第二条路径根本不在代码里,而是浏览器自带的隐藏行为。确认根因之后,修复只有一行 CSS,把修正权收归到唯一一处。这类问题如果一开始就去改锚点计算的数学逻辑,大概率是在给一个本来就没错的公式打补丁。

6. 警惕"多路修正互搏"——包括看不见的浏览器原生行为

把上一条抽象成通用规则:任何时候,只要一个状态(滚动位置、动画进度、光标位置)可能被多于一处逻辑修改,就存在"两边同时纠正、互相打架"的风险。写代码时容易只审视自己新加的这一处,而漏掉浏览器/框架自带的默认行为也在同一个战场里。遇到"数值时对时不对、方向不固定"这种典型症状,第一反应应该是"是不是有第二个修正者",而不是"我这处公式还不够精确"。

7. 允许工程判断:视觉不可感知的残差可以是"已知问题",不是失败

最终 3~6px 的残差没有继续深挖下去。理由摆在明面上:方向恒定、半秒内定型、不再跳动,换算下来大约是四分之一行高,人眼分辨不出来。计划里定的字面判据是"≤2px",按字面这项不通过——但继续投入去把 5px 磨到 2px,边际收益已经趋近于零,还会引入新的复杂度和新的风险面。把"字面判据不通过、工程判定通过"如实记录下来,交给决策者做最终判断,比在计划书面前硬凑一个"全绿"要诚实,也更专业。 过度追求字面指标,往往是把验证的目的(用户体验是否受损)偷换成了数字游戏。


三、收束:一张可复用的检查单

  • 先建反馈回路,再开药方。 没有可复现、可量化的信号,任何修复都是在赌。
  • 方案分级摆出来,别替用户拍板。 成本和收益写清楚,风险等级由清楚业务优先级的人选。
  • 贵的方案先当项目管理。 写计划、查历史教训、把接口边界画清楚,再决定怎么拆任务。
  • 任务自证不算数。 实现和评审必须是两双眼睛,哪怕评审者是另一个子代理。
  • 整体评审专门抓"接缝"。 跨任务的组合 bug,只有看完整分支的那次评审才可能看见。
  • 性能验证必须量基线,而且要在真实运行环境里做。 jsdom 和"跑通"都不能替代真实浏览器里的观察。
  • 指标卡壳时做受控实验,别靠猜。 一次只改一个变量,尤其要怀疑"看不见的第二个修正者"。
  • 允许工程判断收尾。 如实记录已知残差,比硬凑一个字面全绿更值得信任。
  • 高风险动作的开关永远在人手里。 提交、推送这类不可逆或影响他人的动作,自动化流程再长也不能替人按下去。

这次落地的具体功能是消息列表虚拟化,但上面这九条和源码本身没有关系——换成任何一个"用户反馈很模糊、方案有多种权衡、想让 AI 承担大部分执行"的需求,这套拆解流程都适用。