明明在找鲸鱼,却出现了猫
一句话总结
搜索框中的输入、发往服务器的请求、界面上显示的结果,是不同时间点的信息。如果把这三者当作同一个字符串来思考,缓慢的过去的响应就会占据最新的界面。
为什么需要它
在月球基地的图书馆里搜索了猫,随即又改成了鲸鱼。请求发往了两个观测站,鲸鱼的响应先到了。界面上刚刚出现鲸鱼,等了很久的猫的响应就到达了。没有错误窗口,HTTP 响应也是成功的,界面上却留下了错误的资料。这与其说是搜索算法的问题,不如说是谁现在有资格改变界面的问题。
在这里,如果禁用搜索按钮,可以把现象掩盖起来。但用户必须能够修正拼写错误或改变主意。是否让用户等待符合产品需求,必须先做出判断。本实验选择的策略是允许新的搜索,并丢弃不再有效的响应。
工作原理
正在输入的 draft 是尚未提交的句子。已提交的请求中,要放入搜索词和提交序号。因为即使再次提交同一个搜索词,它也可能是新的意图。界面状态按下面的方式区分。
| 状态 | 界面所知道的内容 | 需要向用户提供的提示 |
|---|---|---|
| idle | 还没有已提交的搜索 | 输入搜索词 |
| loading | 正在等待某个特定请求的响应 | 正在等待什么 |
| success | 与该请求相符的结果列表 | 这是哪次搜索的结果 |
| empty | 响应成功,但没有结果 | 修改搜索词 |
| error | 请求失败及其原因 | 重试的办法 |
如果分别管理 loading: boolean、error: boolean、items: Result[],就可能出现加载和错误同时为开的组合。以 kind 为依据区分的联合类型,会把每种状态应该具有的信息绑在一起。只在 success 中读取 items,只在 error 中读取 message,在渲染函数中也更容易发现矛盾。不过,类型检查并不保证响应的顺序。因为猫和鲸鱼都是类型正确的结果。
先来写一张事件表。编号不是毫秒,而是顺序。
| 事件 | 内容 | 应显示的搜索 |
|---|---|---|
| 1 | 猫的请求 | 等待猫 |
| 2 | 鲸鱼的请求 | 等待鲸鱼 |
| 3 | 鲸鱼的响应 | 鲸鱼的结果 |
| 4 | 猫的响应 | 仍然是鲸鱼的结果 |
最后一行就是必须实现的产品策略。必须能够把“响应成功”这个事实与“现在仍然有效”这个事实分开来解释。
设计的是可能的状态,而不是数据
来比较下面两种状态。示例中的字符串只是为了说明格式而取的样本,实际的响应值会随请求而不同。
type View =
| { kind: 'idle' }
| { kind: 'loading'; query: string }
| { kind: 'success'; query: string; titles: string[] }
| { kind: 'error'; query: string; message: string };
const waiting: View = { kind: 'loading', query: '별 지도' };
const failed: View = {
kind: 'error', query: '별 지도', message: '자료실에 연결할 수 없습니다'
};
不是同时打开两种状态,而是从中选出当前的一种状态。如果选择了 error,就必须同时有哪个请求失败了以及提示语句。实际的实验会在此基础上增加 empty。在添加新状态时,不要只修改类型定义就结束,还要一并找出界面文案和状态转换检查。
如果在失败之后重试时转入了 loading,旧的错误语句却依然显示,就说明状态与界面的连接错位了。不要只看创建新请求的函数,而要确认渲染分支读取的是哪些字段。反过来,结果先消失也并不总是正确答案。也有产品会保留之前的结果,同时等待新的结果。如果选择这种策略,就还需要额外的类型来表示“这是之前的结果”以及当前请求的状态。本实验选择清空之前结果的简单策略,专注于竞态。
在现场相遇的样子
不仅是搜索,地址自动补全、按商品选项显示的库存、地图位置变更、按客户显示的详情面板中,都会出现同样的问题。当 FDE 在客户演示过程中遇到界面偶尔变回过去的值的现象时,首先必须同时记录输入时刻和响应时刻。如果只查找失败日志,就会漏掉成功的旧响应。
2026-09-11 查看的 Canonical Web Developer 招聘启事 要求 TypeScript、响应式 UI、可访问性和复杂 UI 的性能,并把大规模 React+TypeScript 经验列为加分项。这个任务把这些要求转化为一个小而可观察的工作。一份 EMEA 招聘启事,并不能保证整个市场的招聘频率或录用结果。
要亲自确认的内容
在实验室的请求队列中,先送达鲸鱼,后送达猫。分别读取输入框和结果标题,并记录在哪个时间点两者出现了错位。请用一句话说明,为什么起始代码的类型检查明明能通过,界面却仍然可能出错。
类型的表示方式请参考 TypeScript 对可辨识联合(discriminated unions)的说明。