Skip to content

应用壳与生成器通信

这篇文档只讲一件事:应用壳(@atomm-developer/generator-workbench)和生成器之间到底怎么通信。

如果你只想看应用壳总览,请先读 应用壳功能与配置参考。如果你想看原始协议面,请读 生成器接入协议。这篇文档位于两者之间,专门解释真实的通信模型。

这篇文档解决什么问题

当你有下面这些问题时,直接看这篇:

  • 生成器怎么知道当前页面是不是 ?mode=embed
  • 应用壳会把哪些参数传给生成器?
  • 生成器可以给应用壳发哪些事件?
  • 应用壳又能给生成器发哪些命令?
  • embed 模式下,生成器事件和 iframe bridge 转发之间是什么关系?

通信方向总览

当前官方推荐分成两个方向:

方向入口用途
生成器 → 应用壳runtime.subscribe(listener)生成器上报业务事件,例如 state-changeparams_changeselect_template
应用壳 → 生成器runtime.dispatchWorkbenchCommand?(command)生成器接收应用壳发回来的壳层或宿主命令

在应用壳内部,这两个方向都统一由一套公开通信层管理:

  • RuntimeEventRegistry
  • RuntimeEventChannel

完整事件总表

在看细节之前,先明确这几个概念:

  • mount({ mode, routeMode, target, state, panelFilter }) 属于参数透传,不属于事件
  • runtime.subscribe(listener) 是标准的 生成器 -> 应用壳 事件通道
  • workbench.dispatchRuntimeCommand(...) / runtime.dispatchWorkbenchCommand(...) 是标准的 应用壳 -> 生成器 命令通道

生成器 → 应用壳

事件方向载荷当前应用壳内置处理
ready生成器 → 应用壳当前保留给生成器生命周期信号,应用壳暂无内置壳层动作
state-change生成器 → 应用壳{ state, patch?, source? }在开启 auto-save 时用于触发壳层自动保存编排
params_change生成器 → 应用壳{ data: { field, value, params? } }会派发 runtime-params-change DOM 自定义事件
panel-schema-change生成器 → 应用壳{ schema }当前预留,应用壳暂无内置壳层动作
warning生成器 → 应用壳{ code, message }当前预留,应用壳暂无内置壳层动作
error生成器 → 应用壳{ code, message }当前预留,应用壳暂无内置壳层动作
select_template生成器 → 应用壳{ data: { name, category? } }会派发 runtime-select-template;在 ?mode=embed 下继续转发为 generator_toSelectTemplate

应用壳 → 生成器

命令通道方向载荷当前内置命令名
runtime.dispatchWorkbenchCommand?(command)应用壳 → 生成器{ type: string, data?: unknown }当前还没有保留的内置命令名;命令名由宿主自行定义

当前文档中出现过的示例命令:

示例命令方向载荷用途
open-login应用壳 → 生成器{ source: 'topbar' }只是示例,表示壳层或宿主可以要求生成器打开登录相关面板

1. 应用壳传给生成器的参数

挂载参数

每次生成器挂载仍然从下面这组参数开始:

ts
await runtime.mount({
  mode: 'full' | 'embed',
  routeMode: 'full' | 'embed',
  target: 'full' | 'canvas' | 'panel',
  container,
  state,
  panelFilter,
})

这里最关键的是区分:

  • mode:本次生成器以什么挂载形态进入当前宿主容器
  • routeMode:当前页面路由能力模式到底是什么

为什么要有 routeMode

这个参数在 shell 模式下最重要。

例如:

ts
await runtime.mount({
  mode: 'full',
  routeMode: 'embed',
  target: 'full',
  container,
})

这表示:

  • 生成器仍然是以完整 workspace 方式挂载
  • 但页面本身跑在 ?mode=embed
  • 因此生成器可以基于页面能力模式做分支,而不用改变原有挂载路径

生成器推荐读取方式

ts
async function mount(options: {
  mode: 'full' | 'embed'
  routeMode?: 'full' | 'embed'
  target?: 'full' | 'canvas' | 'panel'
  container: HTMLElement
}) {
  const routeMode = options.routeMode ?? 'full'

  if (routeMode === 'embed') {
    disableShellDependentUi()
  }

  renderApp(options)
}

