题干与适用场景
一个任务编辑器会立即显示用户提交的标题。用户可以在任何请求完成前先提交标题 A,再提交标题 B。第二个响应可能先回来,任一请求都可能失败,两个请求等待期间还可能完成一次后台重新拉取,其他用户也可能更新同一任务。每次写入被接受后,服务端会返回权威任务数据和单调递增的资源版本。
请设计客户端状态、请求契约、顺序策略、失败恢复、冲突体验、无障碍反馈和验证方案。要求不只是让界面看起来更快。无论请求按什么顺序结束,界面状态都必须能由“服务端权威状态 + 仍待确认的用户意图”解释出来。
本题适合高级前端、UI 基础设施和前端系统设计面试。公开前端面试资料明确讨论乐观更新、请求竞态、错误状态和回滚;一份 2025 年公开面经也记录了面试官继续追问乐观更新的原理与适用场景。这些来源能证明该主题在当前环境中具有代表性,但不能证明它是某家公司的固定原题,也不能证明具体面试频率。本题归入 frontend,因为核心能力是浏览器侧异步状态与交互设计;服务端并发控制只是设计输入,不是主要实现目标。
面试官考察点
第一,看候选人能否拆开权威状态和推测状态。直接替换缓存中的对象,并保存一个旧快照用于回滚,只适用于单个隔离请求。如果后续乐观操作依赖同一对象,这种做法就会出错。稳健的模型保留最新已确认基础状态和有序的待确认意图,再从两者推导界面。
第二,看候选人能否识别操作语义。“只接收最新响应”可以保护一条客户端渲染链路,却无法阻止服务端最后处理更早发出的请求。候选人必须决定操作应串行、合并、改写成可交换形式,还是交给服务端强制排序。把标题设为 B、加一、切换状态、删除和支付命令的选择不同。
第三,看能否只恢复受影响的操作。如果提交 B 后 A 才失败,恢复 A 之前的整份快照可能把 B 一并抹掉。高质量回答会删除或标记失败的 A,只用有效的权威响应推进基础状态,再重放剩余意图。如果服务端版本冲突导致重放不再安全,界面应展示当前服务端值,让用户明确解决冲突。
最后,看生产级处理:待确认和错误状态对键盘与辅助技术用户仍然可操作;不会把取消请求误认为服务端回滚;测试会强制覆盖所有响应顺序、失败顺序、重新拉取竞态、重试和冲突,而不是只验证正常路径。
回答前需要澄清的问题
- 一次操作的语义是什么? 绝对赋值
setTitle("B")可以覆盖更早的标题草稿,increment(1)可能要求两次操作都提交;toggle()在重试时含义不清,发送明确目标值更安全。操作语义决定能否合并。 - 保存等待期间还能否再次提交? 禁用控件能得到简单的串行流程,但可能破坏编辑体验。如果必须允许继续输入,应把本地草稿单独保存,再排队或合并提交,或者使用带版本的并行协议。
- 谁决定写入顺序? 如果服务端只有无条件的“最后到达者获胜”,客户端必须串行发送顺序相关的保存。若 API 接受基础版本或客户端序号,并拒绝过时写入,才可以考虑受控并行。
- 乐观操作是否低风险且可逆? 点赞、标签和草稿往往适合乐观反馈。支付、破坏性操作、受权限控制的修改和产生不可逆外部效果的操作,可能应该显示待确认,而不是提前宣称成功。
- 后台数据源是否会更新同一记录? 重新拉取、订阅、另一个浏览器标签页和协作者都可能改变基础状态。状态模型要识别资源版本,并定义待确认意图能否安全重放到新基础上。
- 用户需要感知什么? 明确每一行是否需要保存中、重试和冲突标记,焦点能否移动,以及哪些保存或失败消息需要播报,同时避免每次按键都触发实时区域消息。
30 秒回答框架
“我会把最新服务端确认的任务作为基础状态,把每次本地提交表示为带 ID 的意图,界面把待确认意图叠加在基础状态上。对标题替换,我默认每个任务只有一个在途保存,并把排队草稿合并成最新标题;请求 ID 只能防止旧响应覆盖界面,不能阻止服务端最后写入旧请求。成功时我采用返回的权威版本、删除对应意图并重放剩余意图;失败时只删除失败意图,不恢复整份旧快照。版本冲突会暂停自动重放并展示双方值。我会提供逐项待确认和错误状态、保持焦点,并测试乱序响应、成功失败交错、重新拉取竞态、重试、冲突和离线恢复。”
分步骤深入解答
1. 在选库之前先定义一个不变量
每个资源保留一个已确认的 base 和一个有序的 pending 列表。每个待确认项包含稳定的客户端操作 ID、意图与载荷、提交顺序和当前状态。显示状态是一个纯投影:
view = fold(base, pending in logical order, applyIntent)不变量是:界面等于最新被服务端接受的状态,再叠加所有仍有资格执行的本地意图。这样,晚到的重新拉取或响应只是对账输入,不会直接获得覆盖界面的权力。
React 的乐观 reducer 模式体现了同样的分离:Action 等待期间基础值发生变化时,React 可以基于新基础重新执行 reducer。客户端缓存库可以管理 mutation 生命周期回调,但不会替产品决定操作语义。无论实现使用 React state、TanStack Query、其他缓存还是自定义 store,这个不变量都适用。
不要维护 serverTask、formTask、optimisticTask 三份互相独立的数据,再用零散 Effect 同步。尚未提交的表单草稿要单独保存,因为打字还不是 mutation;提交时再把它转换成拥有稳定身份的意图。
2. 根据操作语义选择顺序,而不是根据延迟选择
常用策略有三种:
| 策略 | 适用场景 | 代价或风险 |
|---|---|---|
| 按资源串行 | 顺序相关的写入;API 没有顺序保护 | 后续操作需要等待,但服务端顺序可证明 |
| 串行并合并 | 只有最后一个未发送值重要,例如连续提交标题 | 中间已提交值会被有意丢弃 |
| 带服务端契约的并行 | 操作独立或可交换,或 API 强制基础版本/客户端序号 | 吞吐更高,但必须显式处理对账和冲突 |
对于这个标题编辑器,按任务 ID 串行,并合并尚未发送的标题变化。如果 A 在途时又提交 B,界面立即显示 B,但网络队列只保留 B 作为下一次写入。A 结束后,再用最新已接受版本发送 B。TanStack Query 文档说明 mutation 默认并行,并提供 mutation scope 让同一 scope 串行;不用该库也可以实现同样策略。
只有语义更强时才安全并行。API 可以拒绝过时的基础版本、接受同一编辑会话单调递增的客户端序号,或者提供真正可交换的操作。React 官方文档也明确提醒,自定义异步 Transition 不保证请求顺序,仍需使用提供顺序保证的高层 Action 或显式队列。只在浏览器记录最新请求 ID,能够防止旧响应把可见的 B 改回 A,却无法阻止服务端最终存成 A。取消 A 也不能证明服务端没有提交 A。
3. 成功对账不能相信到达顺序
每个响应都应标明对应操作,并返回权威资源和版本。串行模式下,处理当前活动操作,采用返回的基础状态,删除该操作,再重放排队意图。A 完成时,B 仍处于待确认状态,所以界面不会闪回 A。
受控并行时,先用操作 ID 匹配响应,再验证资源版本或确认信息。不能因为 Promise 已完成就直接赋值响应数据。早于当前确认版本的响应不能替换基础状态;只能删除该响应明确确认的操作。如果服务端会规范化数据,例如裁剪标题,应先把规范化结果作为新基础,再重放后续本地意图。
后台重新拉取遵循相同规则。当前基础是版本 11 时,返回版本 12 的拉取结果可以推进基础状态;若待确认意图语义允许,再把它们叠加到版本 12。返回版本 10 的结果是过时证据,不能让基础状态倒退。
4. 按操作恢复,不能按快照恢复
假设 A 和 B 都已经乐观显示,随后 A 失败。恢复 A 之前捕获的对象,会把 B 一起删除。正确做法是标记或删除 A,保留 B,再重新计算投影。绝对标题赋值可以继续基于当前基础发送 B;顺序相关的增量操作则可能要等待、重新计算,或因原前提不再成立而失败。
把失败分成可行动的状态:
- 校验或权限失败在用户修改输入或权限前是终态;在控件附近展示被拒绝值和服务端原因。
- 短暂网络失败可以提供重试。只有服务端契约保证重试安全时才复用同一操作身份;否则要先确认原请求是否已经提交。
- 版本冲突说明基础状态被其他人改变。采用或重新获取当前服务端值,对比本地意图;只有合并规则得到证明时才自动重放。标题冲突应展示当前值和提议值,不能静默二选一。
- 未知结果表示请求可能已经提交,只是响应丢失。“本地回滚后按新操作重试”可能重复不可幂等的行为。
有些操作不应乐观显示。如果失败难以撤销、会改变授权、会扣款,或会制造误导性的法律或业务状态,应立即显示真实的待确认反馈,等服务端接受后再确认成功。
5. 让推测状态可见且无障碍
乐观状态不应伪装成已确认状态。标记受影响任务正在保存,保留已提交值,并提供局部重试或冲突入口。不要禁用无关任务。采用串行时,还要区分当前在途保存与较新的排队值,让监控和错误消息能够对应正确意图。
保存成功、失败或缓存对账时保持键盘焦点。状态区域可以播报“正在保存任务标题”“任务标题已保存”或“保存失败”,且不需要移动焦点。W3C 的 status 角色具有礼貌播报的实时区域语义;状态容器应在消息变化前就存在,也不要每次按键都播报。字段错误要与对应字段建立关联,视觉状态也不能只依赖颜色。
6. 测状态机,也要观察策略结果
测试所选顺序策略,不能假装每种策略都允许相同事件轨迹。串行路径要先断言 B 已经可见但在 A 结束前不会发出,再覆盖 A 成功或失败后 B 成功或失败、响应丢失后的对账,以及服务端规范化。对于任何受支持的并行路径,使用可控传输层和服务端 stub,分别强制 A 后 B、B 后 A 的处理顺序与响应顺序。每个事件后都断言界面投影、排队操作、确认版本和最终服务端值。
继续注入 mutation 等待期间的新版本拉取和旧版本拉取、协作者冲突、离线后恢复、组件卸载再挂载、重复提交,以及服务端提交后才收到取消。验证键盘焦点、状态播报、重试标签,以及错误是否关联正确的任务和操作。
生产指标要把感知速度与正确性分开:乐观渲染延迟、确认延迟、失败与回滚率、冲突率、队列等待、被合并操作数、重试次数和对账不一致。一个很快但经常突然改成意外值的界面,没有满足产品契约。
高质量示范回答
“我会先确认每个提交标题是否都必须保存,还是只保留用户最后一次意图。这个场景只需要最终标题,API 也会返回资源版本,因此同一任务不需要并行写。我会允许用户继续编辑,但按任务 ID 串行网络保存。如果 A 在途时用户又提交 B,界面立即显示 B,B 成为合并后的排队意图。
每个任务的状态包含确认基础、一个活动操作和最多一个排队标题意图。每个操作都有客户端 ID 和提交时使用的基础版本。显示标题优先取排队值,其次是活动乐观值,最后才是确认标题。A 成功后,我采用响应中的权威任务和版本。因为 B 还在等待,界面不会显示 A;随后用新版本发送 B。A 失败时,我只删除 A;如果是短暂故障,仍允许保存 B。校验或权限失败会一直关联被拒绝的意图。
如果服务端报告其他用户已经推进版本,我会暂停自动提交,获取或采用当前标题,并展示服务端值和提议值供用户解决。我不会只忽略旧响应,因为这不能阻止旧请求最后写入服务端;也不会把 abort 当作回滚。
每个任务都有自己的保存中、已排队、失败或冲突状态。焦点保留在编辑器内,预先存在的礼貌状态区域只播报提交结果,不播报每个字符。测试会分别控制 Promise 返回和服务端处理顺序,以证明乱序成功、成功失败交错、过时拉取、冲突、响应丢失、重试、离线恢复和组件卸载后的最终界面与服务端值。”
常见错误
- 保存一份 mutation 前快照,任何错误都恢复它 → 晚到的失败会抹掉更新的乐观操作 → 删除失败操作,再用确认基础和剩余意图重新计算。
- 只保留最新响应 ID → 界面可能正确,服务端却最后提交了旧请求 → 串行顺序相关的写入,或要求服务端强制版本/序号语义。
- 使用一个全局 loading 标志 → 无关控件被冻结,错误也无法关联资源 → 按资源和操作 ID 跟踪待确认与错误状态。
- 假设所有 mutation 都能重放 → 切换、增量、删除和不可逆命令的失败语义不同 → 启用乐观行为前定义明确意图和合并规则。
- 认为取消就撤销了请求 → 服务端可能在收到取消前已经提交 → 对账权威状态,并为未知结果设计重试。
- 允许任何拉取结果覆盖缓存 → 过时拉取或迟到响应让确认状态倒退 → 比较资源版本,并把有效待确认意图重放到最新基础上。
- 隐藏全部待确认状态来营造即时感 → 用户无法解释后续修正,也无法重试对应操作 → 保留乐观值,同时显示局部保存、失败和冲突状态。
- 只按提交顺序测试成功路径 → 最难的竞态没有被执行 → 在测试矩阵中控制响应顺序、服务端顺序、失败、重新拉取、重试和重新挂载。
追问及应对
如果用户可以离线编辑几个小时,需要改变什么?
待确认操作必须持久化,且绑定已登录账户和资源;重放前要刷新授权与权威版本。保存意图语义和稳定操作 ID,不能保存捕获的界面快照。重新联网后先获取基础状态,丢弃用户明确取消的操作,只重放拥有有效合并规则的操作。权限过期、资源已删除和数据结构变化都需要可见的阻塞状态。长期离线协作可能超出简单乐观队列能力,需要领域专用合并操作或协同编辑协议。
服务端返回版本后,是否所有 mutation 都能并行?
不能。版本能说明响应代表哪个状态,却不会自动定义两次写入的顺序或合并方式。只有服务端原子检查提交的基础版本、强制序号,或操作相互独立、真正可交换时,并行才安全。否则两个绝对赋值仍可能按错误顺序到达并提交。对单个顺序相关资源,串行往往是更清晰的契约。
服务端分配真实 ID 时,如何乐观创建记录?
渲染前生成稳定客户端 ID,同时用作操作身份和临时列表 key。成功后记录客户端 ID 到服务端 ID 的映射,替换确认实体,不要让无关行重新挂载。若服务端支持,用操作身份为重试去重。创建失败时只删除或标记该乐观实体。后续若要操作临时实体,应等待 ID 映射,或使用确认后能够重写目标的操作队列。
什么时候应该主动选择悲观界面?
如果提前显示成功会严重误导用户、恢复不能在本地完成、冲突常见,或者操作不可逆且风险高,就应选择悲观界面。支付、权限授予、法律提交或破坏性批量操作可以立即确认用户已点击并显示真实待处理状态,等权威确认后再显示成功。界面仍可以及时响应,但不能声称尚未发生的结果。