Skip to content

乐观 UI 是架构决策,不是小修小补 ​

译注:本文编译自 dev.to 文章《Optimistic UI is an architectural decision, not a minor UX tweak》,作者在开发开源邮件优先 CRM 项目 Rulrmail 时,记录了引入乐观 UI 后从浏览器到数据库逐层暴露的问题。原文链接见文末。

在开发开源、邮件优先的 CRM 项目 Rulrmail 时,作者只想要一个小效果:给邮件加星标时,星标应当立刻亮起,而不是等一个来回的邮件服务器请求之后才亮。

这种技术有个友好的名字:乐观 UI(optimistic UI)。它意味着界面立即按用户的请求行动,仿佛服务器已经答应了一样。请求在后台发出,万一服务器拒绝,界面再悄悄撤销这次变更,并说明原因。

大多数框架把它包装得像一行代码。在 Inertia(Laravel + Vue)中确实如此:

javascript
router.post(`/mailbox/${account}/actions`, { uids, action: 'star' }, {
    optimistic: (props) => ({
        messages: starred(props.messages, uids),
    }),
})

看起来像一次 UI 微调。其实不是。这是应用不再一次只做一件事的分水岭。

1. 点击开始重叠 ​

以前每次点击都排队等待。现在用户一秒内可以给三条消息加星。默认情况下,新的访问会取消正在进行的请求,屏幕会短暂地把第一条消息的星标“取消”掉。

解决办法是让请求并行执行:

javascript
router.post(url, data, {
    async: true,          // 不要取消其他点击
    preserveState: true,  // 保留选中状态、滚动位置、已打开的面板
    optimistic: () => changes,
})

2. 反馈滞后于变更 ​

星标是即时的,但“已加星 · 撤销”的小提示仍在等待服务器。屏幕说完成了,确认信息却说再等等。

解决办法是翻转主导权。页面自己显示提示,并带上一个 id,把这个 id 一并发送。服务器用同一个 id 回应,于是它的提示更新屏幕上已有的那一条,而不是新增第二条。

javascript
const toastId = uuid()
toasts.show({ id: toastId, message: 'Starred 1 message.', undo: pending })
router.post(url, { ...data, toast_id: toastId }, options)
php
// 服务器的提示替换页面的提示,或将其变为错误提示。
return back()->with('toast', Toast::success($message)->toArray($request->toastId()));

3. 用户在服务器行动之前就操作了 ​

有了即时的“撤销”,用户会在原始变更还没到达服务器时就点击它。此时根本还没有东西可撤销。

于是页面立即恢复原状,记住这个请求,并在服务器响应到达的那一刻发出真正的撤销:

javascript
if (toast.undo.token === null) {
    waitingForToken.set(toast.id, restore)  // 等响应到达时再发送
    router.replace({ props: (current) => ({ ...current, ...restore(current) }) })
}

4. 共享状态开始竞争 ​

两个请求同时进行,意味着服务器为“下次页面加载”存在 session 里的消息被错误的请求取走并消失了。任何默默假设一次只有一个请求的逻辑都会失效。解法同上:页面自己保留所需状态,而不是依赖服务器的残留。

5. 数据库也察觉到了 ​

连本地数据库都在抱怨。SQLite 一次只允许一个写入者,并行请求相互冲突,直到日志里出现 database is locked。

有人可能会说:谁在乎 SQLite,那只是本地开发。作者在乎。这无论如何都是一个危险信号:如果两个请求能在我的笔记本上冲突,它们在生产环境也能冲突,只是频率更低、更难复现。本地正是这类 bug 修复成本最低的地方。几个设置解决了问题:等待锁而不是失败,让读写并行,并提前获取写锁。

php
// config/database.php
'sqlite' => [
    // …
    'busy_timeout' => 5000,            // 等待锁,不要失败
    'journal_mode' => 'wal',           // 读者与写者互不阻塞
    'synchronous' => 'normal',
    'transaction_mode' => 'IMMEDIATE', // 由读转写的操作不能等待
],

6. 最后的细节:幻觉很脆弱 ​

为一个已经显示在屏幕上的变更展示加载条,等于告诉用户你不相信自己的 UI:

plaintext
router.post(url, data, { showProgress: false, optimistic: () => changes })

另外,一个只在 HTTPS 下存在的浏览器 API(crypto.randomUUID())在纯 HTTP 的开发域名上悄悄让所有操作失效,而运行在 127.0.0.1(被视为安全上下文)上的测试却一直是绿的:

javascript
export function uuid() {
    const bytes = crypto.getRandomValues(new Uint8Array(16)) // 纯 HTTP 下也能用
    bytes[6] = (bytes[6] & 0x0f) | 0x40
    bytes[8] = (bytes[8] & 0x3f) | 0x80
    const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
    return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}

经验教训 ​

乐观 UI 不是刷一层漆。当屏幕不再等待服务器的那一刻,你就构建了一个这样的系统:

  • 请求会重叠;
  • 屏幕与服务器会短暂不一致;
  • 用户会对尚不存在的东西进行操作;
  • 从浏览器到数据库的每一层都必须应对。

把它当作架构变更来规划,而不是一个功能。尽早决定每个时刻谁拥有真相、如何回滚,以及两件事同时发生时该怎么办。

做得好,没人会注意到这些。星标只是亮起。这正是重点。

原文链接 ​

https://dev.to/raffi001/optimistic-ui-is-an-architectural-decision-not-a-minor-ux-tweak-576n