欢迎光临
我们一直在努力

前端也需要事务:Vue Router 乐观更新、回滚与“过期事务”

这是「多标签页工程化」系列的第 7 篇。

前六篇都在讲缓存、滚动、iframe 这些具体机制。这一篇抽出来讲一个更通用的模式::当 UI 先变、异步结果后到时,怎么保证状态不会烂掉。

在线演示:https://jktantan.github.io/vue-stack-tabs/demo.html 源码:https://github.com/jktantan/vue-stack-tabs


0. 先看一个没有事务的版本

几乎每个中后台系统都有这种代码。点击「下一步」,先更新界面,再跳路由:

const goNext = async () => {
currentStep.value += 1 // ① 先改 UI
await router.push({ name: `step-${currentStep.value}` }) // ② 再导航
}

平时没问题。但异步操作有四种结果,这段代码只处理了第一种:

结果实际发生的事这段代码的表现
成功 路由跳过去了 正常
守卫拒绝 router.push 返回一个 failure UI 已经变了,页面没跳
抛出异常 网络、编码、权限错误 UI 已经变了,错误冒泡
用户连点两次 两个导航并发 后到的旧结果覆盖新状态

这四种情况,本质上对应后端事务要解决的三个问题:

  • 原子性:UI 变更和导航要么都成功,要么都不发生;
  • 隔离性:并发的两个操作不能互相污染;
  • 可回滚:失败时能回到操作前的状态。

前端没有数据库锁,但有三个替代品:快照、版本号、清理函数。


1. 四段式:apply → navigate → commit / rollback

把上面的逻辑拆成四个明确的步骤:

// src/lib/hooks/tabPanel/navigationTransaction.ts(有删减)
interface NavigationTransactionOptions<TSnapshot, TResult> {
/** 乐观应用 UI 变更,返回可用于 rollback 的快照 */
apply: () => TSnapshot
/** 执行异步导航 */
navigate: () => Promise<TResult>
/** 失败时回滚到快照状态 */
rollback: (snapshot: TSnapshot) => void
/** 成功后的确认回调(如驱逐旧缓存) */
commit?: () => void
}

执行顺序:

const snapshot = options.apply()

try {
const result = await options.navigate()

if (isFailure(result)) {
rollback(snapshot)
return result
}

commit?.()
return result
} catch (error) {
rollback(snapshot)
throw error
}

四个角色各司其职:

  • apply:改 UI,同时把「改之前的样子」交出来;
  • navigate:只负责异步操作,不碰状态;
  • commit:成功后的副作用(销毁旧缓存、清理临时标记);
  • rollback:用快照恢复。

1.1 为什么快照要由 apply 返回

因为只有 apply 知道「自己改了哪些东西」。

如果让调用方在外部先存快照:

// 不推荐
const snapshot = getSnapshot()
apply()
await run(snapshot)

快照和修改会分散在两个地方,中间插一行代码就可能不同步。把两者绑在一个函数里,「改了什么」和「怎么退回去」就成了同一个问题的两面。

更重要的是顺序。apply 里必须先取快照、再改状态:

apply: () => {
const snapshot = getPageSnapshot(tab.id) // ← 先取
appendPage(tab.id, { … }) // ← 再改
return snapshot
}

如果反过来,快照记的就是改后的状态,回滚等于什么都没做。这个错误非常隐蔽,因为代码看起来仍然「有快照、有回滚」。


把四段式画成流程图后,执行路径一目了然:

#mermaid-svg-WmTR6TRR4xXpx8Ih{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WmTR6TRR4xXpx8Ih .error-icon{fill:#552222;}#mermaid-svg-WmTR6TRR4xXpx8Ih .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WmTR6TRR4xXpx8Ih .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .marker.cross{stroke:#333333;}#mermaid-svg-WmTR6TRR4xXpx8Ih svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WmTR6TRR4xXpx8Ih p{margin:0;}#mermaid-svg-WmTR6TRR4xXpx8Ih .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .cluster-label text{fill:#333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .cluster-label span{color:#333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .cluster-label span p{background-color:transparent;}#mermaid-svg-WmTR6TRR4xXpx8Ih .label text,#mermaid-svg-WmTR6TRR4xXpx8Ih span{fill:#333;color:#333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .node rect,#mermaid-svg-WmTR6TRR4xXpx8Ih .node circle,#mermaid-svg-WmTR6TRR4xXpx8Ih .node ellipse,#mermaid-svg-WmTR6TRR4xXpx8Ih .node polygon,#mermaid-svg-WmTR6TRR4xXpx8Ih .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WmTR6TRR4xXpx8Ih .rough-node .label text,#mermaid-svg-WmTR6TRR4xXpx8Ih .node .label text,#mermaid-svg-WmTR6TRR4xXpx8Ih .image-shape .label,#mermaid-svg-WmTR6TRR4xXpx8Ih .icon-shape .label{text-anchor:middle;}#mermaid-svg-WmTR6TRR4xXpx8Ih .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WmTR6TRR4xXpx8Ih .rough-node .label,#mermaid-svg-WmTR6TRR4xXpx8Ih .node .label,#mermaid-svg-WmTR6TRR4xXpx8Ih .image-shape .label,#mermaid-svg-WmTR6TRR4xXpx8Ih .icon-shape .label{text-align:center;}#mermaid-svg-WmTR6TRR4xXpx8Ih .node.clickable{cursor:pointer;}#mermaid-svg-WmTR6TRR4xXpx8Ih .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .arrowheadPath{fill:#333333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WmTR6TRR4xXpx8Ih .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WmTR6TRR4xXpx8Ih .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WmTR6TRR4xXpx8Ih .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WmTR6TRR4xXpx8Ih .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WmTR6TRR4xXpx8Ih .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WmTR6TRR4xXpx8Ih .cluster text{fill:#333;}#mermaid-svg-WmTR6TRR4xXpx8Ih .cluster span{color:#333;}#mermaid-svg-WmTR6TRR4xXpx8Ih div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WmTR6TRR4xXpx8Ih .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WmTR6TRR4xXpx8Ih rect.text{fill:none;stroke-width:0;}#mermaid-svg-WmTR6TRR4xXpx8Ih .icon-shape,#mermaid-svg-WmTR6TRR4xXpx8Ih .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WmTR6TRR4xXpx8Ih .icon-shape p,#mermaid-svg-WmTR6TRR4xXpx8Ih .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WmTR6TRR4xXpx8Ih .icon-shape .label rect,#mermaid-svg-WmTR6TRR4xXpx8Ih .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WmTR6TRR4xXpx8Ih .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WmTR6TRR4xXpx8Ih .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WmTR6TRR4xXpx8Ih :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

