Skip to content

数据绑定运行时

把「数据源(取数)」接到「组件实例的数据字段」,运行时由 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',不会自动取数。挂载顺序:

  1. wirePageBindings(options) 拿 handle;
  2. 把 handle 接进宿主(Designer 走 delegate,Renderer 直接持有);
  3. 等订阅挂好后,显式 handle.setMode(initialMode) 才开始 wire / 取数;
  4. 页面销毁时(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'
  • signalAbortSignal,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)的核心字段:

字段类型含义
idstringbinding 唯一 id
labelstring?宿主 UI 显示名
disabledboolean?禁用则不 wire
sourceBindingSource数据 / 事件来源
targetBindingTarget送到哪
mappingBindingMapping?缺省 passthrough
scheduleBindingSchedule?缺省无节流
errorPolicyBindingErrorPolicy?缺省 log

BindingSource(取数用 dataSource;其余 kind 见 组件间事件):

kind字段含义
dataSourcesourceIdwatch?: 'change'数据源取数;watch: 'change' 启用持续订阅
instanceEventinstanceIdevent某实例发了某事件
lifecycleinstanceIdhook: 'mounted' | 'unmounted'实例挂载 / 卸载
pagehook: 'pageInit' | 'pageBeforeUnmount'页面级生命周期

BindingTarget:

kind字段含义
instanceDataInputinstanceIdkey灌进 custom.<key>(取数主用)
instanceMethodinstanceIdmethod调实例方法
instanceEmitinstanceIdevent往实例发事件

BindingMapping —— 数据进 target 前转一道:

kind字段含义
passthrough原样透传(缺省)
staticargs: unknown[]忽略 payload,固定实参
pickPathpaths: { from, to? }[]按路径取字段重组

BindingSchedule —— 三字段可任意组合:

字段类型含义
onceboolean?只触发一次
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)


相关