这是「多标签页工程化」系列的第 7 篇。
前六篇都在讲缓存、滚动、iframe 这些具体机制。这一篇抽出来讲一个更通用的模式::当 UI 先变、异步结果后到时,怎么保证状态不会烂掉。
0. 先看一个没有事务的版本
几乎每个中后台系统都有这种代码。点击「下一步」,先更新界面,再跳路由:
ts
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
把上面的逻辑拆成四个明确的步骤:
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)
}
}
})
差别有两点:
apply没有改状态 。popPages在事务开始前就做了,apply只是把已经算好的快照交出去;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. 一份检查清单
如果你要给自己的导航或多步流程加事务,可以按这几条对照:
- 先写清楚失败有几种:异常、业务失败、重复操作、过期;
- 快照必须在修改之前获取 ,并且由
apply返回; - Vue Router 的失败不是异常 ,用
isNavigationFailure判断; duplicated要看意图:刷新场景下它不是失败;- 用版本号判断过期,每次操作递增,事务记住自己那一版;
- 过期事务不回滚、不提交,只清理自己留下的残留状态;
- 清理要做"再确认":只删除当前状态下确实无主的内容;
- 快照覆盖所有被修改的状态,包括那些看起来无关的计数器;
- 事务的复杂度和状态复杂度匹配,不是所有操作都值得上;
- 记住事务只管 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
你们项目里有没有"点了没反应,再点一次就乱了"的操作?最后是靠防抖、禁用按钮,还是靠回滚解决的?欢迎在评论区聊聊。