一天上线一个高不确定性需求:状态矩阵 + E2E 让前端交付不返工
最近用一个工作日上线了一个"容易反复改"的前端需求,过程几乎没有返工。
说真的,这次上线给我一种很少见的感觉:我对这段逻辑有安全感。不是那种"大概没问题"的心虚。
需求本身不复杂,但很典型:AI 流式回答过程中,根据"思考步骤"和"正文"的返回情况动态切换 UI。
难点不在 UI,在时序不确定性。
一个看着简单、写起来容易错的需求
场景:
某个 AI 对话模式下,如果没有"思考步骤",先展示等待态;有了就切换。
但实际跑起来:
- 正文可能先到,步骤后到
- 步骤可能先到,正文后到
- 中间几秒到十几秒的空窗
- 不同对话模式的逻辑还不一样
写个简单的 if 会怎样?
一边还在显示"等待灵感",另一边正文已经开始滚动了。这种 UI 上线后就是反复改的开始。
先让 AI 找现状,不要直接改
这个需求一开始容易误判。
我最初以为是:没有思考步骤时显示金句,有了之后金句和步骤都显示。后来跟产品确认才知道正确逻辑是:没步骤时显示金句,有步骤后只显示步骤。再往后又发现一个遗漏:如果已经有正文了,即使还没步骤,也不能继续显示金句。
三轮理解修正,才算把需求搞清楚。
这里 AI 的价值不是"直接给答案",而是快速把相关文件串起来。它帮我定位到几个关键文件:展示思考状态的组件、消息列表的渲染入口、全局 UI 状态管理、聊天服务和流式处理逻辑。
最关键的发现是,等待态组件和 Markdown 正文是并列渲染的:
{showProgress && <MessageProgress2 />}
{showMD && <MdRender text={handledContent} />}
只看等待态组件本身,很容易漏掉"金句 + 正文同屏"的问题。得从渲染入口一层层往下看才能发现。
到这一步我意识到:直接写代码大概率改了又改。它不是 UI 问题,是状态问题。
用状态矩阵把需求说清楚
我没有继续讨论"什么时候显示等待态",而是把所有状态列出来:
| 场景 | chatType | 是否有步骤 | 是否有正文 | 期望 |
|---|---|---|---|---|
| 1 | agent | 无 | 无 | 显示等待态(gif + 金句) |
| 2 | agent | 无 | 有 | 显示正文,隐藏金句 |
| 3 | agent | 有 | 无/有 | 显示步骤,不显示金句 |
| 4 | 非 agent | 任意 | 任意 | 保持原逻辑 |
这一步把讨论从"感觉对不对"变成了"每个状态怎么渲染"。
而且我们确实在这里抓到了一个错误:我一开始把正文判断放在了外层条件上,导致非 agent 场景被误伤。后来改成只作用在 agent 分支里。
用 E2E 锁住最容易出错的状态
没写很多测试,只覆盖了三个关键场景:
- agent + 无正文 + 无步骤 → 金句出现
- agent + 有正文 + 无步骤 → 金句消失
- agent + 有步骤 → 步骤树出现,金句消失
测试重点不是 UI 细节,而是:状态有没有切换正确。
为了让测试稳定,我加了几个选择器:
data-testid="progress-agent-quote" // 金句容器
data-testid="progress-quote-text" // 金句文本
data-testid="progress-analyzing" // 分析中状态
data-testid="progress-tree" // 步骤树
这些不是在测实现细节,而是稳定定位几个用户可见状态。
跑完之后我就知道了一件事:以后谁改这段逻辑,这几个状态不会被改坏。第一层安全感就是这样来的。
做 Demo,把时序问题变成可见的
E2E 能证明逻辑,但不适合肉眼看过程。尤其这个需求的重点是"数据从没有到有"的动态变化。
所以我做了一个 Demo 模式,按 375px 移动端视口打开浏览器,演示状态变化:
- 正文先到 → 金句消失 → 再到步骤
- 步骤先到 → 金句消失 → 再到正文
- 两者交错 → 步骤 → 正文 → 子步骤 → 完成态
页面会自动推进状态,每个 case 停留十秒左右,底部有倒计时。这个比截图有用,因为它能暴露"切换瞬间"有没有怪异 UI。
所有人可以"看到"状态变化,不用靠想象。而且是在后端还没准备好之前就把交互问题确认掉了——正文先到怎么办?步骤先到怎么办?loading 什么时候消失?这些如果等到联调才讨论,基本必返工。
一个取舍:不用 mock 网络,直接驱动 Store
一开始考虑过 mock SSE、模拟流式接口。但成本高,而且这次的核心不是网络层,是 UI 状态。
所以我选了一个更直接的方式:直接用脚本驱动 Store 状态。组件完全不变,只是数据来源变了。
这个方案的好处:不依赖后端、状态完全可控、每次演示一致、各种顺序都能模拟。本质是把"时间问题"转成"状态问题"。
测试 hook 的取舍
为了快速做 E2E 和 demo,我在开发模式下加了一个 hook,让 Playwright 可以直接 dispatch Redux 状态。优点是快、稳定、可控。缺点也明显:即使只在 dev 生效,它还是侵入了主入口。
后来讨论了三个方案:
- Playwright route mock SSE —— 最接近真实链路,但动态演示要处理本地 mock server、HTTPS、CORS 等问题,太重
- 单独 debug page —— 干净,但会新增一套页面
- 把 hook 抽到独立 dev-only 文件 —— 保留可控性,主入口侵入降到最低
最后选了方案 3。hook 逻辑放在独立的 dev 文件里,主入口只保留一行动态 import。方便以后整体删掉或替换。
结果
时间线:
- 前一天下班:需求下达
- 晚上(1~2 小时):完成状态建模 + 测试 + demo
- 第二天 10 点:用 demo 和产品确认所有交互
- 下午 4 点:联调完成
- 下午 6 点前:上线
这次真正节省时间的不是写代码快,而是避免了后面的返工。状态在一开始就说清楚了,交互在 demo 阶段就确认了,测试锁住了关键逻辑。联调之后,前端几乎不需要再改。
代价
写 demo 需要额外时间,加了测试需要维护,测试 hook 有一定侵入性。
但跟"上线前反复改 UI + 心里不踏实"比,我觉得值得。
最大的收获
这次让我确认了一件事:在需求模糊、状态复杂、时序不确定的情况下,先确认状态和行为再写代码,其实是更快的路径。
AI 在这里面最有用的地方不是"替我写代码",而是帮我压缩探索时间。一个需求如果直接改,很容易只改一个组件,漏掉渲染入口里并列显示的问题。
而把需求变成状态表之后,几个关键问题自然就浮出来了:正文来了怎么办?不同对话模式是否一样?loading 结束后怎么办?simple 模式要不要动?
这些问题一列出来,代码就好写很多。
最后
这次需求很小,但很典型:状态多、时序乱、容易误解、容易反复改。
用的方法也不复杂:先让 AI 搜,不要先让 AI 改;用状态矩阵说清需求;用 E2E 锁关键状态,不覆盖所有细节;用 Demo 提前确认交互,有争议就跑一遍。
结果:一天上线,几乎零返工。最重要的是,有安全感。
异步 UI 的问题,本质是状态问题。先把状态说清楚,再写代码,才是最快的方式。