组件间事件(广播 / 接收)
低代码画布上经常要做联动:点一下「级联切换」组件,旁边的折线图、柱状图、KPI 卡同时换数据。这页讲怎么在组件源码里把一个组件的「广播」和另一个组件的「接收」写出来。
心智模型:信箱,不是全站喇叭
SDK 的事件不是一个「全局 event bus」。每个组件实例有一个私有信箱(基于 mitt 的 emitter,挂在它的 ComponentInstanceHandle 上)。三个动作:
| API | 含义 |
|---|---|
runtime.on(X, event, fn) | 订 实例 X 的信箱:有人往 X 投 event 时,fn 触发 |
runtime.emit(X, event, payload) | 投 一封信到实例 X 的信箱(只有 X 收到) |
canvas.broadcastInCanvas(event, payload) | 群投:往本画布每一个实例的信箱都投一封 |
由此推出唯一要记的规则:
接收方永远订自己的信箱(
on(this.hcInstanceId, …))。 广播方用broadcastInCanvas往所有信箱投信,接收方的信箱也被投了,于是它收到。 如果广播方知道该发给谁,就用emit(目标id, …)定向投递。
为什么不是全局总线
私有信箱让「一个画布内组件出错不影响其它画布」「同名实例跨画布互不干扰」成为可能。代价是广播需显式走 broadcastInCanvas 群投,而非单一全局 bus.emit。对多画布业务而言,这是必要的隔离。
组件能读到的「身份」
SDK 给每个组件实例注入 4 个保留 prop(Options API 里直接 this.xxx 读,别用这些名字声明自己的 prop):
| Prop | 用途 |
|---|---|
this.hcInstanceId | 自己的实例 ID —— 订自己信箱 / 定向被发都用它 |
this.hcCanvasId | 自己所在画布 ID —— 群投限定本画布用它 |
this.hcComponentId | 组件源 ID(同款多实例共享) |
this.hcCustomValues | 属性面板填的 override 值 |
__HYPERCARD__ 全局对象上拿运行时:window.__HYPERCARD__.runtime(单实例 emit/on)、window.__HYPERCARD__.canvases(画布级群投 / 跨画布)。组件代码里 __HYPERCARD__ 直接可用,无需 import。
案例 1:一对多联动(最常用)
「级联切换」广播维度+粒度,任意数量的图表接收后各自取数。广播方不需要知道谁在听。
广播方 — 级联切换
js
export default {
data() {
return { dim: 'time', grain: 'day' }
},
methods: {
setDim(v) { this.dim = v; this.broadcast() },
setGrain(v) { this.grain = v; this.broadcast() },
broadcast() {
// 往本画布所有实例的信箱投一封 'cascade:change'
window.__HYPERCARD__
?.canvases.get(this.hcCanvasId)
?.broadcastInCanvas('cascade:change', { dim: this.dim, grain: this.grain })
},
},
template: `
<div class="cascade">
<button :class="{ on: dim === 'time' }" @click="setDim('time')">按时间</button>
<button :class="{ on: dim === 'unit' }" @click="setDim('unit')">按单位</button>
<button :class="{ on: dim === 'group' }" @click="setDim('group')">按机组</button>
<span class="sep"></span>
<button :class="{ on: grain === 'day' }" @click="setGrain('day')">日</button>
<button :class="{ on: grain === 'month' }" @click="setGrain('month')">月</button>
<button :class="{ on: grain === 'year' }" @click="setGrain('year')">年</button>
</div>
`,
}接收方 — 折线图(柱状图 / KPI 卡同理)
js
export default {
data() {
return { _off: null, dim: 'time', grain: 'day' }
},
mounted() {
// 订「自己的信箱」。0.4.0 起 register 已在 created 完成,mounted 里立即可订。
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'cascade:change', (payload) => {
this.dim = payload.dim
this.grain = payload.grain
this.reload()
})
},
beforeUnmount() {
// ★ 必须退订,否则组件换源 / 卸载会泄漏 handler
this._off && this._off()
},
methods: {
async reload() {
// 拿 dim / grain 去取数 + 重画 echarts
},
},
}广播方自己也会收到
broadcastInCanvas 不排除发起者本身——它也会收到自己发的 cascade:change。一般无所谓(按事件名/业务忽略即可);要排除就在 payload 里带 from: this.hcInstanceId,接收方判一下。
案例 2:点对点定向(已知目标 ID)
广播方明确知道要发给哪个实例时,跳过群投,直接 emit(目标id, …) —— 全画布只有那个实例收到。
js
// 发起方:把 'highlight' 只发给 instanceId = 'chart_main' 的实例
window.__HYPERCARD__?.runtime.emit('chart_main', 'highlight', { seriesId: 7 })js
// chart_main 这个实例:订自己的信箱
mounted() {
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'highlight', (p) => this.highlight(p.seriesId))
}低代码场景慎用写死 ID
拖拽生成的实例 ID 是运行时分配的(形如 LineChart_169…_3),写死在源码里很脆。联动优先用案例 1 的命名广播;定向 emit 更适合「容器/父组件明确管着某个固定 ID 子件」这类场景。
多画布场景定向投递,用显式带 canvasId 的版本,避免同名实例串台:
js
window.__HYPERCARD__?.canvases.emitToCanvas('card-a', 'chart_main', 'highlight', { seriesId: 7 })案例 3:把事件冒泡给「宿主」
组件想对外暴露一个事件(「我被点了 / 我的值变了」),让宿主页面(不是另一个画布组件)去监听——比如宿主要据此弹窗、改路由、写日志。
组件往自己的信箱投信:
js
methods: {
onPick(row) {
window.__HYPERCARD__?.runtime.emit(this.hcInstanceId, 'rowClick', row)
},
}宿主侧(你的 Vue 页面,拿到 CanvasHandle 或用全局 runtime)订这个实例的信箱:
ts
// 宿主代码,不是组件源码
const off = window.__HYPERCARD__?.runtime.on('table_orders', 'rowClick', (row) => {
router.push(`/order/${(row as any).id}`)
})
// 页面卸载时 off?.()让属性面板/devtools 认识你的事件
组件源码里用字面量调 runtime.emit('rowClick', …) / __hc.emit('rowClick') 时,后端 parseComponentSource 会把 'rowClick' 扫进组件契约的 emitsDecl,devtools 和属性面板就能列出「这个组件会发哪些事件」。动态事件名(emit(this.evt))扫不到,需要手填声明。所以事件名尽量写字面量。
案例 4:跨画布广播
一个 dashboard 同时挂多个 <HyperCardPageRenderer>(多画布),要全站联动(换主题、全局刷新):
js
// 往「所有画布的所有实例」投信
window.__HYPERCARD__?.canvases.broadcast('theme:change', { theme: 'dark' })js
// 任意画布里的任意组件,订自己的信箱即可
mounted() {
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'theme:change', (p) => this.applyTheme(p.theme))
}三层范围,按需选最小的:
| 范围 | API | 谁收到 |
|---|---|---|
| 单实例 | runtime.emit(id, e, p) / canvases.emitToCanvas(cid, id, e, p) | 只有目标实例 |
| 本画布 | canvases.get(cid).broadcastInCanvas(e, p) | 该画布全部实例 |
| 全部画布 | canvases.broadcast(e, p) | 所有画布全部实例 |
案例 5:联动 + 取数(广播触发重拉数据)
接收方收到广播后,既可以自己 fetch,也可以走 SDK 的数据灌入通道把结果塞进 dataInput 字段(组件 custom.<key>.dataInput = true 声明的字段)。后者让取数逻辑可被属性面板/绑定可视化接管:
js
export default {
// 声明一个接收数据的字段
custom: {
rows: { value: [], dataInput: true },
},
data() { return { _off: null } },
mounted() {
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'cascade:change', async ({ dim, grain }) => {
const rows = await fetchSeries(dim, grain) // 你的取数
// 走 setDataInput 通道:等价于 this.custom.rows.value = rows,但会被诊断/绑定记录
const h = window.__HYPERCARD__?.runtime.getInstance(this.hcInstanceId)
h?.setDataInput('rows', rows)
})
},
beforeUnmount() { this._off && this._off() },
template: `<MyChart :data="custom.rows.value" />`,
}数据绑定/可视化连线的完整玩法见 属性面板暴露机制。
生命周期 & 退订(必看)
- 订阅时机:0.4.0 起组件在
created(早于mounted)就 register,所以mounted里runtime.on(this.hcInstanceId, …)一定能拿到自己的 handle,不用nextTick等微任务。 - 务必退订:
on(...)返回一个off函数。存起来,在beforeUnmount调用。漏掉会在组件换源 / 卸载 / HMR 时泄漏 handler,出现「换了组件代码,旧回调还在跑」。 - 实例销毁自动清信箱:实例
unregister/ 画布dispose时,SDK 会清空该实例信箱的所有 listener。但你订的那一份最好还是自己off,别依赖兜底。
instance:ready 回调里别碰 DOM
runtime.on(this.hcInstanceId, …) 是订业务事件,没问题。但如果你订的是生命周期信号 instance:ready(onInstanceLifecycle / handle.on('instance:ready')),0.4.0 起回调触发时子组件 DOM 还没渲染,vm.$el 是 null。要访问 DOM 包一层 nextTick(() => vm.$el …)。详见 core API。
设计期能不能跑?
能。设计器(<HyperCardCanvasDesigner mode="design">)里组件也是经 RuntimeBox 真实编译挂载的,信箱机制照常工作。所以预览模式和发布页行为一致——你在设计器里点「级联切换」,折线图会真的联动。这也意味着设计期的联动副作用(取数请求)会真的发出去,调试时注意。
排查清单
| 现象 | 多半是 |
|---|---|
| 接收方没反应 | 接收方订的是别人的 id 而不是 this.hcInstanceId;或广播方用了 emit(单id) 而没用 broadcastInCanvas |
偶发不触发 / 控制台 instance not found warn | 广播方在接收方 mounted 之前就发了(时序);或目标 instanceId 写错 |
| 换了组件代码旧逻辑还在跑 | 漏了 beforeUnmount 里 off() |
| 多画布串台,别的卡片也被联动 | 该用 broadcastInCanvas(本画布)却用了 canvases.broadcast(全站);或定向 emit 没带 canvasId |
| devtools/属性面板里组件不列事件 | 事件名不是字面量(emit(this.evt)),emitsDecl 扫不到,需手填 |
API 速查
js
const { runtime, canvases } = window.__HYPERCARD__
// —— 单实例信箱 ——
const off = runtime.on(id, event, fn) // 订;返回退订函数
runtime.emit(id, event, payload) // 定向投递
runtime.call(id, method, ...args) // 直接调实例的方法(不是事件)
runtime.getInstance(id) // 拿 handle(setDataInput / setProp 等)
// —— 画布级 ——
const cv = canvases.get(canvasId)
cv.broadcastInCanvas(event, payload) // 本画布群投
cv.listInstances({ componentId }) // 枚举本画布实例
// —— 跨画布 ——
canvases.broadcast(event, payload) // 全站群投
canvases.emitToCanvas(cid, id, event, payload) // 显式 scope 定向
canvases.callComponent(cid, id, method, ...args) // 显式 scope 调方法