乐观更新的核心不是立即修改界面,而是先记录用户意图,再把服务端事实和界面投影分开管理。本文用 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. 真实场景中的失败与重放

请求失败时,代码删除对应队首操作,界面会自动回到“服务端事实加剩余队列”的结果。这就是回滚,但它不是保存一个旧布尔值再恢复,而是重新计算,因此连续操作也能保持正确。

网络断开时,新的操作仍然可以进入队列。队列处理发现请求失败后会回滚该操作。生产系统通常会进一步区分错误类型:网络错误可以保留操作并显示“待重试”,鉴权失败需要清理并要求登录,业务拒绝则应该确认失败并展示服务端原因。示例为了突出流程,将失败操作移除;如果要支持离线重试,可以给操作增加 retryCountnextAttemptAtstatus 字段,使用定时器或页面恢复在线事件触发重试。

重复提交不一定意味着重复请求。按钮连续点击产生的是多个有顺序的意图,示例会按队列顺序发送“设为真”和“设为假”。如果业务要求点击幂等,可以在入队前合并同一资源的连续目标值;但合并必须建立在明确的业务语义上,不能为了减少请求而无条件丢掉用户操作。

服务端校正同样不能被本地投影永久掩盖。示例中的“模拟服务端校正”会改变服务端事实,然后调用重新同步。重新同步时保留尚未完成的队列,并从新快照开始重放;这比简单执行 setLiked(serverLiked) 更完整,因为未完成的本地意图仍然存在。

在多标签页、多人协作或服务端有版本控制时,建议让接口返回资源版本,例如 versionupdatedAt,更新请求携带客户端已知版本。服务端可以返回冲突,前端再选择丢弃、合并或重新排队。客户端的 epoch 只能解决本次页面内的过期响应,不能替代服务端的并发控制。

5. 总结

乐观更新需要管理的是一组状态关系,而不是一个立即变化的本地变量:

  • 将用户操作记录为带唯一标识的本地意图,明确保存目标值。
  • 将最近一次成功响应或重新拉取结果作为服务端事实。
  • 将界面状态定义为服务端事实重放待处理意图后的投影。
  • 通过串行队列控制同一资源的操作顺序,避免响应竞态。
  • 失败时移除对应操作并重新计算投影,保留其他仍然有效的意图。
  • 重新同步时更新服务端快照,并让旧请求通过版本或批次标识失效。
  • 对网络错误、业务拒绝和鉴权失败使用不同的重试与提示策略。

当状态可以被重放,调试、日志记录和离线重试都会更清晰。React 负责渲染这个状态模型,真正决定可靠性的,是本地意图、服务端事实和界面投影之间是否保持了明确边界。