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

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

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

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

源码:https://github.com/jktantan/vue-stack-tabs


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

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

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

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

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

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

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

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


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

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

执行顺序:

ts 复制代码
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 知道「自己改了哪些东西」。

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

ts 复制代码
// 不推荐
const snapshot = getSnapshot()
apply()
await run(snapshot)

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

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

ts 复制代码
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:

ts 复制代码
try {
  await router.push(target)
  // 成功了?
} catch (e) {
  rollback()
}

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

router.push() 的返回值是:

ts 复制代码
Promise<void | NavigationFailure | undefined>

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

所以你需要显式判断:

ts 复制代码
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 不能删。两条路径都要回滚:

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

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


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

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

ts 复制代码
openTab({ id: 'order-1', path: '/order/1' }, true) // renew = true

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

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

ts 复制代码
// 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 用版本号判断「过期」

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

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

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

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

结果返回时:

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

4.3 过期时走第三个分支

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

ts 复制代码
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
  }
}

特别注意成功分支:

ts 复制代码
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 往页面栈里压了一个乐观页面。清理时只删这一个:

ts 复制代码
// 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 弹出了若干页,准备逐出它们的缓存。清理时只驱逐「确定已经不在当前栈里」的:

ts 复制代码
// 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 测试把三种结局都钉住了

ts 复制代码
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) // 也没有提交
})
ts 复制代码
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 的语义是「清空这个标签的页面栈,重新打开」。它要先销毁现有内容,再导航。如果导航失败,必须全部恢复。

ts 复制代码
// 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)
  }
}

调用方在失败时执行:

ts 复制代码
// 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:先压栈,再导航

ts 复制代码
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:先弹栈,再导航,成功才驱逐

ts 复制代码
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)
    }
  }
})

差别有两点:

  1. apply 没有改状态 。popPages 在事务开始前就做了,apply 只是把已经算好的快照交出去;
  2. commit 有实际工作。缓存驱逐是不可撤销的操作,必须等导航确认之后才能做。

这也说明:事务的四个回调不是模板,每个都要根据操作的语义填。


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

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

看这个库里的三个反例。

7.1 关闭标签:没有事务

ts 复制代码
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 激活标签:有事务,但不检测过期

ts 复制代码
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 都没有强绑定。把业务语义抽掉后,核心逻辑不到三十行:

ts 复制代码
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
  }
}

在一个多步表单里用它:

ts 复制代码
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:

ts 复制代码
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. 一份检查清单

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

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

11. 小结

回到开头那段两行代码:

ts 复制代码
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

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

相关推荐
怕浪猫1 小时前
K3.1 直追 Astra,先补 13%
前端·javascript·面试
天若有情6731 小时前
【前端趣味项目】把防抖玩出新花样!一个带隐藏后门的毫秒级“读秒挑战”
前端·javascript·html·防抖·外挂·趣味demo·读秒挑战
步行cgn2 小时前
JSON.stringify 详解:把 JavaScript 对象变成 JSON 字符串
开发语言·javascript·json
Liben2 小时前
Weex开发踩坑记录
android·前端·ios
jerrywus2 小时前
AI 写的代码能跑,为什么接手的人想跑?
前端·agent·claude
山雀~2 小时前
Web 多语言(i18n)技术方案详解
前端
biu_biubiu2 小时前
我开源了 Codebook
前端·vue.js
下山2 小时前
怎么把带自研插件的 DeepSeek Harness 部署到云服务器
前端
彭亚川Allen2 小时前
985 在职研究生到底怎么样?是不是水硕?是不是.....?
前端·后端·程序员