前端投票场景的乐观更新实践
适用场景:需要即时反馈的投票/点赞/收藏等交互,API 调用有延迟,用户期望点击后立即看到状态变化。
问题
投票页点红心 → 调 API → 等返回 → 更新 UI。API 延迟 300-500ms 时,用户双击红心会感觉「没反应」,可能重复点击。
方案:乐观更新 + 失败回退
核心逻辑
async function selectVote(submissionId) {
const previousVote = state.vote; // ① 保存旧值
state.vote = submissionId; // ② 乐观更新
renderVoteWorks(); // ③ 立即渲染
try {
const result = await api.castVote(submissionId);
state.vote = result.battle.myVoteSubmissionId; // ④ 以后端为准
} catch (error) {
state.vote = previousVote; // ⑤ 失败回退
renderVoteWorks();
showToast(error.message);
}
}
关键设计决策
-
保存旧值而非硬编码 null:如果用户之前已投过作品 A,现在改投作品 B 但失败,应该恢复到 A 而不是清空。
previousVote保存的是”点击之前的状态”。 -
以后端返回值为准:即使本地乐观更新成功,服务端返回的
myVoteSubmissionId可能因竞态条件与本地不同(比如另一个标签页同时改了投票)。第④步用后端值覆盖本地值,保证最终一致性。 -
失败回退不是「静默失败」:catch 里同时调了
renderVoteWorks()回退 UI 和showToast()告知用户。只回退不提示,用户不知道操作失败了;只提示不回退,UI 和实际状态不一致。
配合服务端持久化
乐观更新只能解决「点击 → 看到反馈」的延迟,不能解决「刷新 → 状态丢失」。需要服务端同时支持:
buildBattle()返回当前用户的myVoteSubmissionId- 前端
initializeBattle()拿到后恢复state.vote
这样刷新后红心仍然亮着,乐观更新的体验闭环才算完整。
边界
- 乐观更新适合「低风险、高频、期望即时反馈」的交互(投票、点赞、收藏)。不适合「高风险、低频、不可逆」(删除、支付、发布)。
- 失败回退会有短暂的 UI 闪烁(乐观更新 → 失败 → 回退),这是可接受的折中。
- 极端情况下(网络极慢,用户在失败回退前已经离开页面),UI 残留的乐观状态不会有实际影响——刷新后会从服务端恢复真实状态。
来源
PB Arena BUG-03 投票阶段乐观更新实践,commit 1b0a90e,2026-08-11。