乐观 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)中确实如此:
router.post(`/mailbox/${account}/actions`, { uids, action: 'star' }, {
optimistic: (props) => ({
messages: starred(props.messages, uids),
}),
})看起来像一次 UI 微调。其实不是。这是应用不再一次只做一件事的分水岭。
1. 点击开始重叠
以前每次点击都排队等待。现在用户一秒内可以给三条消息加星。默认情况下,新的访问会取消正在进行的请求,屏幕会短暂地把第一条消息的星标“取消”掉。
解决办法是让请求并行执行:
router.post(url, data, {
async: true, // 不要取消其他点击
preserveState: true, // 保留选中状态、滚动位置、已打开的面板
optimistic: () => changes,
})2. 反馈滞后于变更
星标是即时的,但“已加星 · 撤销”的小提示仍在等待服务器。屏幕说完成了,确认信息却说再等等。
解决办法是翻转主导权。页面自己显示提示,并带上一个 id,把这个 id 一并发送。服务器用同一个 id 回应,于是它的提示更新屏幕上已有的那一条,而不是新增第二条。
const toastId = uuid()
toasts.show({ id: toastId, message: 'Starred 1 message.', undo: pending })
router.post(url, { ...data, toast_id: toastId }, options)// 服务器的提示替换页面的提示,或将其变为错误提示。
return back()->with('toast', Toast::success($message)->toArray($request->toastId()));3. 用户在服务器行动之前就操作了
有了即时的“撤销”,用户会在原始变更还没到达服务器时就点击它。此时根本还没有东西可撤销。
于是页面立即恢复原状,记住这个请求,并在服务器响应到达的那一刻发出真正的撤销:
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 修复成本最低的地方。几个设置解决了问题:等待锁而不是失败,让读写并行,并提前获取写锁。
// config/database.php
'sqlite' => [
// …
'busy_timeout' => 5000, // 等待锁,不要失败
'journal_mode' => 'wal', // 读者与写者互不阻塞
'synchronous' => 'normal',
'transaction_mode' => 'IMMEDIATE', // 由读转写的操作不能等待
],6. 最后的细节:幻觉很脆弱
为一个已经显示在屏幕上的变更展示加载条,等于告诉用户你不相信自己的 UI:
router.post(url, data, { showProgress: false, optimistic: () => changes })另外,一个只在 HTTPS 下存在的浏览器 API(crypto.randomUUID())在纯 HTTP 的开发域名上悄悄让所有操作失效,而运行在 127.0.0.1(被视为安全上下文)上的测试却一直是绿的:
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