乐观更新
乐观更新是在服务端确认之前,先把预期的操作结果显示出来。例如点击“收藏”后立即点亮图标,同时发送请求;服务端确认后保留结果,明确拒绝时再撤销这次预期。它缩短的是用户等待反馈的时间,不会让网络请求本身更快。
难点在于区分“看起来已经修改”和“已经保存”。重复点击、响应乱序、网络中断和刷新页面都可能让这两种状态分离。实现时要保留服务端已确认的值、尚未确认的操作,以及每个响应属于哪次操作。
1. 面试核心
先记住这几条,再读正文- 乐观更新把已确认数据和待确认操作分开管理,页面根据两者计算显示结果。它适合容易预测、失败后能解释和恢复的交互。卡 · 已确认状态与显示状态
- 回滚只能撤销对应操作的影响。并发修改时,恢复整份旧快照可能抹掉后续已成功的修改;可以按实体串行,或基于已确认状态重放剩余操作。卡 · 并发修改与回滚范围
- 超时、断网和客户端取消不证明服务端没有执行。结果未知时应查询操作结果,或按服务端约定用同一操作 ID 重试,不能直接断言失败。卡 · 结果未知、重试与版本
- 本地存储要区分缓存和待发送操作。恢复出乐观界面不等于恢复出已保存状态;账号、文档、版本、操作 ID 和重试语义都要对应。卡 · 综合题
- 幂等防重复,版本检查防覆盖,两者解决不同问题。取消旧读取或忽略旧响应也只是客户端的一部分防护,服务端仍需处理并发写入。卡 · 后台读取与多个页面
2. 已确认状态与显示状态
已确认状态是客户端最近一次从服务端得到的可信结果;待确认操作是用户已经发起、还没有得到确定结论的修改。显示状态可以看作把待确认操作应用到已确认状态后的结果。
以收藏为例,服务端最近确认 liked=false,用户提交“设为 true”。页面可以立即显示已收藏,同时显示“保存中”。这时不能把本地的 true 直接记为服务端最新状态,否则失败时已经不知道该恢复什么。
| 阶段 | 已确认值 | 待确认操作 | 显示与提示 |
|---|---|---|---|
| 初始 | false | 无 | 未收藏 |
| 发起请求 | false | 设为 true | 已收藏,保存中 |
| 确认成功 | 服务端返回的值 | 无 | 按确认结果显示 |
| 明确拒绝 | 服务端返回的当前值 | 无 | 按当前值显示,并解释原因 |
| 响应丢失 | 仍是上次确认值 | 保留原操作 | 保留预期,提示结果待确认 |
成功响应也不一定与本地预测完全相同。服务端可能规范化文本、生成正式 ID,或者重新计算计数。确认时应接受协议规定的权威结果,而不只是把 loading 改成 false。
新增列表项可以先用临时 ID 显示“发送中”,确认后建立临时 ID 与正式 ID 的对应。确认响应和推送消息都可能带回同一条记录,合并时需要稳定的操作 ID 或实体身份,避免显示两条。列表排序、筛选和分页也要重新考虑:临时项是否应进入当前页,不能只靠往数组末尾追加。
2.1 适用条件
收藏、完成标记等结果容易预测的操作适合先显示预期;删除可以先暂时移除,但应保留恢复所需的信息。失败率高、依赖服务端复杂计算,或需要用户据此立即作出重要决定的操作,更适合先显示处理中。
例如支付按钮可以立即变成“处理中”,但不能在支付系统确认前显示“付款成功”。乐观更新改变反馈方式,不改变权限、库存或付款事实的确认依据。
3. 单个实体的最小实现
同一实体一次只处理一个未决操作,是控制并发复杂度的一种直接办法。下面允许用户立即看到收藏结果,但确认完成前拒绝第二次修改;网络异常后保留原操作,允许使用同一 ID 重试。不同实体可以各自创建独立实例。
这里的 send 是应用注入的请求函数,协议作如下约定:
- 请求包含
id、value、baseVersion,服务端把操作归属绑定到已认证用户及目标实体。 - 服务端对同一操作 ID 与相同请求内容返回同一个终态;重复 ID 携带不同内容应拒绝。操作记录与业务修改必须可靠地关联,不能只在浏览器内去重。
accepted表示操作已经确认,rejected表示操作明确未执行。两者都返回该响应对应的current,包含布尔值liked和单调递增的整数version。- 无法确定结果、响应解析失败或响应不符合约定时,
send抛出异常。HTTP 错误码怎样映射由接口合同规定,不能把所有非2xx都擅自当作“没有执行”。
function validRecord(record) {
return record !== null && typeof record === 'object'
&& typeof record.liked === 'boolean'
&& Number.isSafeInteger(record.version) && record.version >= 0;
}
export function createOptimisticFlag(initial, send, makeId) {
if (!validRecord(initial)) throw new TypeError('初始状态无效');
let confirmed = { liked: initial.liked, version: initial.version };
let operation = null;
let status = 'idle';
let message = '';
function snapshot() {
return {
confirmed: { ...confirmed },
operation: operation && { ...operation },
visible: operation ? operation.value : confirmed.liked,
status,
message,
};
}
async function transmit() {
status = 'pending';
message = '';
try {
const reply = await send({ ...operation });
if (!reply || !['accepted', 'rejected'].includes(reply.status)
|| !validRecord(reply.current)
|| reply.current.version < confirmed.version) {
throw new Error('响应不符合约定');
}
confirmed = {
liked: reply.current.liked, version: reply.current.version,
};
status = reply.status === 'accepted' ? 'idle' : 'failed';
message = status === 'failed' ? '修改未生效,请检查权限或版本' : '';
operation = null;
} catch {
status = 'unknown';
message = '结果尚未确认,请重试确认';
}
return snapshot();
}
return {
snapshot,
set(value) {
if (typeof value !== 'boolean') throw new TypeError('需要布尔值');
if (operation) throw new Error('上一操作尚未确认');
const id = makeId();
if (typeof id !== 'string' || id.length === 0) {
throw new TypeError('需要有效操作 ID');
}
operation = { id, value, baseVersion: confirmed.version };
return transmit();
},
retry() {
if (status !== 'unknown') throw new Error('当前没有待确认操作');
return transmit();
},
};
}
set(true) 在调用 send 之前设置好 operation,因此调用后立刻读取 snapshot().visible 就是 true。返回的 Promise 完成后再刷新界面,便能显示确认或错误结果。快照和传给 send 的请求都使用副本,调用方不能通过修改这些对象改变内部状态。
下面用一个可手动确认的 Promise 展示先显示、后确认的顺序;它只模拟响应时机,没有连接服务端。将它接在前一个代码块后运行:
let accept;
const model = createOptimisticFlag(
{ liked: false, version: 4 },
() => new Promise(resolve => { accept = resolve; }),
() => 'example-operation-1',
);
const saving = model.set(true);
console.log(model.snapshot().visible, model.snapshot().status);
// true pending
accept({ status: 'accepted', current: { liked: true, version: 5 } });
await saving;
console.log(model.snapshot().visible, model.snapshot().status);
// true idle
页面可以在 pending 时禁用该实体的修改按钮,在 unknown 时展示“重试确认”。真实应用应使用稳定、足够唯一的操作 ID,例如在支持的安全上下文中调用 crypto.randomUUID();上例的固定字符串只用于一次演示。
这个实现没有后台刷新、推送合并和账号切换接口,不能直接把它当成通用缓存。组件卸载后要停止更新该组件;账号或实体变化时使用新的状态容器,避免旧请求结果写进新对象。取消请求也不能撤销已经到达服务端的修改。
4. 并发修改与回滚范围
恢复整份快照只在期间没有其他相关修改时才安全。假设列表最初是 [A],请求甲乐观新增 B,请求乙随后新增 C。乙成功后,甲失败;如果甲恢复自己保存的 [A],成功的 C 也被删除了。
处理办法要与操作语义匹配:
| 策略 | 怎样处理失败 | 适用边界 |
|---|---|---|
| 按实体串行 | 确认后再发送下一次修改 | 控制简单,但交互要说明等待或排队 |
| 按操作撤销 | 删除对应临时项或撤销对应补丁 | 必须能识别独立影响;旧下标可能已失效 |
| 重算显示状态 | 更新已确认基线,再按顺序应用剩余操作 | 操作要可重放,并处理确认与基线的关系 |
| 重新读取 | 获取权威结果后重建页面 | 请求未决时仍要保留其身份,避免旧结果覆盖预期 |
“基线加待处理操作”并不意味着把所有操作重新执行到服务端。它可以只是客户端重新计算界面。服务端已确认的操作要从待处理集合移出,并确保基线已经包含其结果;否则可能显示两次或暂时丢失。
只接收最后一个响应,不能保证服务端按最后一次点击保存。 如果先发“设为已收藏”,再发“设为未收藏”,第二个请求先在服务端完成,第一个后完成,即使客户端忽略第一个响应,服务端最终仍可能是已收藏。需要通过串行、条件写入或明确的服务端操作顺序解决,而不是只加一个前端递增序号。
4.1 后台读取与多个页面
后台读取也可能覆盖乐观结果。例如点击收藏前发出的读取请求,点击后才返回旧值。可以取消不再需要的读取、检查请求所属版本,或者把读取结果更新到基线后重新应用待处理操作。
TanStack Query 的乐观更新文档区分了仅在 UI 层展示待提交变量,以及直接修改查询缓存两种方式。修改缓存时要协调正在执行的读取、错误恢复和后续失效刷新。具体库负责缓存生命周期,业务仍需定义同一实体的并发策略。
多标签页、多个设备和其他用户不受当前按钮的禁用状态约束。本地串行只约束当前容器;涉及共享数据时,服务端还要用版本或业务规则检测冲突。收到其他端的新状态,也不能不加区分地清空本端未决操作。
5. 结果未知、重试与版本
响应丢失会留下“已经执行但没有收到确认”的窗口。服务端可能已经保存收藏,返回数据时连接断开。此时直接回滚可以作为暂时的显示选择,但提示不能写成“服务端保存失败”;用户刷新后可能又看到已收藏。
可靠的恢复方式包括按操作 ID 查询结果,或者按接口约定重试同一操作。重试必须保留原 ID、目标和值;生成新 ID 就可能成为新的业务操作。服务端去重记录需要明确保存时间,超过窗口后不能无限假设仍能安全重放。
幂等表示相同操作重复执行的预期效果与执行一次相同。“设为已收藏”比“把收藏状态反转一次”更容易设计重试:后者执行两次会回到原状态。但设置操作也可能产生通知、审计或计费等副作用,仍要由完整接口语义保证去重。
版本检查确认修改基于的旧状态是否仍有效。客户端读取到版本 4,提交时附带 baseVersion=4;服务端只有在当前版本仍是 4 时才应用修改并递增版本。检查与修改必须原子完成,否则两个请求仍可能同时通过检查。
HTTP 可以用强 ETag 与 If-Match 表达条件写入,不匹配时通常返回 412 Precondition Failed。本文示例的整数 baseVersion 是应用协议,不是 ETag 的固定格式。冲突以后应读取当前值并决定是否重新应用用户意图,不能无条件改用新版本重试来绕过冲突。
幂等与版本检查需要配合:原操作已经成功,确认丢失后带旧版本重试,服务端应按操作记录识别已经完成的同一次请求;不能仅因为当前版本已经变化,就把这个重试当成第一次提交失败。
6. 本地存储与恢复
缓存服务端结果和保存离线操作是两件事。只缓存确认后的数据,刷新时可以先展示缓存并重新校验;如果希望刷新后继续发送未完成修改,还要保存操作本身和恢复协议。
一个待处理记录至少需要说明它属于哪个账号、哪个实体、哪种数据格式,以及操作 ID、目标值和基线版本。下面只是单条记录的格式与读取检查,没有自动发送逻辑:
export function readPending(storage, key, userId, entityId) {
try {
const text = storage.getItem(key);
if (text === null) return null;
const item = JSON.parse(text);
if (!item || item.schemaVersion !== 1
|| item.userId !== userId || item.entityId !== entityId
|| typeof item.operation?.id !== 'string' || !item.operation.id
|| typeof item.operation.value !== 'boolean'
|| !Number.isSafeInteger(item.operation.baseVersion)
|| item.operation.baseVersion < 0) return null;
return {
schemaVersion: 1, userId, entityId,
operation: {
id: item.operation.id,
value: item.operation.value,
baseVersion: item.operation.baseVersion,
},
};
} catch {
return null;
}
}
readPending 对损坏 JSON、错误账号、错误实体、旧格式和无效字段返回 null。这是读取不可信本地数据的基本边界,不是授权校验;重新发送时仍由服务端验证当前身份与权限。读不到记录也不证明服务端从未收到过操作。
如果产品承诺刷新后恢复操作,应在发送前完成相应的本地持久化,并在写入失败时明确提示恢复能力受限。操作确认后删除本地记录;如果服务端已经成功、客户端还没来得及删除就退出,下一次仍须通过同一操作 ID 确认,避免重复提交。
localStorage 写入可能因配额或访问限制失败,而且没有跨多个读改写步骤的事务,不能把两个标签页的“读取队列、追加、写回”当成原子操作。操作量较大或需要事务时可以使用 IndexedDB;多标签页还需明确谁发送、怎样协调。账号退出时清理或隔离记录,不能让下一个账号接着发送前一个账号的离线操作。
7. 常见问题
7.1 防抖能替代请求去重吗?
不能。防抖减少一段时间内发起的请求,却无法处理刷新后的重发、网络重试或多个页面同时提交。它还会延迟发送,必须明确用户最后一次意图如何保留。幂等与并发控制仍需单独设计。
7.2 请求取消以后就可以恢复旧值吗?
只能根据产品规则调整本地显示,不能据此认定服务端已经撤销。客户端停止等待时,服务端可能已经执行。若要撤销业务操作,需要另一个有明确语义的请求,或者等待原操作结果后再提交反向修改。
7.3 乐观更新必须修改全局缓存吗?
不必。只有当前组件需要预期结果时,可以在 UI 层叠加待处理项;多个组件都应立即反映变化时,再考虑共享状态或查询缓存。无论放在哪里,都需要保留操作身份与确认状态。
8 · 面试题卡片
本页题目与参考回答
8.1 乐观更新的完整处理过程是什么?
考察重点:本地显示、已确认数据、操作状态与失败恢复之间的关系。
相关内容:已确认状态与显示状态、单个实体的最小实现。
参考回答
先保存已确认基线和本次操作,立即显示预期结果,再根据服务端结论完成确认或恢复。 页面应区分保存中、明确失败和结果未知。
成功时接纳服务端返回的规范化数据与版本;明确失败时只撤销本次操作的影响。并发修改需要串行或按操作合并,网络异常则保留操作 ID,通过查询或安全重试确认结果。
8.2 两次乐观更新中,前一次失败为什么不能直接恢复旧快照?
考察重点:回滚范围、并发操作和已确认基线。
相关内容:并发修改与回滚范围。
参考回答
旧快照没有后续修改,整体恢复会把后一次成功的结果一起抹掉。 例如甲新增 B、乙新增 C,乙成功后甲失败,恢复最初的 [A] 会丢掉 C。
可以按实体串行,也可以只移除甲对应的临时项,或从包含乙结果的已确认基线重新应用剩余操作。选择哪一种,要看操作能否独立撤销和重放。
8.3 请求超时后,为什么不能立即认定操作失败并用新 ID 重试?
考察重点:服务端执行与客户端接收之间的间隔、重试身份。
相关内容:结果未知、重试与版本。
参考回答
超时只说明没有及时收到响应,服务端可能已经完成修改。 用新 ID 重试可能被识别为另一笔操作,产生重复记录或副作用。
应保留原目标、操作 ID 和请求内容,查询结果或按服务端幂等约定重试。页面可以显示待确认,并明确重试窗口与无法确认时的处理方式。
8.4 乐观更新怎样配合本地存储,刷新后如何恢复?
题型:综合题
考察重点:确认缓存与离线操作的区别、持久化时机、账号隔离与重放。
相关内容:本地存储与恢复、结果未知、重试与版本。
参考回答
把确认数据和未决操作分别保存,恢复时先校验记录,再确认或重放原操作。 未决记录应包括格式版本、账号、实体、操作 ID、目标值和基线版本,不能只存一个已经改变的 UI 值。
承诺刷新恢复时,应先可靠保存操作再发送,确认后清除;清除前退出可能再次读到已执行操作,因此服务端需要识别重复。还要处理存储失败、过期、权限变化和多标签页竞争,不能把本地记录当作服务端已保存的证明。
8.5 幂等键和版本检查分别防止什么问题?
考察重点:重复执行、丢失更新与原子条件写入。
相关内容:后台读取与多个页面、结果未知、重试与版本。
参考回答
幂等键识别同一次操作的重复请求,版本检查阻止基于过期状态覆盖新数据。 两个不同操作都有唯一 ID,仍可能互相覆盖;只做版本检查,也可能无法识别已成功但响应丢失的重试。
服务端应可靠地关联操作结果和业务修改,并原子地检查版本与写入。HTTP 的强 ETag 配合 If-Match 可以表达条件写入,条件不满足时返回冲突结果,由客户端重新协调用户意图。
9. 参考资料
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明