低代码组件通信:`EventBus`和响应式数据流,到底该选哪个

13 阅读1分钟

低代码组件通信: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管理就写了六百多行代码。比核心逻辑还多。

没救了。

再说第二个结构性缺陷:没有状态快照能力

这就是为什么很多低代码平台做到后期都往响应式数据流上迁移,或者至少搞一个混合方案。不是因为事件总线"不好",它在简单场景下确实又快又直观。问题在于它的设计假设——扁平通信、无状态、手动管理addEventListenerremoveEventListener的生命周期——跟低代码平台的核心需求之间有结构性的冲突。动态树形结构、状态持久化、自动依赖追踪,这些需求事件总线一个都满足不了。组件数量少的时候还能忍,量一上来补丁式的修补(加作用域、加防循环检测、加状态缓存层)的复杂度会直接超过重新设计一套方案的成本。

响应式数据流能解决什么,又会带来什么新问题

响应式的核心思路换个说法:别让组件自己去"喊"和"听"了,改成大家都从一个共享的状态树里读和写,框架自动追踪谁读了什么,写了什么变了该通知谁。

听起来完美。

实现起来嘛。怎么说呢,坑不少。

先看思路,低代码平台的状态可以建模成一棵跟组件树同构的状态树:

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'
      }
    }
  }
}

关键在derivedref这两个概念。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(...)
  }),
  ...
}

每个节点的响应式是独立的,不存在嵌套代理的问题。结构变更就是往Mapsetdelete一个条目,简单粗暴。依赖关系在邻接表里一目了然,检测循环依赖直接跑个DFS就行。

代价呢?你丢掉了状态树跟组件树的同构关系。组件树上"Tab1 包含 FormContainer 包含 Select"这种层级信息在扁平Map里只能通过 key 的命名约定(tab1.form1.province)来隐式表达。如果需要做"删除 Tab1 时级联删除所有子组件状态"这种操作的话?你得靠前缀匹配来找出所有tab1.*的 key。

能用,但不优雅。而且Map.keys()上做前缀匹配是O(n)的。

不过话说回来——低代码平台的组件数量级通常在几百到一两千。就这。就算是那种超重型的报表设计器,一个页面上也很少超过两千个组件。在这个量级上O(n)的前缀匹配跑一次也就几毫秒。完全不是瓶颈。真正的性能瓶颈永远是级联更新时的 DOM 操作和异步数据fetch()请求。

够用就行。

有个实际经验值得一提:依赖图里一定要加一个深度限制。

这套方案跑了差不多半年,扛住了几个大客户的复杂表单场景。级联更新没再出过那种"点一下卡两秒"的性能事故,主要归功于扁平化之后依赖追踪变精确了,再加上CascadeScheduler的优先级排序把同步和异步更新分开处理。代码写得不算好看,坦白说有些地方还挺丑的,但生产环境里稳定跑了半年没炸过,这就够了。