2. 生成器传给应用壳的事件

生成器往外发事件的标准入口是:

ts
const unsubscribe = runtime.subscribe((event) => {
  // 应用壳在这里监听
})

注意:下面这些生成器事件都依赖 runtime.subscribe(listener) 已实现;如果生成器没有实现这个入口,应用壳不会收到事件。

当前文档化的生成器事件形状包括:

ts
type RuntimeEvent =
  | { type: 'ready' }
  | { type: 'state-change'; state: Record<string, unknown>; patch?: Record<string, unknown>; source?: string }
  | { type: 'params_change'; data: { field: string; value: unknown; params?: Record<string, unknown> } }
  | { type: 'panel-schema-change'; schema: PanelSchema }
  | { type: 'select_template'; data: { name: string; category?: string } }
  | { type: 'template_publish_media_change'; data: { generatorImage?: string; cover?: string } }
  | { type: 'warning'; code: string; message: string }
  | { type: 'error'; code: string; message: string }

应用壳当前内置处理了什么

应用壳目前内置处理这些生成器事件:

  • state-change
    • 用于壳层 auto-save 编排
  • params_change
    • 会派发成 DOM 自定义事件 runtime-params-change
  • select_template
    • 会派发成 DOM 自定义事件 runtime-select-template
    • 当路由为 ?mode=embed 时,会继续转发为 generator_toSelectTemplate
  • template_publish_media_change
    • 会覆盖模板发布里的 generatorImageinitialData.cover

生成器如何把特定参数传给应用壳

先区分一个边界:

  • 应用壳 -> 生成器 的初始化透传,官方只有 mount({ mode, routeMode, target, container, state, panelFilter })
  • 生成器 -> 应用壳 没有一个"任意初始化参数自动注入 shell config"的通用直连入口

这意味着:如果你说的"特定参数"是生成器想让应用壳感知的数据,当前推荐分成下面三类。

1. 走应用壳已内建支持的生成器事件

如果参数本身正好对应应用壳已知语义,直接发生成器事件即可:

  • params_change
    • 适合参数栏字段变化通知
    • 应用壳会转成 DOM 事件 runtime-params-change
  • select_template
    • 适合把当前选中的模板卡信息传给应用壳
    • 应用壳会转成 runtime-select-template
  • template_publish_media_change
    • 适合把模板发布时使用的 generatorImage / cover 传给应用壳
    • 应用壳会把它们写入模板发布弹窗参数

也就是说,只有这些已经文档化的生成器事件,应用壳才会内建消费并转成对应壳层行为。

2. 通用业务参数:由宿主自己桥接

如果你要传的是任意业务参数,比如:

  • 生成器生成的标题
  • 生成器解析出的业务模式
  • 生成器启动时计算出的某个开关
  • 生成器自己维护的一组 meta / feature flags

那么当前不推荐假设应用壳会自动读取它们。推荐由宿主自己桥接:

  1. 先创建生成器接入对象
  2. 通过 runtime.getState() 读取初始化快照
  3. 宿主用这些值构造 workbench.config
  4. 再调用 workbench.mount()

示例:

ts
const runtime = createRuntime()
const initialState = runtime.getState()

workbench.runtime = runtime
workbench.config = {
  title:
    typeof initialState.meta?.generatorName === 'string'
      ? initialState.meta.generatorName
      : 'My Generator',
  style:
    typeof initialState.style === 'string'
      ? initialState.style
      : 'modern-minimal',
}

await workbench.mount()

这里的关键点是:

  • 生成器负责拥有业务状态
  • 宿主负责决定哪些生成器数据要映射成应用壳初始化配置
  • 应用壳自己不会在初始化阶段自动扫描生成器任意字段并写回 config
  • 例如要把固定的样式模式传给模板发布弹窗,应由宿主显式桥接成 workbench.config.style

3. 挂载后持续同步:宿主监听生成器或监听应用壳 DOM 事件