业务失败

抛异常

成功

apply()乐观改 UI 并返回快照

navigate()执行异步导航

结果判定

rollback(snapshot)恢复快照

commit()执行成功副作用

返回失败结果 / 抛出异常

返回成功结果

2. 第一个坑:Vue Router 的失败不是异常

很多人第一反应是用 try/catch:

try {
await router.push(target)
// 成功了?
} catch (e) {
rollback()
}

但 Vue Router 的导航失败通常不会抛出异常。

router.push() 的返回值是:

Promise<void | NavigationFailure | undefined>

守卫返回 false、重定向到别处、导航被取消、重复导航,这些情况都会正常 resolve,只是 resolve 出来一个 NavigationFailure。

所以你需要显式判断:

import { isNavigationFailure } from 'vue-router'

runNavigationTransaction({
// …
isFailureResult: isNavigationFailure
})

2.1 三类失败,语义不同

Vue Router 的 NavigationFailureType 有三类:

类型含义该不该回滚
aborted 守卫返回了 false 该
cancelled 新导航取代了当前导航 看情况
duplicated 目标地址和当前地址相同 通常不该

第三类是个例外,下一节专门讨论。

三类失败的处理决策可以这样表示:

#mermaid-svg-lWhSGibwCdg3w5N8{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lWhSGibwCdg3w5N8 .error-icon{fill:#552222;}#mermaid-svg-lWhSGibwCdg3w5N8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lWhSGibwCdg3w5N8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lWhSGibwCdg3w5N8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lWhSGibwCdg3w5N8 .marker.cross{stroke:#333333;}#mermaid-svg-lWhSGibwCdg3w5N8 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lWhSGibwCdg3w5N8 p{margin:0;}#mermaid-svg-lWhSGibwCdg3w5N8 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-lWhSGibwCdg3w5N8 .cluster-label text{fill:#333;}#mermaid-svg-lWhSGibwCdg3w5N8 .cluster-label span{color:#333;}#mermaid-svg-lWhSGibwCdg3w5N8 .cluster-label span p{background-color:transparent;}#mermaid-svg-lWhSGibwCdg3w5N8 .label text,#mermaid-svg-lWhSGibwCdg3w5N8 span{fill:#333;color:#333;}#mermaid-svg-lWhSGibwCdg3w5N8 .node rect,#mermaid-svg-lWhSGibwCdg3w5N8 .node circle,#mermaid-svg-lWhSGibwCdg3w5N8 .node ellipse,#mermaid-svg-lWhSGibwCdg3w5N8 .node polygon,#mermaid-svg-lWhSGibwCdg3w5N8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lWhSGibwCdg3w5N8 .rough-node .label text,#mermaid-svg-lWhSGibwCdg3w5N8 .node .label text,#mermaid-svg-lWhSGibwCdg3w5N8 .image-shape .label,#mermaid-svg-lWhSGibwCdg3w5N8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-lWhSGibwCdg3w5N8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lWhSGibwCdg3w5N8 .rough-node .label,#mermaid-svg-lWhSGibwCdg3w5N8 .node .label,#mermaid-svg-lWhSGibwCdg3w5N8 .image-shape .label,#mermaid-svg-lWhSGibwCdg3w5N8 .icon-shape .label{text-align:center;}#mermaid-svg-lWhSGibwCdg3w5N8 .node.clickable{cursor:pointer;}#mermaid-svg-lWhSGibwCdg3w5N8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lWhSGibwCdg3w5N8 .arrowheadPath{fill:#333333;}#mermaid-svg-lWhSGibwCdg3w5N8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lWhSGibwCdg3w5N8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lWhSGibwCdg3w5N8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lWhSGibwCdg3w5N8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lWhSGibwCdg3w5N8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lWhSGibwCdg3w5N8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lWhSGibwCdg3w5N8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lWhSGibwCdg3w5N8 .cluster text{fill:#333;}#mermaid-svg-lWhSGibwCdg3w5N8 .cluster span{color:#333;}#mermaid-svg-lWhSGibwCdg3w5N8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lWhSGibwCdg3w5N8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lWhSGibwCdg3w5N8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-lWhSGibwCdg3w5N8 .icon-shape,#mermaid-svg-lWhSGibwCdg3w5N8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lWhSGibwCdg3w5N8 .icon-shape p,#mermaid-svg-lWhSGibwCdg3w5N8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lWhSGibwCdg3w5N8 .icon-shape .label rect,#mermaid-svg-lWhSGibwCdg3w5N8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lWhSGibwCdg3w5N8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lWhSGibwCdg3w5N8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lWhSGibwCdg3w5N8 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

