Vue 3 的模板语法足以覆盖绝大多数业务场景,可一旦组件结构需要随数据动态拼装、类型约束需要贯穿到渲染逻辑内部,TSX 的表达力就体现出来了。所谓事务同步扩展,指的是把一组相关的状态变更包装成原子操作:要么全部生效并一次性同步到视图与副作用,要么整体回滚,任何中间状态都不对外泄漏。这篇文章从编译链路配置讲起,动手实现一个轻量事务管理器,再把它扩展到 watcher 调度层面,最后给出工程化组织方式和一份避坑清单。

一、搭建 TSX 编译链路:让渲染函数获得完整类型支持
在 Vue 3 工程里启用 TSX,核心依赖是 @vitejs/plugin-vue-jsx。它基于 Babel 的 JSX 转换,把 TSX 里的元素语法编译成 h 函数调用,同时保留完整的 TypeScript 类型检查。安装后把插件注册进 Vite 配置,再让 tsconfig 的 compilerOptions 支持 jsx 相关选项,编辑器就能对渲染函数里的 props、事件和插槽做精确推导,重构与跳转定义都不会失效。
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
export default defineConfig({
plugins: [
vue(),
// enableObjectSlots 关闭后,插槽统一走函数形式,类型推导更精确
vueJsx({ enableObjectSlots: false })
]
})TSX 组件的写法和组合式 API 完全一致,setup 接收 props 并返回渲染函数。区别在于模板指令在这里变成了对象属性或函数调用:事件绑定写成 on 开头的属性,双向绑定拆成 value 与 onChange 的组合。写法上啰嗦了一点,但每一行都是普通 TypeScript,重构工具能理解,事务逻辑也能直接包裹进去,这正是后续章节需要的特性。
import { defineComponent, ref } from 'vue'
export default defineComponent({
name: 'CounterPanel',
props: {
title: { type: String, required: true }
},
setup(props) {
const count = ref(0)
const step = ref(1)
return () => (
<div class="panel">
<h3>{props.title}</h3>
<button onClick={() => (count.value += step.value)}>
当前值:{count.value}
</button>
</div>
)
}
})还有一个现实原因让 TSX 更适合做事务扩展的载体:渲染函数是同步执行的普通函数,可以在执行前后插入任意的调度逻辑;而模板的编译产物对开发者是黑盒,想在其内部做拦截几乎不可能。事务机制要介入渲染与副作用流程,可编程的渲染函数是前提条件。
二、中间态泄漏:问题的真正根源
不少人对 Vue 3 的批量更新有误解,以为它能天然避免中间态,这个判断只对了一半。组件重渲染确实是异步批量的,同一个 tick 里改多个 ref 只会触发一次 patch。但 watcher 的默认调度时机是 flush 取 pre,回调在组件更新前执行,多个 watcher 又各自独立入队。一次业务操作连续修改 items 和 discount 时,依赖 items 的回调先执行,此刻它读到的 discount 还是旧值,数据就处于半新半旧的状态。
watch(() => state.items, recalc)
watch(() => state.discount, recalc)
function batchAdd(newItems: Item[]) {
state.items.push(...newItems)
// 此刻依赖 items 的 recalc 已被调度,discount 仍是旧值
state.discount = computeDiscount(state.items)
// 第二次触发 recalc,中间态已被上一个回调消费
}如果联动函数只是纯计算,代价是重复执行两遍;一旦里面混入请求、埋点、写日志这类副作用,问题立刻被放大:埋点上报了错误的总价,请求带上了不一致的参数。更麻烦的是这类缺陷具有偶发性,强依赖变量的修改顺序,测试环境很难稳定复现,排查起来相当耗时。
事务同步要根治的就是这个时序问题:在逻辑层把一组变更打包,让所有依赖这组变量的回调只在事务提交时执行一次,并且读到的是最终一致的状态。思路借鉴数据库事务的原子性,但实现必须贴合 Vue 响应式的调度机制,而不是简单加锁或者延迟执行。
三、实现事务管理器:收集、提交与回滚
核心设计是一个调度队列加深度计数器。schedule 是统一调度入口:处于事务内时任务入队等待,否则立即执行。transaction 负责包裹业务逻辑,深度计数保证嵌套事务只有最外层负责冲刷队列,内层事务结束时不会提前执行任务,语义与数据库的嵌套事务一致。队列用 Map 实现,同一个 key 的任务相互覆盖,提交时只执行最后一次入队的版本,天然完成了联动逻辑的去重。
type Task = () => void
const queue = new Map<string, Task>()
let depth = 0
let seq = 0
export function transaction<T>(fn: () => T): T {
depth++
try {
return fn()
} finally {
depth--
if (depth === 0 && queue.size > 0) {
// 提交阶段:先拷贝再清空,防止任务执行中再次入队造成死循环
const tasks = [...queue.values()]
queue.clear()
tasks.forEach(run => run())
}
}
}
export function schedule(task: Task, key?: string): void {
if (depth === 0) {
task()
return
}
// 同 key 任务相互覆盖,提交时只执行最后一次入队的版本
queue.set(key ?? `__task_${seq++}`, task)
}冲刷前先把任务拷贝出来再清空队列,是为了防止任务执行过程中又触发新的 schedule 造成死循环。提交阶段的任务执行是同步的,执行完毕后所有状态与副作用都到达一致点,组件渲染则交给 Vue 自身的批量机制,一个 tick 只 patch 一次,两层批量互不干扰。
接下来扩展 watcher。Vue 官方的 watch 支持 flush: 'sync' 配置,回调会在依赖变化的那一刻同步调用。利用这个时机,在回调内部把真正的业务逻辑通过 schedule 转入事务队列:事务包裹的代码块里无论触发多少个 transactionalWatch,回调都会被收集,等最外层 transaction 结束才统一执行;没有事务时 schedule 直接执行,行为退化为普通的同步 watch,对现有代码零侵入。
import { watch, type WatchCallback } from 'vue'
import { schedule } from './transaction'
export function transactionalWatch<T>(
source: () => T,
callback: WatchCallback<T>,
key?: string
) {
return watch(
source,
(value, oldValue, onCleanup) => {
schedule(() => callback(value, oldValue, onCleanup), key)
},
{ flush: 'sync' }
)
}回滚能力靠快照实现。事务开始前对状态容器做一次深拷贝,业务函数抛异常时把快照写回原对象。这里有两个限制要认清:toRaw 只能解包 reactive 代理,状态如果是 ref 需要先取 value;structuredClone 无法处理函数和 Proxy,快照对象必须是纯数据。所以这套回滚是尽力而为的语义,适合表单提交这类可重放的场景,不能替代服务端的真事务。
import { toRaw } from 'vue'
import { transaction } from './transaction'
export function withRollback<S extends object>(
state: S,
fn: () => void
): boolean {
// 快照必须基于纯数据,包含函数或 Proxy 时 structuredClone 会直接抛错
const snapshot = structuredClone(toRaw(state))
try {
transaction(fn)
return true
} catch (error) {
Object.assign(state, snapshot)
return false
}
}四、TSX 组件实战:订单编辑器
把前面的积木拼起来看一个完整例子。订单编辑器维护条目列表、折扣与应付金额,条目数或折扣变化都要重算合计。批量添加条目时,期望重算只发生一次,应付金额不出现中间值。两个事务化 watcher 共用同一个 key,正是为了让去重机制发挥作用。
import { defineComponent, ref } from 'vue'
import { transaction } from './transaction'
import { transactionalWatch } from './transactional'
interface Item {
name: string
price: number
}
export default defineComponent({
name: 'OrderEditor',
setup() {
const items = ref<Item[]>([])
const discount = ref(0)
const total = ref(0)
const payable = ref(0)
function recalc() {
total.value = items.value.reduce((sum, item) => sum + item.price, 0)
payable.value = Math.max(total.value - discount.value, 0)
}
// 两个源头共用同一个 key,提交阶段自动去重
transactionalWatch(() => items.value.length, recalc, 'recalc')
transactionalWatch(() => discount.value, recalc, 'recalc')
function batchAdd(newItems: Item[]) {
transaction(() => {
items.value.push(...newItems)
discount.value = Math.min(items.value.length * 5, 50)
// recalc 由事务化 watcher 在提交阶段统一触发,只执行一次
})
}
return () => (
<div class="order-editor">
<button onClick={() => batchAdd([
{ name: '键盘', price: 199 },
{ name: '鼠标', price: 89 }
])}>
批量添加
</button>
<p>
条目 {items.value.length} 个,合计 {total.value},应付 {payable.value}
</p>
</div>
)
}
})关键在 batchAdd 内部。items 的 push 会同步触发第一个 transactionalWatch 的调度,discount 的赋值触发第二个,两个回调带着相同的 key 进入队列;事务提交时 Map 去重生效,recalc 只执行一次,此刻读到的 items 与 discount 都是最终值。应付金额从头到尾只经历一次变化,页面上不会闪现中间数字,埋点与请求拿到的也是一致的数据。
如果担心重算逻辑本身抛错污染状态,把 transaction 换成 withRollback 即可:异常时状态整体回到操作前,界面停留在提交前的样子,配合错误提示就能给用户明确的反馈,不需要再手写一堆还原赋值。
五、工程化组织与避坑清单
工程化组织上,建议把事务相关代码独立成模块与组件解耦:transaction.ts 承载核心机制,transactional.ts 封装 watcher 与回滚扩展,组件只依赖后者的公开接口。类型层面把 Task、快照工厂抽成显式别名,避免回调签名在业务代码里重复散落。时序语义必须用单元测试锁死,下面这条用例直接验证了事务最核心的承诺。
import { describe, it, expect, vi } from 'vitest'
import { ref } from 'vue'
import { transaction } from '../src/transaction'
import { transactionalWatch } from '../src/transactional'
describe('transaction', () => {
it('事务内的多次变更只触发一次回调', () => {
const count = ref(0)
const spy = vi.fn()
transactionalWatch(() => count.value, spy, 'spy')
transaction(() => {
count.value = 1
count.value = 2
count.value = 3
})
expect(spy).toHaveBeenCalledTimes(1)
})
})除了结构组织,几个容易踩的坑值得单独列出,它们都来自事务机制与异步、与状态库交互时的边界情况:
- 事务是同步语义,函数体内一旦出现 await,队列冲刷会发生在微任务之后,原子性即被打破。需要异步时把网络请求放在事务外,事务只包裹状态写入。
- flush 取 sync 的 watcher 在事务外退化为同步执行,高频修改场景要评估回调的执行成本,必要时在回调内部再做节流。
- structuredClone 不支持函数、DOM 节点和 Proxy,包含 Map 或 Set 的状态需要自行实现序列化与还原。
- 与 Pinia 配合时,事务包裹 action 即可生效,但跨多个 store 的回滚需要自己维护多份快照并按逆序恢复。
- 冲刷时某个任务抛异常会中断后续任务,建议在执行处对每个任务做 try-catch 隔离,至少保证错误可观测、队列不残留。
回看整套方案,TSX 提供了可编程的渲染载体,事务管理器补上了 Vue 响应式在逻辑层的时序缺口,两者结合让状态变更从散落的赋值变成了可声明、可测试、可回滚的原子单元。核心代码总量不到两百行,没有引入任何新依赖,适合直接嵌入现有工程。当业务里出现多状态联动、批量编辑、表单提交这类场景时,这套机制的价值会越来越明显。