乐观更新的核心不是立即修改界面,而是先记录用户意图,再把服务端事实和界面投影分开管理。本文用 React 实现一个可排队、可回滚、可重放的状态同步示例,并处理请求竞态、重复提交、服务端校正和网络断开等场景。
1. 先区分三种状态
很多乐观更新的问题,来自把三个概念塞进同一个 useState:用户想做什么、服务端最终确认了什么,以及界面此刻应该展示什么。
以“点赞”为例,用户连续点击两次,真实过程可能是:第一次请求还没有返回,第二次操作又产生了;网络随后断开;服务端还可能因为权限、风控或业务规则拒绝第一次请求。如果代码只是执行 setLiked(!liked),就无法回答下面这些问题:当前界面值是否已经被服务端确认?失败时应该撤销哪一次操作?晚到的响应是否还能覆盖新状态?
可以将边界拆成三层:
| 状态 | 含义 | 生命周期 |
|---|---|---|
| 本地意图 | 用户按下按钮产生的操作,例如“希望变为已点赞” | 进入队列后保留,直到成功或明确失败 |
| 服务端事实 | 最近一次服务端确认的资源状态 | 成功响应或重新拉取后更新 |
| 界面状态 | 服务端事实叠加未完成意图后的投影 | 随队列变化,不能直接当作事实 |
界面状态可以写成:view = replay(serverFact, pendingIntents)。这条公式很重要:界面不是独立存储的第三份事实,而是可以从服务端状态和待处理操作重放得到的结果。
2. 用操作队列代替布尔值
每个用户操作至少需要一个唯一标识、目标值和创建时间。这里不保存“执行了几次取反”,而是保存明确目标,例如 { desired: true }。明确目标比相对操作更适合重试和重放,因为重试不会因为当前状态变化而产生不同含义。
队列还解决了请求竞态。示例采用同一资源串行发送:队首操作成功后才发送下一项。这样服务端看到的顺序与本地意图一致。若业务必须并行请求,也需要在响应中携带版本号或操作序号,并丢弃过期响应;仅依赖响应返回顺序是不可靠的。
失败处理也不应该直接把界面设置成 false。正确做法是删除失败的那条意图,保留服务端事实,再将剩余意图重新投影。假设队列是“设为真、设为假”,第一条失败后,第二条仍然是有效的用户意图,不能一起丢弃。
3. 一个可运行的 React 示例
下面的代码可直接放入 Vite React 项目的 src/App.jsx。其中 serverApi 用延迟 Promise 模拟服务端,networkOnline 模拟网络开关,serverAllowsLike 模拟服务端校正规则。真实项目中只需要把这部分替换成 fetch 或项目已有的请求封装。
import { useEffect, useMemo, useReducer, useState } from 'react'
let networkOnline = true
let serverAllowsLike = true
const serverApi = {
liked: false,
version: 0,
async read() {
await delay(350)
if (!networkOnline) throw new Error('network offline')
return { liked: this.liked, version: this.version }
},
async update(desired) {
await delay(500)
if (!networkOnline) throw new Error('network offline')
this.liked = desired && serverAllowsLike
this.version += 1
return { liked: this.liked, version: this.version }
},
correct() {
this.liked = false
this.version += 1
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const initialState = {
server: { liked: serverApi.liked, version: serverApi.version },
pending: [],
inFlight: null,
epoch: 0,
error: ''
}
function reducer(state, action) {
switch (action.type) {
case 'enqueue':
return {
...state,
pending: [...state.pending, action.op],
error: ''
}
case 'start':
return { ...state, inFlight: action.id }
case 'success':
if (action.epoch !== state.epoch || state.pending[0]?.id !== action.id) return state
return {
...state,
server: action.server,
pending: state.pending.slice(1),
inFlight: null,
error: ''
}
case 'failure':
if (action.epoch !== state.epoch || state.pending[0]?.id !== action.id) return state
return {
...state,
pending: state.pending.slice(1),
inFlight: null,
error: `操作 ${action.id} 失败,已回滚:${action.message}`
}
case 'resync':
return {
...state,
server: action.server,
inFlight: null,
epoch: state.epoch + 1,
error: ''
}
default:
return state
}
}
export default function App() {
const [state, dispatch] = useReducer(reducer, initialState)
const [online, setOnline] = useState(true)
const viewLiked = useMemo(
() => state.pending.reduce((value, op) => op.desired, state.server.liked),
[state.server.liked, state.pending]
)
useEffect(() => {
const op = state.pending[0]
if (!op || state.inFlight) return
const epoch = state.epoch
dispatch({ type: 'start', id: op.id })
serverApi.update(op.desired).then(
(server) => dispatch({ type: 'success', id: op.id, epoch, server }),
(error) => dispatch({ type: 'failure', id: op.id, epoch, message: error.message })
)
}, [state.pending, state.inFlight, state.epoch])
async function resync() {
try {
const server = await serverApi.read()
dispatch({ type: 'resync', server })
} catch (error) {
dispatch({ type: 'resync', server: state.server })
dispatch({ type: 'failure', id: '__none__', epoch: -1, message: error.message })
}
}
function toggleNetwork() {
const next = !online
networkOnline = next
setOnline(next)
}
function enqueue() {
dispatch({
type: 'enqueue',
op: { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, desired: !viewLiked }
})
}
function serverCorrection() {
serverApi.correct()
resync()
}
return (
<main style={{ fontFamily: 'sans-serif', maxWidth: 640, margin: '40px auto', padding: 16 }}>
<h1>可回滚的点赞同步</h1>
<button onClick={enqueue} style={{ fontSize: 20, padding: '10px 18px' }}>
{viewLiked ? '取消点赞' : '点赞'}
</button>
<p>界面投影:{viewLiked ? '已点赞' : '未点赞'}</p>
<p>服务端事实:{state.server.liked ? '已点赞' : '未点赞'},版本 {state.server.version}</p>
<p>待处理操作:{state.pending.length},网络:{online ? '在线' : '断开'}</p>
<button onClick={toggleNetwork}>{online ? '模拟断网' : '恢复网络'}</button>{' '}
<button onClick={resync}>重新同步</button>{' '}
<button onClick={serverCorrection}>模拟服务端校正</button>
{state.error && <p style={{ color: 'crimson' }}>{state.error}</p>}
<p>服务端规则:{serverAllowsLike ? '允许点赞' : '拒绝点赞'}</p>
</main>
)
}
这个示例有几个值得注意的实现细节。viewLiked 只由服务端事实和队列计算,因此点击后可以立即反馈,但不会伪装成已确认事实。success 只接受当前队首和当前 epoch 的响应。重新同步会递增 epoch,让已经发出的旧请求失效,避免迟到响应覆盖新的服务端快照。
4. 真实场景中的失败与重放
请求失败时,代码删除对应队首操作,界面会自动回到“服务端事实加剩余队列”的结果。这就是回滚,但它不是保存一个旧布尔值再恢复,而是重新计算,因此连续操作也能保持正确。
网络断开时,新的操作仍然可以进入队列。队列处理发现请求失败后会回滚该操作。生产系统通常会进一步区分错误类型:网络错误可以保留操作并显示“待重试”,鉴权失败需要清理并要求登录,业务拒绝则应该确认失败并展示服务端原因。示例为了突出流程,将失败操作移除;如果要支持离线重试,可以给操作增加 retryCount、nextAttemptAt 和 status 字段,使用定时器或页面恢复在线事件触发重试。
重复提交不一定意味着重复请求。按钮连续点击产生的是多个有顺序的意图,示例会按队列顺序发送“设为真”和“设为假”。如果业务要求点击幂等,可以在入队前合并同一资源的连续目标值;但合并必须建立在明确的业务语义上,不能为了减少请求而无条件丢掉用户操作。
服务端校正同样不能被本地投影永久掩盖。示例中的“模拟服务端校正”会改变服务端事实,然后调用重新同步。重新同步时保留尚未完成的队列,并从新快照开始重放;这比简单执行 setLiked(serverLiked) 更完整,因为未完成的本地意图仍然存在。
在多标签页、多人协作或服务端有版本控制时,建议让接口返回资源版本,例如 version 或 updatedAt,更新请求携带客户端已知版本。服务端可以返回冲突,前端再选择丢弃、合并或重新排队。客户端的 epoch 只能解决本次页面内的过期响应,不能替代服务端的并发控制。
5. 总结
乐观更新需要管理的是一组状态关系,而不是一个立即变化的本地变量:
- 将用户操作记录为带唯一标识的本地意图,明确保存目标值。
- 将最近一次成功响应或重新拉取结果作为服务端事实。
- 将界面状态定义为服务端事实重放待处理意图后的投影。
- 通过串行队列控制同一资源的操作顺序,避免响应竞态。
- 失败时移除对应操作并重新计算投影,保留其他仍然有效的意图。
- 重新同步时更新服务端快照,并让旧请求通过版本或批次标识失效。
- 对网络错误、业务拒绝和鉴权失败使用不同的重试与提示策略。
当状态可以被重放,调试、日志记录和离线重试都会更清晰。React 负责渲染这个状态模型,真正决定可靠性的,是本地意图、服务端事实和界面投影之间是否保持了明确边界。
评论