否

是

aborted守卫拒绝

cancelled被新导航取代

duplicated地址相同

router.push() 返回

isNavigationFailure?

正常成功

失败类型

回滚

看情况通常视为过期

通常不回滚刷新场景属预期

2.2 异常路径仍然要处理

虽然导航失败不抛异常,但下面这些情况会:

  • 路由配置错误(path 不存在);
  • beforeEach 里抛了未捕获的错误;
  • 参数序列化失败。

所以 try/catch 不能删。两条路径都要回滚:

try {
const result = await navigate()
if (isFailure(result)) {
rollback(snapshot)
return result
}
commit?.()
return result
} catch (error) {
rollback(snapshot)
throw error
}

一个完整的实现必须同时覆盖「业务失败」和「技术异常」。只写 catch 会漏掉守卫拒绝,只判断返回值会漏掉配置错误。


3. 第二个坑:重复导航不是失败

考虑这个场景:用户点击一个已经打开的标签,但你想让它「重新加载」。

openTab({ id: 'order-1', path: '/order/1' }, true) // renew = true

地址没变,router.push 会返回 duplicated 失败。如果按默认逻辑回滚,就会把刚刚清空的页面栈又恢复回去——刷新没生效。

所以这里要定制「什么算失败」:

// src/lib/hooks/useTabActions.ts(有删减)
runNavigationTransaction({
apply: () => undefined,
navigate: () => router.push({ path, query, hash }),
rollback: () => undefined,
isFailureResult: (result) =>
isNavigationFailure(result) &&
!(allowDuplicatedNavigation && isNavigationFailure(result, NavigationFailureType.duplicated)),
rejectFailureResult: true
})

含义是:普通失败算失败;但在 renew 场景下,duplicated 是预期结果,不算失败。

为什么 duplicated 在这里可以被接受?因为「刷新」的语义不是「导航到新地址」,而是「让当前地址重新解析」。地址不变恰恰是正常的,refreshKey 会触发重新解析。

失败判定不应该是全局统一的,它取决于这次操作的意图。


4. 第三个坑:过期事务

这是整套机制里最难、也最容易被忽略的部分。

4.1 一个具体的时序

用户在 100ms 内点击了两次「下一步」:

t=0 点击 A:apply → UI 变成 step 2 → router.push('/step-2')
t=100 点击 B:apply → UI 变成 step 3 → router.push('/step-3')
t=300 A 的导航结果回来:失败(被 B 取消了)

此时 A 该怎么办?

如果回滚:用 A 的快照(step 1)覆盖当前状态,B 的 step 3 就没了。用户明明点击了两次,界面却退回第一步。

如果提交:A 的提交逻辑(比如驱逐缓存)会把 B 正在用的资源删掉。

正确答案是:什么都不做,只清理自己留下的残骸。

用一张时序图来看两次点击的交错:

Router

事务层

用户

Router

事务层

用户

#mermaid-svg-i8hkjZhkkC4MtsIP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i8hkjZhkkC4MtsIP .error-icon{fill:#552222;}#mermaid-svg-i8hkjZhkkC4MtsIP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i8hkjZhkkC4MtsIP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i8hkjZhkkC4MtsIP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i8hkjZhkkC4MtsIP .marker.cross{stroke:#333333;}#mermaid-svg-i8hkjZhkkC4MtsIP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i8hkjZhkkC4MtsIP p{margin:0;}#mermaid-svg-i8hkjZhkkC4MtsIP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8hkjZhkkC4MtsIP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-i8hkjZhkkC4MtsIP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i8hkjZhkkC4MtsIP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-i8hkjZhkkC4MtsIP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-i8hkjZhkkC4MtsIP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-i8hkjZhkkC4MtsIP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-i8hkjZhkkC4MtsIP .sequenceNumber{fill:white;}#mermaid-svg-i8hkjZhkkC4MtsIP #sequencenumber{fill:#333;}#mermaid-svg-i8hkjZhkkC4MtsIP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-i8hkjZhkkC4MtsIP .messageText{fill:#333;stroke:none;}#mermaid-svg-i8hkjZhkkC4MtsIP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8hkjZhkkC4MtsIP .labelText,#mermaid-svg-i8hkjZhkkC4MtsIP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-i8hkjZhkkC4MtsIP .loopText,#mermaid-svg-i8hkjZhkkC4MtsIP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-i8hkjZhkkC4MtsIP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-i8hkjZhkkC4MtsIP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-i8hkjZhkkC4MtsIP .noteText,#mermaid-svg-i8hkjZhkkC4MtsIP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-i8hkjZhkkC4MtsIP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8hkjZhkkC4MtsIP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8hkjZhkkC4MtsIP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-i8hkjZhkkC4MtsIP .actorPopupMenu{position:absolute;}#mermaid-svg-i8hkjZhkkC4MtsIP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-i8hkjZhkkC4MtsIP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-i8hkjZhkkC4MtsIP .actor-man circle,#mermaid-svg-i8hkjZhkkC4MtsIP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-i8hkjZhkkC4MtsIP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

