低代码组件通信:EventBus和响应式数据流,到底该选哪个
// 某低代码平台的真实 bug 现场
canvas.on('component:change', ({ id, value }) => {
const deps = getDependencies(id)
deps.forEach(dep => dep.update(value))
// dep.update 里又触发了新的 change 事件
// getDependencies 又找到一批下游……
// 递归炸弹
})
第一次撞上这个问题,是在一个表单设计器项目里,用户在画布上拖了大概四十多个组件,三层嵌套容器,每层容器里的下拉框还互相联动,点一下省份选择器页面直接卡了两秒。打开Performance面板一看,change事件触发了 370 多次。没夸张。
事件总线在低代码场景里为什么会失控
先说事件总线。大多数团队第一反应就是用它——组件 A 发事件,组件 B 监听,解耦干净利落,在常规业务项目里这套玩法完全没毛病。但低代码平台有个根本性的不同(听起来很合理对吧,但是)。你想啊,常规项目的组件树是开发者写死的,谁跟谁通信、链路多长,写代码的时候心里有数。
低代码不一样。组件树是用户拖出来的。
用户今天拖个两层嵌套,明天拖个五层。完犊子了。
讲道理,事件总线本质上就是个Map<string, Set<Function>>,一张扁平的订阅表,它压根不关心组件之间有没有层级关系,所有事件在同一个平面上广播。组件少的时候无所谓。但低代码画布上的组件数量是用户决定的——我见过有人在一个页面里堆了两百多个表单字段,每个字段都挂了联动规则,listeners数量直接上千。
来看一个简化的EventBus实现:
class EventBus {
private listeners = new Map<string, Set<Function>>()
private emitting = new Set<string>()
on(event: string, fn: Function) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(fn)
}
emit(event: string, payload: any) {
if (this.emitting.has(event)) {
console.warn(`循环触发: ${event}`)
return
}
this.emitting.add(event)
this.listeners.get(event)?.forEach(fn => fn(payload))
this.emitting.delete(event)
// 跨事件循环检测不到
// A 触发 eventX → B 监听后触发 eventY → C 监听后又触发 eventX
// emitting 里存的是当前事件名,eventX 第二次进来时早被 delete 了
}
}
单事件循环好防。emitting加个标记就完事。但跨事件循环呢?A 的onChange触发 B 更新,B 更新又触发 C 的onReset,C 重置完了又回来影响 A——这种链路在扁平事件总线里几乎没法检测,因为事件总线压根不知道"A 触发了 B"这层因果关系。没辙。
这就引出事件总线在低代码场景的第一个结构性毛病:缺乏拓扑感知。
你在设计器里看到的组件树长这样:
Canvas(画布根节点)
├── TabContainer
│ ├── Tab1
│ │ ├── FormContainer
│ │ │ ├── Select[省份] ←─ 改了这个
│ │ │ ├── Select[城市] ←─ 要联动这个
│ │ │ └── Input[地址]
│ │ └── TableContainer
│ │ └── DataTable ←─ 也要根据省份过滤
│ └── Tab2
│ └── ChartContainer
│ └── BarChart ←─ 这个要不要更新?看配置
└── SidePanel
└── Summary ←─ 跨容器引用了省份值
事件总线看到的是什么?一坨扁平的listener列表(听起来很合理对吧,但是)。它不知道Select[城市]和Select[省份]在同一个FormContainer里,也不知道Summary跨了两层容器在引用省份的值。谁喊一嗓子谁听到谁响应,就这么简单粗暴。
这带来两个实际问题。
第一个,作用域污染。
等等,我得纠正一下自己。说"全量广播"不太准确,事件总线是按事件名分组的,准确说是"同名事件的全量广播",但在低代码平台里如果你用component:${id}:change这种细粒度事件名的话?listener的注册和清理本身就是个噩梦。组件拖进来要注册、拖出去要注销、复制粘贴要重新绑定、撤销重做要恢复绑定状态。我在一个项目里光是事件的lifecycle管理就写了六百多行代码。比核心逻辑还多。
没救了。
再说第二个结构性缺陷:没有状态快照能力。
这就是为什么很多低代码平台做到后期都往响应式数据流上迁移,或者至少搞一个混合方案。不是因为事件总线"不好",它在简单场景下确实又快又直观。问题在于它的设计假设——扁平通信、无状态、手动管理addEventListener和removeEventListener的生命周期——跟低代码平台的核心需求之间有结构性的冲突。动态树形结构、状态持久化、自动依赖追踪,这些需求事件总线一个都满足不了。组件数量少的时候还能忍,量一上来补丁式的修补(加作用域、加防循环检测、加状态缓存层)的复杂度会直接超过重新设计一套方案的成本。
响应式数据流能解决什么,又会带来什么新问题
响应式的核心思路换个说法:别让组件自己去"喊"和"听"了,改成大家都从一个共享的状态树里读和写,框架自动追踪谁读了什么,写了什么变了该通知谁。
听起来完美。
实现起来嘛。怎么说呢,坑不少。
先看思路,低代码平台的状态可以建模成一棵跟组件树同构的状态树:
stateTree = {
canvas: {
tabContainer_1: {
activeTab: 'tab1',
tab1: {
formContainer_1: {
province: '浙江',
city: '杭州', // 依赖 province
address: ''
},
tableContainer_1: {
filter: derived(=> formContainer_1.province),
data: []
}
},
tab2: {
chartContainer_1: {
bindField: 'province',
chartData: derived(=> query(stateTree, bindField))
}
}
},
sidePanel: {
summary: {
ref: 'canvas.tabContainer_1.tab1.formContainer_1.province'
}
}
}
}
关键在derived和ref这两个概念。derived是派生状态,值不是用户直接设的而是从别的状态算出来的;ref是跨容器引用,靠状态路径指向另一个容器里的值。这两个加起来能覆盖低代码平台百分之九十的联动需求(虽然剩下那百分之十才是真正要命的)。
响应式方案确实解决了事件总线的两个结构性缺陷。拓扑感知有了,状态树本身就是拓扑结构。状态快照也有了,JSON.stringify(stateTree)序列化存起来就是快照。
但新问题来了。
你想啊,状态树跟组件树同构,组件树是用户拖出来的,用户拖一下你就得改状态树的结构(后来发现根本不是这回事)。也就是说状态树是动态的。动态状态树的响应式追踪比静态的难一个量级。
拿 Vue3 的reactive()举例——不是说低代码一定要用 Vue——先别急着反驳,而是它的响应式 API 比较直观好讲。reactive对已有属性的追踪没问题,但你动态给对象加属性(用户往容器里新拖了个组件),或者删属性(用户把组件删了),这些操作在Proxy层面虽然能拦截到,但依赖追踪的effect怎么处理?
跑不通。
已经走一遍过的effect里没有读过新属性,所以新属性变了也不会触发重跑。这不是 Vue 的 bug,这是响应式系统的固有限制——依赖是运行时收集的,没跑过的代码路径收集不到依赖。
所以你需要一层额外的机制来区分"结构变更"和"值变更":
值变更(Value Change)
province: '浙江' → '江苏'
→ 触发依赖了 province 的 derived/effect
→ 精确更新,性能好
结构变更(Structure Change)
formContainer_1 新增了 zipCode 字段
→ 状态树形状变了
→ 需要重新注册响应式追踪
→ 可能需要重新计算依赖图
→ 影响范围不确定,通常需要脏检查该容器下所有 derived
坦白说这就是为什么很多低代码平台的状态管理最终会变成一个"响应式 + 手动通知"的混合体——值变更走watchEffect的自动追踪,结构变更走手动emit通知,两套机制叠一起,复杂度直接翻倍。
实际工程中还有个更恶心的问题:级联更新的执行顺序。
假设用户改了省份。城市要联动,表格要过滤,图表要刷新,这三个更新之间有没有顺序要求?有的。城市的options列表要先更新完,地址字段才能根据新城市做validate()校验。但图表的刷新可以异步走fetch(),因为它要发请求拉数据,没必要阻塞同步联动链路。
这部分我自己也不太确定。
响应式系统默认是同步批量更新的——Vue 的nextTick、React 的batching——它不关心更新之间的业务优先级。在低代码场景里你需要自己加一层调度器:
class CascadeScheduler {
private queue: UpdateTask[] = []
private running = false
// priority 越小越先执行
// 同步联动(省市联动)priority = 0
// 异步数据加载 priority = 10
// UI 装饰性更新(图表动画)priority = 20
schedule(task: UpdateTask) {
this.queue.push(task)
this.queue.sort((a, b) => a.priority - b.priority)
if (!this.running) this.flush()
}
private async flush() {
this.running = true
while (this.queue.length > 0) {
const task = this.queue.shift()!
await task.execute()
// task.execute() 可能往 queue 里塞新任务(级联)
// while 循环会继续,直到队列真正清空
}
this.running = false
}
}
这个CascadeScheduler看起来简单,但task.execute()可能往queue里塞新任务——这就是级联更新的核心复杂度。你必须保证两件事。第一不能无限循环,需要一个maxIterations的断路器。等等,其实无限循环,需要一个maxIterations的断路器。第二新插入的任务要按priority正确排序插到当前队列的正确位置,而不是push到末尾,不然高优先级的级联更新会被低优先级的阻塞住。
扯远了,拉回来。
说一个很多团队会忽略的点:跨画布通信。低代码平台经常有"主画布 + 弹窗画布"或者"页面 A 跳页面 B"的场景,组件需要跨Canvas实例读取状态。这时候状态树的作用域模型就很关键了。
比较实际的做法是分层:
全局状态层(Global State) ← 跨画布共享,如 userInfo、全局变量
↕ 读写
画布状态层(Canvas State) ← 单个画布内的组件状态
↕ 读写
容器状态层(Container State) ← 单个容器内的局部状态
↕ 向上冒泡 / 向下穿透
组件状态层(Component State) ← 单个组件的内部状态
跨画布通信通过globalStore中转。
这套分层模型好在哪?每一层的状态变更只需要通知本层和直接关联层,不会无脑全局广播。坏在哪?实现复杂度高,尤其是"向上冒泡"——子容器的状态变了什么时候该冒泡到父容器?全部冒泡等于没分层。不冒泡的话?父容器拿不到子容器最新状态,又废了。
实际项目里我们用的策略是"声明式冒泡":容器在配置时声明哪些内部字段对外暴露,只有被标记为exposed: true的字段变更才会冒泡。有点像 Vue 组件的defineExpose,但作用在状态树层面上。
混合方案:该混就混,别追求纯粹
聊到这里结论已经比较明显了。纯事件总线扛不住低代码的复杂度。纯响应式方案的实现成本又高得离谱,特别是动态结构变更和级联调度那块。能怎么办?混着来呗。
实际跑在生产环境里的方案大多是混合的:
// 这段有坑,别直接抄
class ComponentBridge {
private eventBus = new EventBus()
private stateTree = reactive({})
// 值变更:走响应式
setValue(path: string, value: any) {
set(this.stateTree, path, value)
// reactive 自动通知依赖了这个 path 的 derived
}
// 结构变更:走事件总线
addComponent(containerId: string, component: ComponentConfig) {
const path = this.resolvePath(containerId, component.id)
set(this.stateTree, path, component.defaultState)
this.eventBus.emit('structure:change', {
type: 'add',
containerId,
componentId: component.id,
path
})
}
}
这段代码的问题出在set(this.stateTree, path, value)这行。path是个字符串,比如'canvas.tab1.form1.province',你需要把它解析成嵌套的属性访问然后逐层确保每一层都被Proxy正确代理——中间某一层要是后来动态加的,reactive的代理链可能断掉。用 Vue 的set()辅助函数可以解决,或者在每次结构变更后对新增子树重新做reactive()包装,但这会带来重复代理的问题。一个对象被reactive包两次不会报错,但toRaw()拿到的可能不是你以为的那个原始对象。微妙得很。
所以比较稳的做法是:状态树不用深层嵌套对象。
用一个扁平的Map<string, ReactiveState>来存储。key是组件路径,value是单个组件的响应式状态对象。组件之间的依赖关系单独维护一个邻接表,级联更新时按邻接表的拓扑排序来执行。
说白了就是把"树"拍平成"图"。
状态存储(扁平化) 依赖图(邻接表)
Map { province → [city, tableFilter, summary]
'form1.province': reactive({ city → [addressValidator]
value: '浙江', tableFilter → [tableData]
options: [...], chartBind → [chartData]
rules: [...]
}),
'form1.city': reactive({
value: '杭州',
options: derived(...),
rules: [...]
}),
'table1.filter': reactive({
value: derived(...)
}),
...
}
每个节点的响应式是独立的,不存在嵌套代理的问题。结构变更就是往Map里set或delete一个条目,简单粗暴。依赖关系在邻接表里一目了然,检测循环依赖直接跑个DFS就行。
代价呢?你丢掉了状态树跟组件树的同构关系。组件树上"Tab1 包含 FormContainer 包含 Select"这种层级信息在扁平Map里只能通过 key 的命名约定(tab1.form1.province)来隐式表达。如果需要做"删除 Tab1 时级联删除所有子组件状态"这种操作的话?你得靠前缀匹配来找出所有tab1.*的 key。
能用,但不优雅。而且Map.keys()上做前缀匹配是O(n)的。
不过话说回来——低代码平台的组件数量级通常在几百到一两千。就这。就算是那种超重型的报表设计器,一个页面上也很少超过两千个组件。在这个量级上O(n)的前缀匹配跑一次也就几毫秒。完全不是瓶颈。真正的性能瓶颈永远是级联更新时的 DOM 操作和异步数据fetch()请求。
够用就行。
有个实际经验值得一提:依赖图里一定要加一个深度限制。
这套方案跑了差不多半年,扛住了几个大客户的复杂表单场景。级联更新没再出过那种"点一下卡两秒"的性能事故,主要归功于扁平化之后依赖追踪变精确了,再加上CascadeScheduler的优先级排序把同步和异步更新分开处理。代码写得不算好看,坦白说有些地方还挺丑的,但生产环境里稳定跑了半年没炸过,这就够了。