如果这些特定参数不是一次性初始化值,而是后续会变化:

  • 对于已内建支持的事件,宿主直接监听应用壳的 DOM 事件即可
  • 对于任意自定义事件,宿主应直接监听 runtime.subscribe(listener),不要依赖应用壳自动转发

换句话说:

  • 已标准化事件:生成器 -> 应用壳 -> DOM event
  • 自定义业务事件:生成器 -> 宿主

当前应用壳没有把所有未知生成器事件自动派发成 DOM 事件。

应用壳初始化时如何拿到生成器设置的特定参数

当前推荐把"初始化时拿参数"理解成两种时机:

时机 1:mount() 之前拿

这是最适合初始化配置的方式。

流程是:

  1. 宿主创建生成器接入对象
  2. 生成器内部完成默认状态准备
  3. 宿主调用 runtime.getState()
  4. 宿主把需要的字段写进 workbench.config
  5. 宿主调用 workbench.mount()

这个时机适合:

  • 标题
  • logo 文案
  • 模式开关
  • 初始化模板元信息
  • 任何需要在应用壳首次 render 前就决定的 shell 配置

时机 2:mount() 之后拿

如果这些参数只有在生成器挂载后才会确定,例如依赖:

  • 容器尺寸
  • 首次异步数据恢复
  • embed 宿主回填
  • 用户在生成器内的首次交互

那么不要把它们当成"初始化前参数"。这时应在 mount() 后通过:

  • runtime.subscribe(...)
  • runtime-params-change
  • runtime-select-template
  • 其他宿主自定义监听逻辑

来继续同步。

不推荐的理解

不要把下面这件事当成当前已有能力:

ts
// 这不是当前应用壳提供的通用机制
await workbench.mount()
// 应用壳自动读取生成器某个自定义字段并改写自己的 config

当前源码和公开协议里,没有这个"生成器任意字段 -> 应用壳 config 自动注入"的通用机制。若确实需要,请由宿主显式桥接,或者在后续演进中单独定义新的标准生成器事件。

params_change 示例

ts
emit({
  type: 'params_change',
  data: {
    field: 'width',
    value: 120,
    params: {
      width: 120,
      height: 128,
    },
    cover: 'data:image/svg+xml;charset=utf-8,...',
  },
})

约定:

  • data.field 必填
  • data.value 必填
  • data.params 选填,可用于携带当前完整参数对象
  • data.cover 选填,可携带生成器当前缩略图;应用壳会缓存最新一个非空值,并在后续 cloud save 未显式传 cover 时回填给 sdk.cloud.save(...)

推荐触发方式:

  1. 生成器先更新自己的内部状态,例如先把 params.widthparams.height 写回 state
  2. 生成器完成当前预览区重渲染,确保缩略图和最新参数一致
  3. 再触发 params_change
  4. 如果当前已经能拿到最新预览图,就把它作为 data.cover 一起带上

推荐约束:

  • params_change 只用于"参数值变化",不要把纯 UI 临时态(如 hover、选中态、滚动位置)也当成参数变化频繁发送
  • data.cover 建议传当前预览图对应的 data URL 或可直接访问的图片 URL
  • 如果这次变化暂时拿不到新缩略图,建议直接省略 cover 字段,不要传空字符串;应用壳只会缓存最新一个非空值
  • 如果预览图依赖异步渲染或下一帧绘制,应该在缩略图准备好后再发事件,避免 params 已更新但 cover 还是旧图

一个更完整的生成器写法可以参考:

ts
function resolvePreviewCover(container: HTMLElement): string | undefined {
  const preview = container.querySelector('[aria-label="Pendant preview"]')

  if (preview instanceof SVGElement) {
    return svgMarkupToDataUrl(preview.outerHTML)
  }

  if (preview instanceof HTMLImageElement && preview.src) {
    return preview.src
  }

  return undefined
}

