前端改完以后,最容易让人产生错觉的时刻,是页面终于和设计稿很像了。按钮在,卡片在,抽屉能打开,数据也能显示。只要顺手点通一次成功流程,心里就会觉得这次改动已经接近完成。
这次做批量前端改造时,我一开始也有这种感觉。页面按审计清单重写,要求只动指定的4个前端入口,还要在3档尺寸下截图验收。成功态看起来没有明显问题,真正麻烦却从这里才开始:初始化可能抢跑,慢响应可能被轮询覆盖,失败提示可能残留,写操作还可能只改了页面、没有改回服务端。
后来我才意识到,页面是否好看只是交付的一层。一个前端流程是否可靠,不看它顺利时能不能显示,而看它失败以后还能不能解释当前到底发生了什么。
成功点击只是最浅的一层
当时的任务并不是做一个静态展示页,而是要把今日选题、拆解卡、账号矩阵和抽屉交互接到真实状态上。用户可以展开内容、修改权重、把选题移出池子,也可以撤销刚才的操作。这样的页面有一个特点:每次点击都不只是改变颜色,而是在改变一段状态链。
如果只测一次成功点击,能证明的事情很少。它只能说明在当前网络、当前数据和当前操作顺序下,某条路径走通了。它不能说明初始化完成之前是否有人读取了空数据,也不能说明写入失败后页面有没有回到可信状态,更不能说明轮询回来以后会不会把刚刚完成的操作覆盖掉。
如果不处理这些情况,后果不是简单的“偶尔报错”。用户可能看到保存成功的提示,却刷新后发现数据没有改变;也可能明明操作失败,页面仍然保留旧的成功状态。更麻烦的是,这类错误往往不在第一次点击时出现,而在用户切换页面、等待一段时间或连续操作之后才暴露。
所以我把验收目标从“每个按钮能不能点”改成了“每个状态转移之后,页面和真实数据是否还能互相解释”。这一步看起来只是换了说法,实际改变了测试顺序。
第一个坑是页面太早相信自己准备好了
首轮验收时,缓存命中会让页面很快出现内容。页面看起来已经有数据了,但矩阵逻辑和共享页面外壳还没有完全就绪,初始化就先开始了。于是有时能看到正确结果,有时却出现状态不完整。它不是每次都失败,才更难排查。
这里最容易误判的是,把“页面已经显示”当成“页面已经可以交互”。显示是一件事,所有负责读取、路由和更新的部分都准备好,是另一件事。前者快,后者需要明确的边界。
我选择让初始化统一等到页面加载完成后再启动,是因为这个判断比继续给每个模块增加补丁更可靠。既然多个模块共同决定页面状态,就应该有一个共同的起点。否则每个模块都假设别人已经准备好,竞态就会藏在偶然的加载顺序里。
这件事让我重新意识到,初始化不是一行启动代码,而是页面第一次做出判断的时刻。只要这个时刻不稳定,后面所有成功态截图都可能只是一次恰好顺利的排列组合。
慢响应要和轮询分清先后
另一个问题出现在慢响应上。页面会定期轮询最新状态,如果一次写操作等待时间较长,轮询可能先返回。旧的实现把后回来的结果当成更新,于是一个仍然有效的慢响应可能被更早的轮询结果作废。
这类问题很像网络不稳定,实际上是前端没有说明不同请求之间谁更有资格改变状态。一个请求先发出,不代表它一定先完成;一个结果先回来,也不代表它代表了更新后的事实。
这次用6200毫秒的慢响应做检查时,重点不是页面有没有一直转圈,而是慢响应最终回来以后,结果是否仍然能落到正确的状态上。我把写操作和轮询看成两个不同优先级的来源,不能因为轮询更快,就让它覆盖尚未完成的写操作。
异步请求的完成顺序不是业务事实,页面必须自己维护哪些结果仍然有效。
如果不做这层区分,用户会遇到一种最难解释的体验:明明刚保存了新权重,页面过一会儿又显示旧权重;他再次点击保存,可能把已经过时的值写回去。问题不在某一次请求失败,而在页面把不同时间的结果混成了同一个现在。
失败提示不能只出现,还要会消失
故障注入时又发现了一个更细的状态问题。矩阵恢复后,如果成功响应的内容和之前缓存的内容相同,页面可能因为复用缓存而不重新整理错误提示。结果是数据已经恢复,错误条却还留在页面上。
这说明“错误提示出现了”并不等于错误处理完成。提示本身也是状态,和数据、加载中、成功一样需要进入下一步。用户看到错误条,会自然理解为当前仍然有问题;如果真实数据已经正常,提示却不消失,页面就开始说两套互相矛盾的话。
我判断成功回读时必须显式清除错误状态,是因为回读的意义不只是拿一份数据回来,更是确认服务端现在的事实。只要事实已经确认,就不能依赖内容有没有变化来决定是否清理提示。
同样的原则也适用于写操作失败。页面可以先做乐观更新,让用户不用等待就看到动作结果;但一旦服务端返回失败,就必须回读真实状态,再决定保留、撤销还是提示。否则,乐观更新只是把不确定性藏到界面里。
交互边界也是状态恢复的一部分
这次验收没有把抽屉焦点和小屏显示当成“视觉细节”。矩阵里的账号抽屉需要支持键盘进入、移动和退出,拆解卡还要在首屏先给出用户最需要判断的信息。抽屉打开后,如果焦点跑到背景,用户就不知道自己仍然处在哪个操作范围;关闭后,如果焦点没有回到触发位置,键盘路径就断了。
我把这些问题放进状态恢复,是因为交互状态和数据状态一样,都会在一次操作后留下现场。一个操作失败了,数据回去了,但焦点还停在已经消失的元素上,用户仍然会觉得页面坏了。无障碍检查不是另外一套装饰性标准,它是在验证页面能不能让人从当前状态继续行动。
这也是为什么我没有只看3档截图里颜色和间距是否正确。截图能发现小屏遮挡、首屏信息顺序和触控尺寸问题,但键盘路径需要真实走一遍;故障恢复需要先制造失败,再确认页面回到哪里。不同证据回答的是不同问题,不能用一组漂亮截图代替全部验收。
把状态变化当作一台小型状态机
最后我把测试拆成了几组连续动作。先验证初始化和成功态,确认页面在正常条件下能进入可用状态;再注入慢响应,观察轮询是否会抢先作废;接着制造失败,检查提示、乐观更新和回读是否一致;最后做跨账号操作、键盘切换和小屏检查,确认恢复后用户仍然能继续走下去。
真实写操作也要做完复原。权重修改后恢复到0.4、0.45、0.15,选题移出后再撤销,不能只在页面上看起来恢复。因为前端最容易伪造的是“我已经恢复了”,真正可信的判断要来自再次读取服务端状态。
大列表则是另一种边界。5000条热点不应该一次全部渲染,而是先渲染首批40条,确认页面不会因为数据量扩大就失去响应。这里的重点不是追求一个漂亮的性能数字,而是给界面设定一个可解释的工作范围。
我会把每个前端改动都拆成状态进入、状态变化、状态失败和状态复原4个问题来测。
这样做以后,验收记录也从“点过哪些按钮”变成了“哪些状态转移被证明过”。最终的14张截图、故障恢复、权重和选题复原、键盘路径、可见区域对比度与触控尺寸检查,分别对应不同风险。控制台为0错误只是结果之一,不是全部答案。
最容易误判的错误捷径,就是完成页面后立刻截一张成功态,再把剩余问题归到线上观察。这样做短期很省事,却把最贵的验证推迟了。线上用户不会按我们预设的顺序点击,也不会在失败后替我们清理旧提示;他们会等待、返回、重试、切换账号,也会在小屏上用键盘或触控完成同一件事。
如果只测成功截图,初始化竞态可能在真实环境中偶发,慢响应覆盖可能只在网络较差时出现,失败提示残留可能被误认为用户没有刷新。等到这些问题被反馈回来,复现条件已经不完整,修复成本也会比本地故障注入高。
我没有选择把所有异常都留到线上,是因为这次已经知道风险集中在哪里:状态读取有竞态,写操作有回读,轮询会并行,抽屉有焦点边界,大列表有渲染上限。既然风险可以被描述,就应该在交付前把它们变成可重复的动作。
一个流程成熟的标志,不是它能通过一次演示,而是它能在失败后留下足够清楚的证据:数据是什么,提示是什么,焦点在哪里,下一步能不能继续。
下次我会怎么做
下次再改一批有真实数据和写操作的前端,我会在开始截图前先画出最小状态表。至少写清初始化、加载中、成功、失败、回读和复原这几种状态,以及每种状态允许谁改变它。这样可以先发现“两个请求都能覆盖页面”这类结构性问题,而不是等异常出现后再猜。
然后我会按风险顺序验收:先测初始化边界,再测成功态;随后注入慢响应和失败,确认轮询不会覆盖有效写入,错误提示会在成功回读后清理;最后做真实状态复原、键盘路径、3档尺寸和大列表检查。每一步都留下能回答“现在到底是什么状态”的证据。
如果涉及写操作,我不会把页面显示恢复当成完成。会先回读真实数据,再确认提示、焦点和可继续操作的入口都回来了。大数据也不追求一次全部展示,而是先规定首屏范围和可接受的渲染上限。
这件事让我更愿意把前端验收看成一种诚实训练:顺利时,页面很容易替自己证明;失败时,才会暴露它有没有真正理解用户正在经历什么。好的前端不是永远不失败,而是失败以后仍然能给出可信、可恢复、前后一致的答案。