应用壳与生成器通信
这篇文档只讲一件事:应用壳(@atomm-developer/generator-workbench)和生成器之间到底怎么通信。
如果你只想看应用壳总览,请先读 应用壳功能与配置参考。如果你想看原始协议面,请读 生成器接入协议。这篇文档位于两者之间,专门解释真实的通信模型。
这篇文档解决什么问题
当你有下面这些问题时,直接看这篇:
- 生成器怎么知道当前页面是不是
?mode=embed? - 应用壳会把哪些参数传给生成器?
- 生成器可以给应用壳发哪些事件?
- 应用壳又能给生成器发哪些命令?
- embed 模式下,生成器事件和 iframe bridge 转发之间是什么关系?
通信方向总览
当前官方推荐分成两个方向:
| 方向 | 入口 | 用途 |
|---|---|---|
生成器 → 应用壳 | runtime.subscribe(listener) | 生成器上报业务事件,例如 state-change、params_change、select_template |
应用壳 → 生成器 | runtime.dispatchWorkbenchCommand?(command) | 生成器接收应用壳发回来的壳层或宿主命令 |
在应用壳内部,这两个方向都统一由一套公开通信层管理:
RuntimeEventRegistryRuntimeEventChannel
完整事件总表
在看细节之前,先明确这几个概念:
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. 应用壳传给生成器的参数
挂载参数
每次生成器挂载仍然从下面这组参数开始:
await runtime.mount({
mode: 'full' | 'embed',
routeMode: 'full' | 'embed',
target: 'full' | 'canvas' | 'panel',
container,
state,
panelFilter,
})这里最关键的是区分:
mode:本次生成器以什么挂载形态进入当前宿主容器routeMode:当前页面路由能力模式到底是什么
为什么要有 routeMode
这个参数在 shell 模式下最重要。
例如:
await runtime.mount({
mode: 'full',
routeMode: 'embed',
target: 'full',
container,
})这表示:
- 生成器仍然是以完整 workspace 方式挂载
- 但页面本身跑在
?mode=embed - 因此生成器可以基于页面能力模式做分支,而不用改变原有挂载路径
生成器推荐读取方式
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. 生成器传给应用壳的事件
生成器往外发事件的标准入口是:
const unsubscribe = runtime.subscribe((event) => {
// 应用壳在这里监听
})注意:下面这些生成器事件都依赖 runtime.subscribe(listener) 已实现;如果生成器没有实现这个入口,应用壳不会收到事件。
当前文档化的生成器事件形状包括:
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
- 会派发成 DOM 自定义事件
select_template- 会派发成 DOM 自定义事件
runtime-select-template - 当路由为
?mode=embed时,会继续转发为generator_toSelectTemplate
- 会派发成 DOM 自定义事件
template_publish_media_change- 会覆盖模板发布里的
generatorImage和initialData.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
那么当前不推荐假设应用壳会自动读取它们。推荐由宿主自己桥接:
- 先创建生成器接入对象
- 通过
runtime.getState()读取初始化快照 - 宿主用这些值构造
workbench.config - 再调用
workbench.mount()
示例:
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() 之前拿
这是最适合初始化配置的方式。
流程是:
- 宿主创建生成器接入对象
- 生成器内部完成默认状态准备
- 宿主调用
runtime.getState() - 宿主把需要的字段写进
workbench.config - 宿主调用
workbench.mount()
这个时机适合:
- 标题
- logo 文案
- 模式开关
- 初始化模板元信息
- 任何需要在应用壳首次 render 前就决定的 shell 配置
时机 2:mount() 之后拿
如果这些参数只有在生成器挂载后才会确定,例如依赖:
- 容器尺寸
- 首次异步数据恢复
- embed 宿主回填
- 用户在生成器内的首次交互
那么不要把它们当成"初始化前参数"。这时应在 mount() 后通过:
runtime.subscribe(...)runtime-params-changeruntime-select-template- 其他宿主自定义监听逻辑
来继续同步。
不推荐的理解
不要把下面这件事当成当前已有能力:
// 这不是当前应用壳提供的通用机制
await workbench.mount()
// 应用壳自动读取生成器某个自定义字段并改写自己的 config当前源码和公开协议里,没有这个"生成器任意字段 -> 应用壳 config 自动注入"的通用机制。若确实需要,请由宿主显式桥接,或者在后续演进中单独定义新的标准生成器事件。
params_change 示例
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(...)
推荐触发方式:
- 生成器先更新自己的内部状态,例如先把
params.width、params.height写回 state - 生成器完成当前预览区重渲染,确保缩略图和最新参数一致
- 再触发
params_change - 如果当前已经能拿到最新预览图,就把它作为
data.cover一起带上
推荐约束:
params_change只用于"参数值变化",不要把纯 UI 临时态(如 hover、选中态、滚动位置)也当成参数变化频繁发送data.cover建议传当前预览图对应的data URL或可直接访问的图片 URL- 如果这次变化暂时拿不到新缩略图,建议直接省略
cover字段,不要传空字符串;应用壳只会缓存最新一个非空值 - 如果预览图依赖异步渲染或下一帧绘制,应该在缩略图准备好后再发事件,避免
params已更新但cover还是旧图
一个更完整的生成器写法可以参考:
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
例如:
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 变化,而是:
- 生成器在
patchState()里先更新内部 state - 立刻调用
rerender(...),把最新参数对应的预览节点重新渲染出来 - 生成器再主动查找
aria-label="Pendant preview"对应的节点 - 把当前节点内容转成
data URL - 最后通过
params_change.data.cover发给应用壳
示例中的 cover 提取逻辑:
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),
)
}对应的事件发送逻辑:
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应该由生成器主动采集,而不是依赖应用壳反向读取生成器内部 DOMcover的采集时机应该在参数变更并完成重渲染之后,否则可能拿到旧图- 只要生成器能稳定拿到"当前预览图",不管来源是 SVG、
img、Canvas,最终都可以统一放进params_change.data.cover
如果你的生成器里也有一个类似的预览节点,例如:
<div aria-label="Pendant preview">
<!-- 当前参数对应的 SVG / img / canvas 预览 -->
</div>那么推荐直接复用这个模式:
- 先更新参数
- 再重渲染预览
- 再读取该节点导出当前缩略图
- 再发送
params_change
这样应用壳收到的 cover 才能和当前参数保持一致。
select_template 示例
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 示例
emit({
type: 'template_publish_media_change',
data: {
generatorImage: 'https://cdn.example.com/runtime-generator.png',
cover: 'data:image/png;base64,custom-cover',
},
})约定:
data.generatorImage选填,用来覆盖模板发布参数里的generatorImagedata.cover选填,用来覆盖模板发布参数里的initialData.cover- 两个字段至少传一个
- 第一次打开发布弹窗前,最新一次
data.generatorImage会参与生成模板发布参数;如果第一次打开后该图片被上传到 OSS,后续再次打开时会直接复用缓存的 OSS URL,而不是重新上传原始 base64
应用壳暴露出来的 DOM 事件
当生成器发出 select_template 时,应用壳也会派发:
workbench.addEventListener('runtime-select-template', (event) => {
console.log(event.detail.name)
})当生成器发出 params_change 时,应用壳也会派发:
workbench.addEventListener('runtime-params-change', (event) => {
console.log(event.detail.field, event.detail.value)
})3. 应用壳传给生成器的命令
反方向通信推荐使用这个生成器接入对象入口:
runtime.dispatchWorkbenchCommand = async (command) => {
if (command.type === 'open-login') {
openLoginPanel(command.data)
}
}命令形状:
interface WorkbenchCommand {
type: string
data?: unknown
}宿主侧调用方式
应用壳暴露给宿主的方法是:
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. 推荐接入模式
默认建议按下面方式拆分:
- 生成器通过
mount(...)接收routeMode - 生成器通过
subscribe(listener)上报业务事件 - 应用壳负责壳层编排
- 应用壳通过
dispatchRuntimeCommand(...)发回反向命令 - embed 专属的父页面通信继续放在 iframe bridge 层
这样职责会比较清晰:
- 生成器负责业务渲染和业务事件
- 应用壳负责壳层编排
- bridge 负责跨窗口通信
7. 错误处理
当前行为如下:
- 生成器事件处理器抛错时,会通过
config.onError/workbench-error上报 - 应用壳命令处理器抛错时,也会通过
config.onError/workbench-error上报 dispatchRuntimeCommand()当前表示"完成了一次派发尝试",不会因为 handler 抛错而 reject
如果你的宿主需要严格的成功/失败语义,建议在 runtime.dispatchWorkbenchCommand(...) 里自行增加命令级 ack 协议。