async function patchState(
  patch: Record<string, unknown>,
  options?: { source?: string },
) {
  Object.entries(patch).forEach(([path, value]) => {
    setByPath(state, path, value)
  })

  rerender()

  Object.entries(patch).forEach(([path, value]) => {
    if (!path.startsWith('params.')) {
      return
    }

    emit({
      type: 'params_change',
      data: {
        field: path.replace(/^params\./, ''),
        value,
        params: cloneParams(state),
        cover: resolvePreviewCover(container),
      },
    })
  })
}

如果你的预览图不是 DOM / SVG,而是 Canvas 或渲染引擎输出,原则也一样:

  • 先让生成器内部状态与预览结果对齐
  • 再导出当前缩略图
  • 再把该缩略图放进 data.cover

例如:

ts
emit({
  type: 'params_change',
  data: {
    field: 'width',
    value: nextWidth,
    params: cloneParams(state),
    cover: canvas.toDataURL('image/png'),
  },
})

Pendant preview 示例

generator-workbench/src/examples/basic-app.ts 里有一条更完整的参考链路,适合用来理解 cover 应该在什么时候采集、什么时候发送给应用壳。

这个示例并不是让应用壳自己监听 aria-label="Pendant preview" 的 DOM 变化,而是:

  1. 生成器在 patchState() 里先更新内部 state
  2. 立刻调用 rerender(...),把最新参数对应的预览节点重新渲染出来
  3. 生成器再主动查找 aria-label="Pendant preview" 对应的节点
  4. 把当前节点内容转成 data URL
  5. 最后通过 params_change.data.cover 发给应用壳

示例中的 cover 提取逻辑:

ts
function resolvePendantPreviewCover(
  mounts: Set<MountRecord>,
  state: ExampleState,
): string {
  for (const mount of Array.from(mounts).reverse()) {
    const previewElement = mount.container.querySelector('[aria-label="Pendant preview"]')
    if (previewElement instanceof SVGElement) {
      return svgMarkupToDataUrl(previewElement.outerHTML)
    }
    if (previewElement instanceof HTMLImageElement && previewElement.src) {
      return previewElement.src
    }
  }

  return svgMarkupToDataUrl(
    buildPendantPreviewSvg(state.params.width, state.params.height),
  )
}

对应的事件发送逻辑:

ts
async patchState(patch, options) {
  Object.entries(patch).forEach(([path, value]) => {
    setByPath(state as unknown as Record<string, unknown>, path, value)
  })

  rerender(mounts, state, runtime, applyTemplatePreset)

  Object.entries(patch).forEach(([path, value]) => {
    if (!path.startsWith('params.')) {
      return
    }

    emit({
      type: 'params_change',
      data: {
        field: path.replace(/^params\./, ''),
        value,
        params: cloneParams(state),
        cover: resolvePendantPreviewCover(mounts, state),
      },
    })
  })
}

这个示例说明了 3 个关键点:

  • cover 应该由生成器主动采集,而不是依赖应用壳反向读取生成器内部 DOM
  • cover 的采集时机应该在参数变更并完成重渲染之后,否则可能拿到旧图
  • 只要生成器能稳定拿到"当前预览图",不管来源是 SVG、img、Canvas,最终都可以统一放进 params_change.data.cover

如果你的生成器里也有一个类似的预览节点,例如:

html
<div aria-label="Pendant preview">
  <!-- 当前参数对应的 SVG / img / canvas 预览 -->
</div>

那么推荐直接复用这个模式:

  • 先更新参数
  • 再重渲染预览
  • 再读取该节点导出当前缩略图
  • 再发送 params_change

这样应用壳收到的 cover 才能和当前参数保持一致。

select_template 示例

ts
function onTemplateCardClick(template: { name: string; category?: string }) {
  emit({
    type: 'select_template',
    data: {
      name: template.name,
      category: template.category,
    },
  })
}

约定:

  • data.name 必填
  • data.category 选填
  • 第一次打开 Publish as Template 时会使用最新一次 data.name;之后在同一个应用壳实例内再次打开时,会继续复用第一次打开时缓存下来的 generatorTag

template_publish_media_change 示例