A 已过期 → 不回滚、不提交

B 是当前事务 → commit

t=0 点击 A

apply:UI → step 2(快照 step 1)

push('/step-2')

t=100 点击 B

apply:UI → step 3(快照 step 2)

push('/step-3')

t=300 A 导航失败(被取消)

B 导航成功

4.2 用版本号判断「过期」

// src/lib/hooks/useTabRouter.ts(有删减)
let navigationVersion = 0

const forward = (to) => {
// …
const transactionVersion = ++navigationVersion

runNavigationTransaction({
// …
isCurrent: () => navigationVersion === transactionVersion
})
}

每次发起导航,全局版本号 +1,事务记住自己那一版。

结果返回时:

  • navigationVersion === transactionVersion → 我还是最新的,可以提交或回滚;
  • 不相等 → 已经有更新的操作了,我是过期的。

4.3 过期时走第三个分支

完整的实现有三个分支,不是两个:

export const runNavigationTransaction = async <TSnapshot, TResult>(
options: NavigationTransactionOptions<TSnapshot, TResult>
): Promise<TResult> => {
const snapshot = options.apply()

try {
const result = await options.navigate()

if (options.isFailureResult?.(result)) {
if (options.isCurrent?.() ?? true) {
options.rollback(snapshot) // ① 当前事务失败:回滚
} else {
options.cleanupStale?.() // ② 过期事务失败:只清理
}
if (options.rejectFailureResult) throw result
return result
}

if (options.isCurrent?.() ?? true) options.commit?.() // ③ 当前事务成功:提交
return result
} catch (error) {
if (options.isCurrent?.() ?? true) {
options.rollback(snapshot)
} else {
options.cleanupStale?.()
}
throw error
}
}

特别注意成功分支:

if (options.isCurrent?.() ?? true) options.commit?.()

过期事务即使成功了也不提交。 因为它的成功可能只是一个中间状态,提交会误删新事务还需要的东西。

三个分支的完整判定逻辑:

#mermaid-svg-zexJrKYv7E90c4dj{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zexJrKYv7E90c4dj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zexJrKYv7E90c4dj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zexJrKYv7E90c4dj .error-icon{fill:#552222;}#mermaid-svg-zexJrKYv7E90c4dj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zexJrKYv7E90c4dj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zexJrKYv7E90c4dj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zexJrKYv7E90c4dj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zexJrKYv7E90c4dj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zexJrKYv7E90c4dj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zexJrKYv7E90c4dj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zexJrKYv7E90c4dj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zexJrKYv7E90c4dj .marker.cross{stroke:#333333;}#mermaid-svg-zexJrKYv7E90c4dj svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zexJrKYv7E90c4dj p{margin:0;}#mermaid-svg-zexJrKYv7E90c4dj .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-zexJrKYv7E90c4dj .cluster-label text{fill:#333;}#mermaid-svg-zexJrKYv7E90c4dj .cluster-label span{color:#333;}#mermaid-svg-zexJrKYv7E90c4dj .cluster-label span p{background-color:transparent;}#mermaid-svg-zexJrKYv7E90c4dj .label text,#mermaid-svg-zexJrKYv7E90c4dj span{fill:#333;color:#333;}#mermaid-svg-zexJrKYv7E90c4dj .node rect,#mermaid-svg-zexJrKYv7E90c4dj .node circle,#mermaid-svg-zexJrKYv7E90c4dj .node ellipse,#mermaid-svg-zexJrKYv7E90c4dj .node polygon,#mermaid-svg-zexJrKYv7E90c4dj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zexJrKYv7E90c4dj .rough-node .label text,#mermaid-svg-zexJrKYv7E90c4dj .node .label text,#mermaid-svg-zexJrKYv7E90c4dj .image-shape .label,#mermaid-svg-zexJrKYv7E90c4dj .icon-shape .label{text-anchor:middle;}#mermaid-svg-zexJrKYv7E90c4dj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zexJrKYv7E90c4dj .rough-node .label,#mermaid-svg-zexJrKYv7E90c4dj .node .label,#mermaid-svg-zexJrKYv7E90c4dj .image-shape .label,#mermaid-svg-zexJrKYv7E90c4dj .icon-shape .label{text-align:center;}#mermaid-svg-zexJrKYv7E90c4dj .node.clickable{cursor:pointer;}#mermaid-svg-zexJrKYv7E90c4dj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zexJrKYv7E90c4dj .arrowheadPath{fill:#333333;}#mermaid-svg-zexJrKYv7E90c4dj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zexJrKYv7E90c4dj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zexJrKYv7E90c4dj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zexJrKYv7E90c4dj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zexJrKYv7E90c4dj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zexJrKYv7E90c4dj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zexJrKYv7E90c4dj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zexJrKYv7E90c4dj .cluster text{fill:#333;}#mermaid-svg-zexJrKYv7E90c4dj .cluster span{color:#333;}#mermaid-svg-zexJrKYv7E90c4dj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zexJrKYv7E90c4dj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zexJrKYv7E90c4dj rect.text{fill:none;stroke-width:0;}#mermaid-svg-zexJrKYv7E90c4dj .icon-shape,#mermaid-svg-zexJrKYv7E90c4dj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zexJrKYv7E90c4dj .icon-shape p,#mermaid-svg-zexJrKYv7E90c4dj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zexJrKYv7E90c4dj .icon-shape .label rect,#mermaid-svg-zexJrKYv7E90c4dj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zexJrKYv7E90c4dj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zexJrKYv7E90c4dj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zexJrKYv7E90c4dj :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

