数据绑定运行时
把「数据源(取数)」接到「组件实例的数据字段」,运行时由 SDK 负责 wiring。本页讲数据怎么在运行时流转、宿主怎么提供
DataAdapter。属性面板怎么暴露 binding 字段、用户怎么在 UI 里配 binding,见 属性面板暴露机制。
1. 概念
一条 binding 描述「某个数据源的结果,送进某个组件实例的某个字段」。它由三部分组成:
- source:数据从哪来。取数类 binding 用
{ kind: 'dataSource', sourceId }。 - target:数据送到哪去。灌字段用
{ kind: 'instanceDataInput', instanceId, key }。 - mapping / schedule / errorPolicy:可选,描述「怎么转一道」「节流防抖」「出错怎么办」。
SDK 不 fetch、不知道你的后端。它只在合适的时机调用宿主提供的 DataAdapter.query(...) 拿数据,再通过 setDataInput(key, value) 把结果写进组件声明为 dataInput: true 的字段。整条链路是:
binding(source/target) + 宿主 DataAdapter
↓ wirePageBindings 在运行期 wire
SDK 调 adapter.query / adapter.subscribe 取数
↓ 命中 mapping
inst.setDataInput(key, value)
↓ Vue 响应式
组件 custom.<key>.value 更新 → 组件 $watch / 模板重渲宿主只负责两件事:提供 DataAdapter(把 sourceId 解析到真实后端),以及在合适的生命周期调用 wirePageBindings。其余 cache、双向索引、节流、错误策略全在 SDK 内部。
2. wirePageBindings(options)
wirePageBindings 是运行时数据绑定的主入口。它返回一个 handle,宿主用它切换模式、订阅事件、最后 dispose。
options
ts
interface WirePageBindingsOptions {
/** 当前 canvas id;宿主在外算好传进来 */
canvasId: string
/** 文档来源;wirer 靠它读 PageDocument + 感知文档变化 */
source: DocumentSource
/**
* 建议初始模式 —— 仅作 host 可读建议值,wirer 工厂**不**消费。
* 必须在 attach 后显式调 handle.setMode(initialMode)。
*/
initialMode?: CanvasBindingMode
/**
* 数据层 store;传了 dataSource binding 才真取数。
* 不传 → dataSource binding 在 wire 时 emit 'no-data-adapter' 并跳过。
*/
dataStore?: DataSourceStore
}
interface DocumentSource {
/** 当前文档快照(可能 null);wirer 内部需要时再调 */
getDocument(): PageDocument | null
/** Designer / Context 路径:每条 action apply 完之后回调;返 unsubscribe */
onAction?(cb: (action: unknown) => void): () => void
/** 独立 Renderer 路径:文档整体换引用时回调;返 unsubscribe */
onDocumentChange?(cb: () => void): () => void
}source 两路回调至少接通一个,否则 wirer 感知不到文档变化。Designer / Context 场景接 onAction,独立 Renderer 场景接 onDocumentChange。
返回的 handle
ts
interface WirePageBindingsHandle {
setMode(mode: CanvasBindingMode): void
getMode(): CanvasBindingMode
/** 干跑一条 binding(不真调 target、不进 scheduler),返回每个阶段的 trace */
testBinding(bindingId: string, mockPayload?: unknown): BindingTestTrace[]
on<E extends 'binding:fire' | 'binding:error' | 'binding:trace'>(
event: E,
cb: BindingEventCb<E>,
): () => void
/** trace ringbuffer 模式 */
startTracing(opts?: TraceCollectorOptions): void
stopTracing(): BindingTraceEvent[]
getTraceBuffer(): readonly BindingTraceEvent[]
/** 显式 dispose —— 调用方自己管,wirer 内部不挂自动销毁 */
dispose(): void
}on(...) 返回退订函数,三类事件:
ts
interface BindingFireEvent {
bindingId: string
source: BindingSource
target: BindingTarget
payload: unknown // source 原始 payload
args: unknown[] // mapping 之后、送进 target 的实参
}
interface BindingErrorEvent {
bindingId: string
code: BindingErrorCode
message?: string
detail?: unknown
}BindingErrorCode 取值:'target-not-mounted' / 'unsupported-cross-canvas' / 'unknown-target-data-input' / 'no-data-adapter' / 'data-query-failed' / 'cycle-detected' / 'invoke-failed'。
'binding:trace' 推送的是 BindingTraceEvent,覆盖 wire / fire / schedule / 取数全生命周期,用于 devtools / 调试。完整 trace 字段见 Canvas Types。
生命周期
工厂返回时模式为 'off',不会自动取数。挂载顺序:
- 调
wirePageBindings(options)拿 handle; - 把 handle 接进宿主(Designer 走 delegate,Renderer 直接持有);
- 等订阅挂好后,显式
handle.setMode(initialMode)才开始 wire / 取数; - 页面销毁时(
onBeforeUnmount/ Designer dispose)调handle.dispose()。
dispose() 是幂等的:它会按顺序 fire pageBeforeUnmount、解绑所有 binding、停掉文档订阅、清理事件。注意 wirePageBindings 不会替你释放 dataStore —— store 的 owner 自己调 dataStore.disposeAll()。
最小可用示例
ts
import {
wirePageBindings,
createDataSourceStore,
bindingModeForCanvasMode,
} from '@hy-bricks/canvas'
import { nextTick, onBeforeUnmount } from 'vue'
// 1. 宿主实现 DataAdapter(见第 3 节)
const adapter = {
query: (input) => fetch(`/api/data/${input.sourceId}`).then((r) => r.json()),
}
// 2. 建数据 store
const dataStore = createDataSourceStore(adapter)
// 3. wire
const handle = wirePageBindings({
canvasId: 'main',
source: {
getDocument: () => currentDocument.value,
onDocumentChange: (cb) => watchDocumentRef(cb), // 文档换引用时调 cb
},
dataStore,
})
// 4. 延后一拍 setMode,让订阅先挂上;preview / runtime 才取数
handle.on('binding:error', (e) => console.warn('[binding]', e.code, e.message))
nextTick(() => handle.setMode(bindingModeForCanvasMode('preview')))
// 5. 收尾
onBeforeUnmount(() => {
handle.dispose()
dataStore.disposeAll() // store 的 owner 负责
})3. DataAdapter 协议
DataAdapter 是宿主实现的接口。SDK 控制调用时机,宿主内部用 REST / GraphQL / WebSocket 任意方式拿数据。
ts
interface DataAdapter {
/** 查询数据源;可同步返值,也可返 Promise */
query(input: DataQueryInput): unknown | Promise<unknown>
/** 可选:监听数据源变化(WebSocket / SSE 推送);返 unsubscribe */
subscribe?(
input: DataSubscribeInput,
cb: (value: unknown) => void,
): () => void
}
interface DataQueryInput {
sourceId: string
params?: Record<string, unknown> // 预留;当前版本传 undefined
signal?: AbortSignal // SDK 在 dispose / 重发时主动取消
}
interface DataSubscribeInput {
sourceId: string
params?: Record<string, unknown>
}要点:
sourceId是宿主自己配在binding.source上的标识,由宿主内部解析到具体后端。SDK 不解析它的格式。query返回的值会原样(经 mapping 后)送进setDataInput;SDK 内部用await Promise.resolve(result)兜底,所以同步返值或返 Promise 都行。query抛错 / Promise reject → SDK 走error状态并按 binding 的errorPolicy处理,同时 emit'data-query-failed'。signal是AbortSignal,SDK 在重发或销毁时 abort;adapter 实现可选地 honor 它。subscribe不实现时,SDK 只走query单次拉;binding 标了watch: 'change'而 adapter 实现了subscribe时,SDK 用subscribe持续监听并兜底拉一次保鲜。
实现示例
ts
const adapter: DataAdapter = {
// 同步 cache 命中可直接返,异步走 Promise
async query({ sourceId, params, signal }) {
const res = await fetch(`/api/ds/${sourceId}`, {
method: 'POST',
body: JSON.stringify(params ?? {}),
signal, // 透传,SDK abort 时自动取消请求
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
// 可选:WebSocket 推送
subscribe({ sourceId }, cb) {
const ws = openSocket(sourceId)
ws.onmessage = (e) => cb(JSON.parse(e.data))
return () => ws.close() // SDK 在 unwire / dispose 时调
},
}把 adapter 交给 createDataSourceStore(adapter),再把 store 传给 wirePageBindings({ dataStore }) 即可。多条 binding 引用同一 sourceId(+ 同 params)时,SDK 按 cache key 复用同一次 query 结果,不会重复打后端。
4. 数据如何到达组件
dataSource binding 的 target 是 { kind: 'instanceDataInput', instanceId, key }。SDK 取到数据后,对目标实例调 setDataInput(key, value)。这要求组件源码里把该字段声明为 dataInput: true:
js
export default {
custom: {
chartData: { value: [], dataInput: true }, // ★ 标记可被绑定灌入
},
template: `<MyChart :data="custom.chartData.value" />`,
}SDK 有一道守门:只有声明了 dataInput: true 的 custom 字段才能被 setDataInput 写入。若 target.key 不存在或没标 dataInput: true,SDK emit 'unknown-target-data-input' 并跳过,不会绕过协议直接写普通字段。
写入后走 Vue 响应式,组件的 $watch / 模板自动更新。如果你想在组件里手动取数后走同一通道(而不是配 binding),也可以在组件内自己调 setDataInput,详见 组件间事件 · 案例 5。
5. binding 字段结构速查
一条规范化 binding(NormalizedPageBinding)的核心字段:
| 字段 | 类型 | 含义 |
|---|---|---|
id | string | binding 唯一 id |
label | string? | 宿主 UI 显示名 |
disabled | boolean? | 禁用则不 wire |
source | BindingSource | 数据 / 事件来源 |
target | BindingTarget | 送到哪 |
mapping | BindingMapping? | 缺省 passthrough |
schedule | BindingSchedule? | 缺省无节流 |
errorPolicy | BindingErrorPolicy? | 缺省 log |
BindingSource(取数用 dataSource;其余 kind 见 组件间事件):
| kind | 字段 | 含义 |
|---|---|---|
dataSource | sourceId、watch?: 'change' | 数据源取数;watch: 'change' 启用持续订阅 |
instanceEvent | instanceId、event | 某实例发了某事件 |
lifecycle | instanceId、hook: 'mounted' | 'unmounted' | 实例挂载 / 卸载 |
page | hook: 'pageInit' | 'pageBeforeUnmount' | 页面级生命周期 |
BindingTarget:
| kind | 字段 | 含义 |
|---|---|---|
instanceDataInput | instanceId、key | 灌进 custom.<key>(取数主用) |
instanceMethod | instanceId、method | 调实例方法 |
instanceEmit | instanceId、event | 往实例发事件 |
BindingMapping —— 数据进 target 前转一道:
| kind | 字段 | 含义 |
|---|---|---|
passthrough | — | 原样透传(缺省) |
static | args: unknown[] | 忽略 payload,固定实参 |
pickPath | paths: { from, to? }[] | 按路径取字段重组 |
BindingSchedule —— 三字段可任意组合:
| 字段 | 类型 | 含义 |
|---|---|---|
once | boolean? | 只触发一次 |
throttle | { windowMs, leading?, trailing? }? | 节流窗口 |
debounce | { windowMs, leading?, trailing? }? | 防抖窗口 |
BindingErrorPolicy:
| 字段 | 取值 | 含义 |
|---|---|---|
onError | 'continue' | 'stop' | 'log' | 出错后继续 / 停掉该 binding / 仅记录(缺省 log) |
完整类型定义见 Canvas Types。
6. 校验与规范化
宿主写入 binding 前,可以用两个纯函数:
normalizePageBindings(...)—— 把宽松输入(含老形态)规范化为NormalizedPageBinding。validateBindings(...)—— 静态校验,返回BindingIssue[](如未知事件、未声明的 dataInput、孤儿 binding、跨画布不支持等),供宿主 UI 提示。
两者都不取数、不依赖运行时,适合在保存 / 加载文档时跑一遍。
7. 设计期 vs 运行期
binding 是否真触发由 CanvasBindingMode 控制,通过 handle.setMode(...) 切:
| mode | 行为 |
|---|---|
'off' | 完全不 wire,宿主行为不变 |
'data-preview' | 只 wire dataSource binding(取数预览),不接 event / lifecycle |
'runtime' | 全量 wire:event + lifecycle + page + data |
会话级模式(CanvasMode:design / preview / runtime / inspect)到 binding 模式有一份默认映射,用 bindingModeForCanvasMode(canvasMode) 取:
ts
import { bindingModeForCanvasMode } from '@hy-bricks/canvas'
bindingModeForCanvasMode('design') // → 'off'(设计期默认不取数,防误触)
bindingModeForCanvasMode('preview') // → 'runtime'
bindingModeForCanvasMode('runtime') // → 'runtime'即:设计期默认不取数,预览 / 运行期才真打后端。宿主可以用 setMode 显式覆盖(比如在设计器里临时切到 'data-preview' 看真数据)。切模式会保留已取到的 cache,要强制重取走 dataStore.invalidate(sourceId)。
相关
- 属性面板暴露机制 —— binding 字段如何在属性面板里配
- 组件间事件 —— event / lifecycle binding 与
setDataInput取数案例 - Canvas Types —— 完整类型定义
- CanvasHandle —— mount-ready 信号 /
getCachedBySourceId等