ts
emit({
  type: 'template_publish_media_change',
  data: {
    generatorImage: 'https://cdn.example.com/runtime-generator.png',
    cover: 'data:image/png;base64,custom-cover',
  },
})

约定:

  • data.generatorImage 选填,用来覆盖模板发布参数里的 generatorImage
  • data.cover 选填,用来覆盖模板发布参数里的 initialData.cover
  • 两个字段至少传一个
  • 第一次打开发布弹窗前,最新一次 data.generatorImage 会参与生成模板发布参数;如果第一次打开后该图片被上传到 OSS,后续再次打开时会直接复用缓存的 OSS URL,而不是重新上传原始 base64

应用壳暴露出来的 DOM 事件

当生成器发出 select_template 时,应用壳也会派发:

ts
workbench.addEventListener('runtime-select-template', (event) => {
  console.log(event.detail.name)
})

当生成器发出 params_change 时,应用壳也会派发:

ts
workbench.addEventListener('runtime-params-change', (event) => {
  console.log(event.detail.field, event.detail.value)
})

3. 应用壳传给生成器的命令

反方向通信推荐使用这个生成器接入对象入口:

ts
runtime.dispatchWorkbenchCommand = async (command) => {
  if (command.type === 'open-login') {
    openLoginPanel(command.data)
  }
}

命令形状:

ts
interface WorkbenchCommand {
  type: string
  data?: unknown
}

宿主侧调用方式

应用壳暴露给宿主的方法是:

ts
await workbench.dispatchRuntimeCommand({
  type: 'open-login',
  data: {
    source: 'topbar',
  },
})

命名说明:

  • workbench.dispatchRuntimeCommand(...) 是宿主看到的入口
  • 内部会转发到 RuntimeEventChannel.dispatchWorkbenchCommand(...)
  • 生成器最终通过 runtime.dispatchWorkbenchCommand(...) 接收

它们本质上说的是同一个方向:应用壳 -> 生成器

4. 通信层内部结构

应用壳现在通过一套独立公开通信层管理生成器通信:

  • RuntimeEventRegistry
    • 注册生成器事件处理器
    • 注册应用壳命令处理器
  • RuntimeEventChannel
    • 连接 runtime.subscribe(...)
    • 按事件类型路由生成器事件
    • 把应用壳命令回传给生成器

这样可以避免 GeneratorWorkbenchElement 继续演变成一个越来越大的事件分发中心。

5. Embed 路由和 Bridge 转发的关系

当页面路由为 ?mode=embed 时,通信会继续向父页面 bridge 延伸。

生成器 → 应用壳 → 父页面

当前内置 bridge 转发关系:

生成器事件应用壳行为父页面 bridge 事件
select_template派发 DOM 事件generator_toSelectTemplate

应用壳 → 生成器 和 Bridge 是两层事

workbench.dispatchRuntimeCommand(...) 属于本地壳层到生成器的通信,不会自动变成父页面 bridge 事件。

如果主站宿主需要跨 iframe 通信,请继续使用 主站 Embed 宿主接入 里定义的 bridge 协议。

6. 推荐接入模式

默认建议按下面方式拆分:

  1. 生成器通过 mount(...) 接收 routeMode
  2. 生成器通过 subscribe(listener) 上报业务事件
  3. 应用壳负责壳层编排
  4. 应用壳通过 dispatchRuntimeCommand(...) 发回反向命令
  5. embed 专属的父页面通信继续放在 iframe bridge 层

这样职责会比较清晰:

  • 生成器负责业务渲染和业务事件
  • 应用壳负责壳层编排
  • bridge 负责跨窗口通信

7. 错误处理

当前行为如下:

  • 生成器事件处理器抛错时,会通过 config.onError / workbench-error 上报
  • 应用壳命令处理器抛错时,也会通过 config.onError / workbench-error 上报
  • dispatchRuntimeCommand() 当前表示"完成了一次派发尝试",不会因为 handler 抛错而 reject

如果你的宿主需要严格的成功/失败语义,建议在 runtime.dispatchWorkbenchCommand(...) 里自行增加命令级 ack 协议。

相关文档

MIT Licensed