是

否

是:当前事务

否:过期事务

是:当前事务

否:过期事务

apply() 取快照

await navigate()

isFailureResult?

isCurrent()?

isCurrent()?

rollback(snapshot)

cleanupStale()只清理自己的残留

commit()

什么都不做不提交

返回失败 / 抛出

返回成功

4.4 为什么不能「回滚到我的快照」

因为快照是旧的。

回滚操作假设「当前状态 = 我改完之后的状态」。在并发场景下,这个假设不成立:

A 的快照:step 1
A 改完: step 2
B 改完: step 3
A 回滚 → step 1 ← B 的修改被抹掉

过期事务能确定的事情只有一件:「我加进去的那个东西,如果现在还在,而且没人认领,就可以删掉。」

这就是 cleanupStale 的语义边界。

4.5 两种清理,两种精度

前进(forward):apply 往页面栈里压了一个乐观页面。清理时只删这一个:

// src/lib/hooks/useTabRouter.ts(有删减)
let optimisticPageId = ''

runNavigationTransaction({
apply: () => {
const snapshot = tab ? getPageSnapshot(tab.id) : []
if (tab) {
optimisticPageId = createPageId()
appendPage(tab.id, { id: optimisticPageId, path: targetPath, query })
}
return snapshot
},

cleanupStale: () => {
if (!tab || !optimisticPageId) return
const pages = getPages(tab.id).filter((page) => page.id !== optimisticPageId)
replacePages(tab.id, pages)
}
})

不是恢复整个快照,而是按 id 精确移除自己添加的那一页。别人的页面原封不动。

后退(backward):apply 弹出了若干页,准备逐出它们的缓存。清理时只驱逐「确定已经不在当前栈里」的:

// src/lib/hooks/useTabRouter.ts(有删减)
cleanupStale: () => {
const activePageIds = new Set(getPages(tab.id).map((page) => page.id))
for (const id of recyclingBin) {
if (!activePageIds.has(id)) evictPageCache(id)
}
}

第二次后退可能又把某个页面压回了栈里,所以要先查当前栈,再决定删不删。

这两段清理的共同点:都有「再确认一次」的步骤。 过期事务不能假设任何事,只能基于当前状态做判断。

4.6 测试把三种结局都钉住了

it('事务过期时不回滚也不提交,避免旧导航覆盖新导航状态', async () => {
const error = new Error('stale navigation')
let pages = ['newer-success']
let rolledBack = false
let committed = false

await expect(
runNavigationTransaction({
apply: () => ['old-root'],
navigate: () => Promise.reject(error),
rollback: () => {
rolledBack = true
pages = ['old-root']
},
commit: () => {
committed = true
},
isCurrent: () => false
})
).rejects.toBe(error)

expect(pages).toEqual(['newer-success']) // 新状态没被覆盖
expect(rolledBack).toBe(false) // 没有回滚
expect(committed).toBe(false) // 也没有提交
})

it('事务过期时执行过期清理但不做整栈回滚', async () => {
const failure = { type: 'cancelled' }
let pages = ['root', 'stale-optimistic', 'newer-success']
let rolledBack = false

await expect(
runNavigationTransaction({
apply: () => ['root'],
navigate: () => Promise.resolve(failure),
rollback: () => {
rolledBack = true
pages = ['root']
},
cleanupStale: () => {
pages = pages.filter((page) => page !== 'stale-optimistic')
},
isFailureResult: (result) => result === failure,
isCurrent: () => false
})
).resolves.toBe(failure)

expect(rolledBack).toBe(false)
expect(pages).toEqual(['root', 'newer-success']) // 只删了自己的残留
})

第二个用例最能说明问题:清理之后,root 和 newer-success 都还在,只有 stale-optimistic 被删除。


5. 快照里应该放什么

“快照”听起来像是一个技术细节,实际上它决定了回滚能做到什么程度。

这个库在一次导航里,可能要改这些东西:

状态谁改的失败后怎么恢复
标签内的页面栈 appendPage / popPages replacePages(snapshot)
当前激活标签 activateTab 恢复 prevActiveId
当前激活标签的 sessionStorage saveActiveTabToSession 恢复原始字符串
页面缓存的 include 列表 evictPageCache restoreCaches(snapshot)
组件注册表 components.delete restoreComponents(snapshot)
全局刷新版本号 refreshKey.value++ 恢复数字

少恢复任何一项,都会留下“看起来回滚了,但某个角落还是错的”的状态。

5.1 一个真实的例子:openTab 的 renew 回滚

renew 的语义是「清空这个标签的页面栈,重新打开」。它要先销毁现有内容,再导航。如果导航失败,必须全部恢复。

// src/lib/hooks/useTabPanel.tsx(有删减)
const renewTab = (tab: ITabData) => {
const pageSnapshot = getPageSnapshot(currentTab.id)
const cacheSnapshot = snapshotCaches()
const componentSnapshot = snapshotComponents()

markTabPagesForEvictionOnly(currentTab)
evictMarkedCaches()
clearPages(currentTab.id)

// 把回滚函数返回给调用方
return () => {
replacePages(currentTab.id, pageSnapshot)
restoreCaches(cacheSnapshot)
restoreComponents(componentSnapshot)
}
}

