React 接入 generator-workbench
这篇文档说明 React 生成器如何按推荐方式接入官方壳层 generator-workbench。
对于新的标准生成器,推荐架构是:
generator-sdk提供登录、导出、云存档、历史、计费、模板等平台能力- 你的 React 代码实现 generator runtime
generator-workbench在 runtime 外层提供统一官方壳层
React 实际接入的是什么
React 不需要一套单独的 React 版 workbench 壳层。
在这个仓库里的推荐模型是:
- React 实现 runtime
- 宿主页渲染一个
<generator-workbench>custom element - 宿主把
sdk、runtime、config赋给这个元素 generator-workbench把 runtime 挂载到正确的 DOM 容器里
适用场景
当满足以下条件时,优先使用这篇文档:
- 你的生成器是 React 技术栈
- 你想复用官方壳层
- 你希望登录、积分、导出、模板、云存档、历史这些 UI 由 workbench 接管
- 你希望项目收敛到标准 runtime 模型
以下场景不应把这篇文档作为主路径:
- 你明确需要完全自定义壳层
- 你只想在现有 React 页面里调用少量 SDK API
- 你不打算暴露
mount()、getState()、getPanelSchema()等 runtime 接口
架构分工
推荐职责划分如下:
- React runtime 负责:
- 业务状态
- 画布渲染
- 参数渲染
- runtime 导出数据
PanelSchema
generator-workbench负责:- 顶栏
- 登录 / 头像 / 退出入口
- 积分 / 计费壳层 UI
- 模板导入 / 发布入口
- 云存档 / 历史壳层入口
generator-sdk负责:- 鉴权
- 导出动作
- 云端接口
- 历史接口
- 计费与模板 helper
推荐目录结构
src/
sdk.js
main.jsx
workbench-host.jsx
runtime/
state.js
panel-schema.js
runtime.js第 1 步:创建 React 项目
推荐使用 Vite:
npm create vite@latest或者:
pnpm create vite@latest建议选择:
- Framework:
React - Variant:
JavaScript
然后执行:
cd your-project-name
npm install第 2 步:安装 SDK 和 Workbench
npm install @atomm-developer/generator-sdk @atomm-developer/generator-workbenchgenerator-workbench 是一个 custom element 壳层。你的 React 应用负责承载这个元素,而不是自己重写一套壳层。
关于 atomm-ui 和 atomm-pro
generator-workbench 内部依赖 atomm-ui,并在部分流程里按需使用 atomm-pro。
这并不意味着你的 runtime 必须改写成 Vue。
更准确的边界是:
- 壳层使用这些依赖去渲染 shell-owned UI
- 你的 React runtime 仍然可以保持完全 React 化
- 如果你的业务区也想复用同一套视觉体系,那是另一个单独决策,不要把壳层依赖自动等同成 runtime 依赖
第 3 步:只初始化一次 SDK
创建 src/sdk.js:
import { GeneratorSDK } from '@atomm-developer/generator-sdk'
export const sdk = GeneratorSDK.init({
appKey: import.meta.env.VITE_ATOMM_APP_KEY,
env: import.meta.env.MODE === 'production' ? 'prod' : 'dev',
})创建 .env.local:
VITE_ATOMM_APP_KEY=your_app_key不要把 SDK 实例放进 React 组件状态中。它应该被视为单例服务。
第 4 步:定义可序列化的 Runtime State
创建 src/runtime/state.js:
export function createInitialState() {
return {
meta: {
schemaVersion: '1.0.0',
generatorId: 'react-pendant',
title: 'React Pendant Generator',
},
document: {
width: 520,
height: 360,
unit: 'px',
},
params: {
text: 'generator',
width: 96,
height: 128,
accentColor: '#111111',
},
}
}
export function cloneState(value) {
return JSON.parse(JSON.stringify(value))
}
export function setByPath(target, path, value) {
const keys = path.split('.')
let current = target
for (let index = 0; index < keys.length - 1; index += 1) {
const key = keys[index]
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key]
}
current[keys[keys.length - 1]] = value
}runtime state 最好保持 JSON 可序列化,因为后续宿主可能会拿它做恢复、云存档、模板导入导出。
第 5 步:定义 PanelSchema
创建 src/runtime/panel-schema.js:
export function getPanelSchema() {
return {
version: '1.0.0',
generatorId: 'react-pendant',
groups: [
{
id: 'content',
title: '内容',
fields: [
{
id: 'text',
label: '文字',
type: 'text',
bind: { path: 'params.text' },
},
],
},
{
id: 'size',
title: '尺寸',
fields: [
{
id: 'width',
label: '宽度',
type: 'number',
bind: { path: 'params.width' },
},
{
id: 'height',
label: '高度',
type: 'number',
bind: { path: 'params.height' },
},
],
},
],
}
}
export function applyPanelFilter(schema, panelFilter) {
if (!panelFilter) {
return schema
}
const includeGroups = panelFilter.includeGroups || null
const excludeGroups = new Set(panelFilter.excludeGroups || [])
const includeFields = panelFilter.includeFields || null
const excludeFields = new Set(panelFilter.excludeFields || [])
const readonlyFields = new Set(panelFilter.readonlyFields || [])
const groups = schema.groups
.filter((group) => !excludeGroups.has(group.id))
.filter((group) => !includeGroups || includeGroups.includes(group.id))
.map((group) => ({
...group,
fields: group.fields
.filter((field) => !excludeFields.has(field.id) && !excludeFields.has(field.bind.path))
.filter((field) => !includeFields || includeFields.includes(field.id) || includeFields.includes(field.bind.path))
.map((field) => ({
...field,
readonly:
field.readonly ||
readonlyFields.has(field.id) ||
readonlyFields.has(field.bind.path),
})),
}))
.filter((group) => group.fields.length > 0)
return {
...schema,
groups,
}
}这一步非常关键。模板宿主和 generator-workbench 的模板流程消费的是统一 schema,而不是你私有的一套表单实现。
第 6 步:把 React UI 封装成 Runtime
创建 src/runtime/runtime.js:
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInitialState, cloneState, setByPath } from './state'
import { getPanelSchema, applyPanelFilter } from './panel-schema'
function CanvasView({ state }) {
return (
<div style={{ display: 'grid', placeItems: 'center', minHeight: '100%' }}>
<div
style={{
width: 520,
height: 360,
borderRadius: 20,
border: '1px solid #dbe3f0',
background: '#fff',
display: 'grid',
placeItems: 'center',
}}
>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, color: state.params.accentColor }}>
{state.params.text}
</div>
<div style={{ marginTop: 8, color: '#64748b' }}>
{state.params.width} x {state.params.height} mm
</div>
</div>
</div>
</div>
)
}
function PanelView({ schema, state, onFieldChange }) {
return (
<div style={{ padding: 16 }}>
{schema.groups.map((group) => (
<section key={group.id} style={{ marginBottom: 16 }}>
<h3>{group.title}</h3>
{group.fields.map((field) => (
<label
key={field.id}
style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}
>
<span>{field.label}</span>
<input
disabled={field.readonly}
type={field.type === 'number' ? 'number' : 'text'}
value={String(resolveFieldValue(state, field.bind.path) ?? '')}
onChange={(event) => {
const nextValue =
field.type === 'number'
? Number(event.target.value)
: event.target.value
onFieldChange(field.bind.path, nextValue)
}}
/>
</label>
))}
</section>
))}
</div>
)
}
function FullWorkspace(props) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '320px minmax(0, 1fr)',
minHeight: '100%',
}}
>
<aside style={{ borderRight: '1px solid #e5e7eb', background: '#fff' }}>
<PanelView {...props} />
</aside>
<main style={{ padding: 24, background: '#f5f7fb' }}>
<CanvasView state={props.state} />
</main>
</div>
)
}
function resolveFieldValue(state, path) {
return path.split('.').reduce((current, key) => current?.[key], state)
}
export function createGeneratorRuntime() {
let state = createInitialState()
const listeners = new Set()
const mounts = new Map()
function emit(event) {
listeners.forEach((listener) => listener(event))
}
function getState() {
return cloneState(state)
}
function renderMount(record) {
const schema = applyPanelFilter(getPanelSchema(), record.panelFilter)
const onFieldChange = (path, value) => {
patchState(
{ [path]: value },
{
source: 'user',
},
)
emit({
type: 'params_change',
data: {
field: path.replace(/^params\./, ''),
value,
params: cloneState(state.params),
},
})
}
if (record.mode === 'embed' && record.target === 'canvas') {
record.root.render(<CanvasView state={state} />)
return
}
if (record.mode === 'embed' && record.target === 'panel') {
record.root.render(
<PanelView
schema={schema}
state={state}
onFieldChange={onFieldChange}
/>,
)
return
}
record.root.render(
<FullWorkspace
schema={schema}
state={state}
onFieldChange={onFieldChange}
/>,
)
}
function rerender() {
mounts.forEach((record) => renderMount(record))
}
function setState(nextState, options = {}) {
state = cloneState(nextState)
rerender()
if (!options.silent) {
emit({
type: 'state-change',
state: getState(),
source: options.source || 'host',
})
}
}
function patchState(patch, options = {}) {
Object.entries(patch || {}).forEach(([path, value]) => {
setByPath(state, path, value)
})
rerender()
if (!options.silent) {
emit({
type: 'state-change',
state: getState(),
patch,
source: options.source || 'host',
})
}
}
return {
version: '1.0.0',
generatorId: 'react-pendant',
capabilities: {
modes: ['full', 'embed'],
mountTargets: ['full', 'canvas', 'panel'],
supportsPatchState: true,
supportsReadonly: true,
supportsPanelFilter: true,
supportsPartialPanel: true,
exports: ['cover', 'download-svg', 'open-in-studio'],
},
mount({
mode,
target = 'full',
container,
state: incomingState,
panelFilter,
}) {
if (incomingState) {
state = cloneState(incomingState)
}
const existing = mounts.get(container)
const root = existing?.root || createRoot(container)
const record = {
root,
mode,
target,
container,
panelFilter,
}
mounts.set(container, record)
renderMount(record)
emit({ type: 'ready' })
return {
unmount() {
const current = mounts.get(container)
current?.root.unmount()
mounts.delete(container)
},
}
},
getState,
setState,
patchState,
getPanelSchema({ panelFilter } = {}) {
return applyPanelFilter(getPanelSchema(), panelFilter)
},
async export(action) {
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="360" viewBox="0 0 520 360">
<rect width="520" height="360" rx="20" fill="white" stroke="#dbe3f0" />
<text x="260" y="170" text-anchor="middle" font-size="28" fill="${state.params.accentColor}">
${state.params.text}
</text>
<text x="260" y="210" text-anchor="middle" font-size="14" fill="#64748b">
${action}
</text>
</svg>`
return {
dataUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`,
mimeType: 'image/svg+xml',
ext: 'svg',
width: 520,
height: 360,
}
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}这一步就是核心适配层。
generator-workbench 不是直接挂你的 React 应用,而是挂一个 runtime 对象。React 只是通过这个 runtime 对象被渲染到 workbench 提供的容器中。
第 7 步:在 React 里承载 Workbench Custom Element
创建 src/workbench-host.jsx:
import { useEffect, useRef } from 'react'
import { GeneratorWorkbench } from '@atomm-developer/generator-workbench'
import { sdk } from './sdk'
import { createGeneratorRuntime } from './runtime/runtime'
GeneratorWorkbench.defineGeneratorWorkbench()
export default function WorkbenchHost() {
const elementRef = useRef(null)
const runtimeRef = useRef(null)
if (!runtimeRef.current) {
runtimeRef.current = createGeneratorRuntime()
window.__GENERATOR_RUNTIME__ = runtimeRef.current
}
useEffect(() => {
const element = elementRef.current
if (!element) {
return undefined
}
element.sdk = sdk
element.runtime = runtimeRef.current
element.config = {
title: 'React Pendant Generator',
mode: 'shell',
readyPolicy: 'runtime-ready',
exportEnabled: true,
studioEnabled: true,
templateEnabled: true,
}
let disposed = false
Promise.resolve(element.mount()).catch((error) => {
console.error('Failed to mount workbench', error)
})
return () => {
if (!disposed) {
element.unmount?.()
disposed = true
}
}
}, [])
return <generator-workbench ref={elementRef} />
}然后修改 src/main.jsx:
import React from 'react'
import ReactDOM from 'react-dom/client'
import WorkbenchHost from './workbench-host'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<WorkbenchHost />
</React.StrictMode>,
)这就是 React 侧推荐的宿主方式:
- React 渲染 custom element
- React 通过
ref给元素赋对象属性 - custom element 再去挂 runtime
第 8 步:理解 shell、full 和 embed
大部分 React 生成器建议从下面这类配置开始:
element.config = {
mode: 'shell',
}在 shell 模式下:
- workbench 保留官方顶栏
- runtime 自己决定内部工作区布局
- runtime 通常以完整工作区方式挂载
如果你有模板页或宿主拆分挂载场景,runtime 还必须支持:
runtime.mount({
mode: 'embed',
target: 'canvas',
container,
})
runtime.mount({
mode: 'embed',
target: 'panel',
container,
panelFilter,
})这也是为什么上面的 runtime 适配层要支持:
mode: 'full'mode: 'embed'target: 'full' | 'canvas' | 'panel'
第 9 步:让 Workbench 接管平台壳层 UI
一旦接入 generator-workbench,除非你明确要自定义,否则不要在 React runtime 里再重复造一套壳层能力。
接入后典型的归属应该是:
- 登录按钮:workbench
- 头像 / 退出:workbench
- 积分角标:workbench
- 导出入口:workbench
- 模板导入 / 发布入口:workbench
- 云存档 / 历史入口:workbench
React runtime 应该聚焦在:
- state
- rendering
- schema
- export payload
第 10 步:开启可选壳层能力
如果你要启用云存档和历史:
element.config = {
title: 'React Pendant Generator',
mode: 'shell',
cloudEnabled: true,
historyEnabled: true,
autoSaveEnabled: true,
getCloudSaveOptions: ({ state }) => ({
title: 'Draft',
snapshot: state,
}),
}这些能力要求 runtime 至少提供稳定的 state,最好还实现 subscribe(listener)。
对于 Open in Studio 和下载,runtime 负责 export(action) 返回导出数据,SDK / workbench 决定如何消费这些导出数据。
TypeScript 说明
如果你的 React 项目是 TypeScript,需要给 custom element 补 JSX 类型:
declare namespace JSX {
interface IntrinsicElements {
'generator-workbench': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>
}
}常见错误
- 把
generator-workbench误认为是 runtime 业务逻辑替代品 - 接入后又在 React runtime 里重写登录、积分、模板壳层 UI
- 仍然只保留一个 React 页面,而没有暴露 runtime 对象
- 缺少
getPanelSchema(),却希望模板能力正常工作 - state 不可序列化
- 有模板宿主需求,却只实现了完整页面,没有实现
embed挂载目标
如果你仍然想走原始 SDK 直连路径
这种方式仍然有效,但只适合你明确要自己做壳层的时候。
在那种模式下:
- React 自己渲染整页
- 你直接调用
sdk.auth.login()、sdk.export.openInStudio()等 API - 你不使用
generator-workbench作为壳层宿主
只有当壳层自定义优先级高于标准化接入时,才推荐这样做。
上传资源
- 执行生产构建,比如
npm run build。 - 将生成的
dist目录压缩成 zip 包。 - 确保 zip 根目录包含
index.html。 - 上传到 https://www.atomm.com/generator-upload。
一句话总结
React 的推荐接入方式是:
React 实现 runtime contract,generator-workbench 承载官方壳层,generator-sdk 在底层提供平台能力。