调用方在失败时执行:

// src/lib/hooks/useTabActions.ts(有删减)
let rollbackRenew: (() => void) | undefined
let refreshKeySnapshot: number | undefined

if (tabInfo.id && renew && isExistingTab) {
rollbackRenew = renewTab(tab)
if (currentTab?.active) {
refreshKeySnapshot = refreshKey.value
refreshKey.value++
}
}

navigateToTab(tab, tabInfo, isExistingTab && renew).then(
() => resolve(tabInfo.id),
(error) => {
rollbackRenew?.()
if (refreshKeySnapshot !== undefined) refreshKey.value = refreshKeySnapshot
reject(error)
}
)

注意 refreshKey 那一小段。它看起来像个无关的计数器,但它也会影响下一次渲染:如果导航失败了、计数器却加了 1,下一次渲染会莫名其妙地重建页面。

判断一个状态该不该进快照,标准不是「它重不重要」,而是「它变了之后,失败时能不能解释得通」。


6. 完整对照:forward 和 backward

两条路径用的是同一个事务,但四个回调的职责完全不同。

6.1 forward:先压栈,再导航

runNavigationTransaction({
apply: () => {
const snapshot = getPageSnapshot(tab.id)
optimisticPageId = createPageId()
appendPage(tab.id, { id: optimisticPageId, path, query })
return snapshot
},
navigate: () => router.push({ path, query }),
rollback: (snapshot) => replacePages(tab.id, snapshot),
isFailureResult: isNavigationFailure,
isCurrent: () => navigationVersion === transactionVersion,
cleanupStale: () => {
const pages = getPages(tab.id).filter((page) => page.id !== optimisticPageId)
replacePages(tab.id, pages)
}
})

  • apply:压栈(乐观),返回压栈前的快照;
  • commit:不需要。前进成功后,页面栈就是目标状态,没有额外副作用;
  • rollback:恢复整条页面栈;
  • cleanupStale:只删自己压进去的那一页。

6.2 backward:先弹栈,再导航,成功才驱逐

const originalPages = getPageSnapshot(tab.id)
const recyclingBin = popPages(tab.id, steps).map((page) => page.id)

runNavigationTransaction({
apply: () => originalPages,
navigate: () => router.push({ path: target.path, query }),
rollback: (snapshot) => replacePages(tab.id, snapshot),
isFailureResult: isNavigationFailure,
isCurrent: () => navigationVersion === transactionVersion,
commit: () => {
for (const id of recyclingBin) evictPageCache(id)
},
cleanupStale: () => {
const activePageIds = new Set(getPages(tab.id).map((page) => page.id))
for (const id of recyclingBin) {
if (!activePageIds.has(id)) evictPageCache(id)
}
}
})

差别有两点:

  • apply 没有改状态。popPages 在事务开始前就做了,apply 只是把已经算好的快照交出去;
  • commit 有实际工作。缓存驱逐是不可撤销的操作,必须等导航确认之后才能做。
  • 这也说明:事务的四个回调不是模板,每个都要根据操作的语义填。


    7. 不是所有操作都值得上事务

    写到这里很容易过度设计:给每个按钮都套一个事务。其实不需要。

    看这个库里的三个反例。

    7.1 关闭标签:没有事务

    const removeTab = (id: string): string => {
    const result = removeTabFromStore(id)
    if (result.nextActiveId) {
    emitter.emit(TabEventType.TAB_ACTIVE, { id: result.nextActiveId })
    }
    return result.nextActiveId
    }

    关闭标签之后触发路由跳转,如果跳转失败,结果是「标签没了但页面还在」。

    这个状态可以接受:用户刷新一下就能恢复到一个一致的状态,而且用户的意图(关闭)已经完成了。为它加事务,复杂度远大于收益。

    7.2 激活标签:有事务,但不检测过期

    return runNavigationTransaction({
    apply: () => {
    const prevActiveId = getActiveTab()?.id
    // SSR 环境无 sessionStorage;快照为 null 时 rollback 视为「无变化」
    const sessionSnapshot =
    typeof window !== 'undefined' ? window.sessionStorage.getItem(getSessionKey()) : null
    activateTarget()
    return { prevActiveId, sessionSnapshot }
    },
    navigate: () => router.push({ path: top.path, query }),
    rollback: (snapshot) => {
    activateTab(snapshot.prevActiveId ?? '')
    restoreActiveTabSession(snapshot.sessionSnapshot)
    },
    isFailureResult: isNavigationFailure,
    rejectFailureResult: true
    // 没有 isCurrent
    })

    没有传 isCurrent,意味着默认“总是当前事务”。

    为什么可以这样?因为激活操作修改的状态很少(active id + session),回滚成本低。代价是:极端并发下,旧事务的回滚可能覆盖新的激活状态。

    这里接受这个取舍——激活是高频、低成本的操作,完整的并发保护会让代码复杂很多。事务的复杂度要和状态的复杂度匹配。

    7.3 刷新:不需要事务

    刷新是幂等的:失败了再点一次就行,没有「部分成功」的中间状态。

    给幂等操作套事务,只会增加代码量。

    7.4 一个判断标准

    失败之后,用户看到的状态能不能用一句话解释清楚?

    • 「标签关了,页面还在」——能解释,不需要事务;
    • 「按钮显示已完成,但数据没保存」——不能解释,需要事务;
    • 「页面栈少了一层,但地址还停在原来的页」——不能解释,需要事务。

    把判断标准画成决策树。

    #mermaid-svg-zrsvLMfgTzIJPdHo{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zrsvLMfgTzIJPdHo .error-icon{fill:#552222;}#mermaid-svg-zrsvLMfgTzIJPdHo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zrsvLMfgTzIJPdHo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zrsvLMfgTzIJPdHo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zrsvLMfgTzIJPdHo .marker.cross{stroke:#333333;}#mermaid-svg-zrsvLMfgTzIJPdHo svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zrsvLMfgTzIJPdHo p{margin:0;}#mermaid-svg-zrsvLMfgTzIJPdHo .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-zrsvLMfgTzIJPdHo .cluster-label text{fill:#333;}#mermaid-svg-zrsvLMfgTzIJPdHo .cluster-label span{color:#333;}#mermaid-svg-zrsvLMfgTzIJPdHo .cluster-label span p{background-color:transparent;}#mermaid-svg-zrsvLMfgTzIJPdHo .label text,#mermaid-svg-zrsvLMfgTzIJPdHo span{fill:#333;color:#333;}#mermaid-svg-zrsvLMfgTzIJPdHo .node rect,#mermaid-svg-zrsvLMfgTzIJPdHo .node circle,#mermaid-svg-zrsvLMfgTzIJPdHo .node ellipse,#mermaid-svg-zrsvLMfgTzIJPdHo .node polygon,#mermaid-svg-zrsvLMfgTzIJPdHo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zrsvLMfgTzIJPdHo .rough-node .label text,#mermaid-svg-zrsvLMfgTzIJPdHo .node .label text,#mermaid-svg-zrsvLMfgTzIJPdHo .image-shape .label,#mermaid-svg-zrsvLMfgTzIJPdHo .icon-shape .label{text-anchor:middle;}#mermaid-svg-zrsvLMfgTzIJPdHo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zrsvLMfgTzIJPdHo .rough-node .label,#mermaid-svg-zrsvLMfgTzIJPdHo .node .label,#mermaid-svg-zrsvLMfgTzIJPdHo .image-shape .label,#mermaid-svg-zrsvLMfgTzIJPdHo .icon-shape .label{text-align:center;}#mermaid-svg-zrsvLMfgTzIJPdHo .node.clickable{cursor:pointer;}#mermaid-svg-zrsvLMfgTzIJPdHo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zrsvLMfgTzIJPdHo .arrowheadPath{fill:#333333;}#mermaid-svg-zrsvLMfgTzIJPdHo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zrsvLMfgTzIJPdHo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zrsvLMfgTzIJPdHo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zrsvLMfgTzIJPdHo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zrsvLMfgTzIJPdHo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zrsvLMfgTzIJPdHo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zrsvLMfgTzIJPdHo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zrsvLMfgTzIJPdHo .cluster text{fill:#333;}#mermaid-svg-zrsvLMfgTzIJPdHo .cluster span{color:#333;}#mermaid-svg-zrsvLMfgTzIJPdHo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zrsvLMfgTzIJPdHo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zrsvLMfgTzIJPdHo rect.text{fill:none;stroke-width:0;}#mermaid-svg-zrsvLMfgTzIJPdHo .icon-shape,#mermaid-svg-zrsvLMfgTzIJPdHo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zrsvLMfgTzIJPdHo .icon-shape p,#mermaid-svg-zrsvLMfgTzIJPdHo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zrsvLMfgTzIJPdHo .icon-shape .label rect,#mermaid-svg-zrsvLMfgTzIJPdHo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zrsvLMfgTzIJPdHo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zrsvLMfgTzIJPdHo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zrsvLMfgTzIJPdHo :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    能解释

    不能解释

    简单、高频

    复杂、涉及多状态

    操作失败后

    用户看到的状态能用一句话解释清楚吗?

    不需要事务如:关闭标签、刷新

    状态改动是否复杂?

    轻量事务可不检测过期

    完整事务快照 + 版本号 + 清理

    8. 一个不依赖任何库的最小实现

    这套模式与 Vue、Vue Router 都没有强绑定。把业务语义抽掉后,核心逻辑不到三十行:

    interface TransactionOptions<Snapshot, Result> {
    /** 乐观修改状态,返回修改前的快照 */
    apply: () => Snapshot
    /** 执行异步操作 */
    run: () => Promise<Result>
    /** 失败时恢复快照 */
    rollback: (snapshot: Snapshot) => void
    /** 成功后的副作用 */
    commit?: () => void
    /** 判断「业务失败」的结果 */
    isFailure?: (result: Result) => boolean
    /** 本事务是否仍然有效 */
    isCurrent?: () => boolean
    /** 过期时的清理逻辑 */
    cleanup?: () => void
    /** 业务失败时是否抛异常 */
    rejectFailure?: boolean
    }

    export async function runTransaction<Snapshot, Result>(
    options: TransactionOptions<Snapshot, Result>
    ): Promise<Result> {
    const snapshot = options.apply()

    const handleFailure = (result: Result): Result => {
    if (options.isCurrent?.() ?? true) {
    options.rollback(snapshot)
    } else {
    options.cleanup?.()
    }
    if (options.rejectFailure) throw result
    return result
    }

    try {
    const result = await options.run()

    if (options.isFailure?.(result)) {
    return handleFailure(result)
    }

    if (options.isCurrent?.() ?? true) {
    options.commit?.()
    }
    return result
    } catch (error) {
    if (options.isCurrent?.() ?? true) {
    options.rollback(snapshot)
    } else {
    options.cleanup?.()
    }
    throw error
    }
    }

    在一个多步表单里用它:

    let version = 0

    async function goNext() {
    const current = ++version

    await runTransaction({
    apply: () => {
    const snapshot = { step: state.step }
    state.step += 1
    return snapshot
    },
    run: () => router.push({ name: `step-${state.step}` }),
    rollback: (snapshot) => {
    state.step = snapshot.step
    },
    isFailure: (result) => isNavigationFailure(result),
    isCurrent: () => version === current,
    cleanup: () => {
    // 这次操作没有留下额外资源,不需要清理
    }
    })
    }

    在 React 里同样适用,只是把 state 换成 setState:

    apply: () => {
    const snapshot = step
    setStep((s) => s + 1)
    return snapshot
    },
    rollback: (snapshot) => setStep(snapshot),

    框架无关的原因是:事务只依赖「读快照、改状态、恢复状态」这三个能力,它们在任何状态管理方案里都有。


    9. 边界与代价

    9.1 事务不保护异步请求

    run 里的请求如果已经发出去了,回滚只恢复 UI,不会取消请求。

    用户看到“操作失败,已恢复”,但服务器可能已经写入了数据。

    要真正做到“失败即撤销”,请求本身需要可取消(AbortController),或者服务端提供补偿接口。前端事务只能保证界面一致,不能保证数据一致。

    9.2 快照的深浅是个取舍

    浅拷贝成本低,但嵌套对象仍然共享引用;深拷贝更安全,但大对象会让每次操作都变慢。

    这个库的做法是:页面栈用 getPageSnapshot 深拷贝,缓存数组用 […caches.value] 浅拷贝,组件注册表用 new Map(components) 浅拷贝。

    原则是「改哪一层,就拷哪一层」:不修改对象内部的字段,就不需要深拷贝。

    9.3 版本号的作用域

    navigationVersion 是 useTabRouter 闭包里的变量:每个调用 useTabRouter() 的页面实例各有一份。如果两个完全无关的操作也共用它,就会互相误判。

    更好的做法是为每个操作域设置独立的版本号,或者用 AbortController 的 signal 作为“是否过期”的判据。这里的实现选择了简单方案:一个标签页同一时刻只应该有一次导航。

    9.4 乐观更新是在「骗」用户

    UI 先变了,但请求还没成功。如果网络很慢,用户会以为已经完成,转而去做别的事。

    所以乐观更新适合:

    • 操作成功率高;
    • 失败可以立即感知;
    • 失败后恢复不会造成困惑。

    对于「支付」「提交订单」这类操作,应该用悲观更新:等结果回来再改 UI。事务是给「乐观」兜底的,不是鼓励所有操作都乐观。

    9.5 事务不处理「部分成功」

    如果一个操作包含多个异步步骤(先保存草稿,再提交,再跳转),这个事务模型只包住最后一次导航操作。前面两步失败了,它不知道。

    多步骤的原子性需要更上层的编排(比如一个显式的状态机),不是单个事务能解决的。


    10. 一份检查清单

    如果你要给自己的导航或多步流程加事务,可以按这几条对照:

  • 先写清楚失败有几种:异常、业务失败、重复操作、过期;
  • 快照必须在修改之前获取,并且由 apply 返回;
  • Vue Router 的失败不是异常,用 isNavigationFailure 判断;
  • duplicated 要看意图:刷新场景下它不是失败;
  • 用版本号判断过期,每次操作递增,事务记住自己那一版;
  • 过期事务不回滚、不提交,只清理自己留下的残留状态;
  • 清理要做“再确认”:只删除当前状态下确实无主的内容;
  • 快照覆盖所有被修改的状态,包括那些看起来无关的计数器;
  • 事务的复杂度和状态复杂度匹配,不是所有操作都值得上;
  • 记住事务只管 UI 一致,数据一致要靠请求取消或服务端补偿。

  • 11. 小结

    回到开头那段两行代码:

    currentStep.value += 1
    await router.push({ name: `step-${currentStep.value}` })

    它缺的不是错误处理,而是一个明确的“如果没成,怎么退回去”的约定。

    前端事务的本质,就是把这件事显式化:

    • apply 说清楚“我改了什么”;
    • rollback 说清楚“怎么退回去”;
    • commit 说清楚“成功之后还要做什么”;
    • isCurrent 说清楚“我怎么知道已经轮到别人了”。

    四个问题回答完,状态就不会在异步的缝隙里烂掉。

    这套模式不依赖 Vue Router,也不依赖某个状态管理库。只要你有一处“先改 UI、后等结果”的代码,它就用得上。

    在线演示:https://jktantan.github.io/vue-stack-tabs/demo.html 文档:https://jktantan.github.io/vue-stack-tabs/ 源码:https://github.com/jktantan/vue-stack-tabs License: Apache-2.0

    你们项目里有没有“点了没反应,再点一次就乱了”的操作?最后是靠防抖、禁用按钮,还是靠回滚解决的?欢迎在评论区聊聊。

    赞(0)
    未经允许不得转载:171主机测评 » 前端也需要事务:Vue Router 乐观更新、回滚与“过期事务”
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址