低代码营销平台的编辑器界面看起来很直观:左边是组件库,中间是预览区域,右边是属性编辑面板。用户把组件拖到页面里,点击组件后修改参数,然后发布页面。
但真正实现时,不能把它理解成“拖一个 DOM 到另一个 DOM”。低代码编辑器操作的是一棵组件树,拖拽只是修改这棵树的手段;属性面板操作的是组件配置,表单只是修改配置的 UI;组件通信操作的是页面运行时状态,不能靠组件之间互相直接调用。
所以这类编辑器的核心实现可以拆成三部分:
拖拽引擎:负责把用户操作转换成组件树变更配置协议:负责把组件元信息转换成属性编辑面板通信机制:负责让组件之间通过受控方式产生联动
拖拽引擎操作的是组件树
页面 DSL 通常是一棵树,或者至少是一个顺序列表。拖拽的本质是对这棵树做插入、移动、删除和排序。
例如页面初始状态是:
{ "components": [ { "id": "hero-1", "type": "HeroBanner" }, { "id": "list-1", "type": "CarList" } ]}
当用户从左侧组件库拖入一个轮播组件,并放到头图和车型列表之间,编辑器要生成的不是 DOM 变更,而是 DSL 变更:
{ "components": [ { "id": "hero-1", "type": "HeroBanner" }, { "id": "carousel-1", "type": "Carousel" }, { "id": "list-1", "type": "CarList" } ]}
然后编辑器把新的 DSL 同步给 iframe 预览页,由预览运行时重新渲染。
这套模式有一个好处:编辑器里的每次操作都可以被记录为数据变更。后续要做撤销重做、草稿保存、版本对比、复制组件、跨页面复用,都有基础。
拖拽需要区分拖拽源和落点
拖拽引擎至少要处理两类拖拽:
从组件库拖入新组件在页面内部移动已有组件
这两类操作最终都会修改组件树,但语义不同。
从组件库拖入时,需要根据组件类型创建一个新节点:
function createNodeFromComponent(type: string): ComponentNode { const meta = componentRegistry.get(type); return { id: generateComponentId(type), type, props: meta.defaultProps, dataSource: meta.defaultDataSource, tracking: meta.defaultTracking };}
页面内部移动时,则不能创建新节点,而是移动已有节点的位置:
function moveNode(list, fromIndex, toIndex) { const next = [...list]; const [node] = next.splice(fromIndex, 1); next.splice(toIndex, 0, node); return next;}
这两个动作如果混在一起,会导致很多边界问题。例如拖动已有组件时误创建新组件,或者从组件库拖入时复用了旧组件 ID。
iframe 内的命中计算不能依赖编辑器 DOM
预览区域放在 iframe 中以后,拖拽命中会更复杂。编辑器外层拿不到 iframe 内部组件的直接布局信息,至少不能假设它们在同一个 DOM 坐标系里。
比较稳妥的方式是让 iframe 预览运行时负责组件位置采集:
iframe 渲染组件 -> 每个组件外层包一层编辑态容器 -> 采集组件 id 和 getBoundingClientRect -> 通过 postMessage 同步给编辑器 -> 编辑器根据鼠标位置计算插入位置
预览页中的每个组件可以包一层编辑容器:
function EditableBlock({ node, children }) { return ( <div data-component-id={node.id}> {children} </div> );}
拖拽过程中,iframe 可以实时计算当前鼠标落在哪两个组件之间:
function getDropIndex(pointerY, blocks) { for (let i = 0; i < blocks.length; i += 1) { const rect = blocks[i].rect; const middle = rect.top + rect.height / 2; if (pointerY < middle) { return i; } } return blocks.length;}
然后通过消息告诉编辑器当前落点:
window.parent.postMessage({ type: "DROP_POSITION_CHANGE", payload: { index: dropIndex }});
编辑器收到落点后更新占位线。真正 drop 时,再修改页面 DSL。
属性面板由组件 schema 生成
右侧属性面板不应该为每个组件手写。否则新增一个组件就要改编辑器,平台会很快失控。
更合理的方式是组件自己声明属性 schema:
{ "type": "HeroBanner", "name": "头图组件", "propsSchema": [ { "name": "imageUrl", "label": "头图", "type": "image", "required": true }, { "name": "title", "label": "标题", "type": "text", "defaultValue": "" }, { "name": "jumpUrl", "label": "跳转链接", "type": "url", "required": false } ]}
编辑器根据 schema 渲染表单:
function renderField(field) { switch (field.type) { case "text": return <TextInput field={field} />; case "image": return <ImageUploader field={field} />; case "url": return <UrlInput field={field} />; case "select": return <Select field={field} />; default: return null; }}
这样右侧面板就变成一个通用表单渲染器。组件新增字段时,只需要更新组件包里的 schema,编辑器不需要发布新版本。
配置不只包括基本属性
营销组件的配置通常不只是标题、图片、链接。还会包含异步 API、埋点、默认值、必填项和非必填项。
可以把组件配置拆成三类:
props:影响组件展示的基础属性dataSource:影响组件数据获取的异步配置tracking:影响曝光、点击、转化等埋点配置
例如车型列表组件:
{ "id": "car-list-1", "type": "CarList", "props": { "title": "热门车型", "layout": "two-column" }, "dataSource": { "type": "api", "url": "/api/car/list", "params": { "seriesId": "123" } }, "tracking": { "exposure": { "event": "car_list_show", "required": ["pageId", "componentId"], "defaults": { "module": "marketing" } }, "click": { "event": "car_item_click", "required": ["carId"], "optional": ["rank"] } }}
这里的重点是把不同职责分开。props 交给组件渲染,dataSource 交给运行时数据层处理,tracking 交给埋点层处理。组件本身可以读取最终数据,但不应该自己理解整个平台的埋点协议。
必填项、默认值和校验也应该配置化
属性面板必须支持校验。否则页面发布后才发现某个头图没有配置、某个 API 参数为空、某个埋点必填字段缺失,成本会很高。
字段 schema 可以包含校验规则:
{ "name": "imageUrl", "label": "头图", "type": "image", "required": true, "rules": [ { "type": "url", "message": "请填写合法的图片地址" } ]}
发布前需要对整份页面 DSL 做校验:
function validatePage(pageSchema) { const errors = []; for (const node of pageSchema.components) { const meta = componentRegistry.get(node.type); errors.push(...validateNode(node, meta)); } return errors;}
校验不能只发生在属性面板里。因为页面 DSL 可能来自草稿恢复、复制页面、接口导入或历史版本升级,所以发布链路也必须再校验一次。
组件通信不能靠互相引用
低代码页面里经常会出现组件联动。例如:
一个筛选组件选中车型后,车型列表组件刷新数据一个 Tab 组件切换后,下面的卡片组件展示不同内容一个表单组件提交成功后,优惠券组件更新状态
最直接但最糟糕的做法是让组件互相引用。比如筛选组件直接调用车型列表组件的 reload 方法。这样组件之间会强耦合,页面一复杂就很难维护。
更好的方式是引入页面级事件总线或状态中心。组件只发出事件,不关心谁消费;组件只订阅自己关心的事件,不关心事件来自哪里。
eventBus.emit("carFilter.change", { seriesId: "123", priceRange: "10-20"});
车型列表组件声明自己监听这个事件:
{ "id": "car-list-1", "type": "CarList", "bindings": [ { "event": "carFilter.change", "action": "reload", "paramsMapping": { "seriesId": "seriesId", "priceRange": "priceRange" } } ]}
运行时负责解释这段绑定关系:
eventBus.on("carFilter.change", payload => { runComponentAction("car-list-1", "reload", mapParams(payload));});
这样组件之间没有直接依赖。页面配置决定谁影响谁,运行时负责调度。
通信机制要防止联动失控
组件联动一旦配置化,就必须考虑循环和风暴。
例如 A 组件变化触发 B 组件刷新,B 组件刷新后又触发 A 组件更新,就会出现循环。或者一个筛选组件变化后触发十几个组件同时请求接口,页面瞬间出现大量并发请求。
所以通信机制需要几个约束:
- 事件命名必须有命名空间。
- 单次用户操作生成一个 transaction id。
- 同一 transaction 内避免重复触发相同组件动作。
- 对高频事件做 debounce 或 throttle。
- 对异步请求做并发控制和取消。
- 发布前检查明显的循环依赖。
可以给每次联动加上下文:
eventBus.emit("carFilter.change", payload, { transactionId: createTransactionId(), sourceComponentId: "filter-1"});
运行时在调度时记录调用链。如果发现同一个 transaction 内某个组件动作被重复触发,就可以跳过或报错。
编辑器要保留可调试性
低代码平台的一个难点是:页面不是开发者手写出来的,而是配置出来的。出了问题以后,不能只让研发去读最终页面代码。
编辑器应该提供调试视图:
- 当前页面 DSL。
- 当前选中组件的 props、dataSource、tracking。
- 组件间事件绑定关系。
- 运行时事件日志。
- 发布前校验结果。
- 异步 API 请求结果。
这样当一个组件数据没有刷新时,可以看到是事件没有发出、绑定关系没有命中、参数映射错误,还是 API 请求失败。
小结
低代码编辑器的核心不是拖拽动画,也不是属性面板长什么样,而是能否把用户操作稳定地转换成结构化配置。
拖拽引擎负责维护组件树,属性 schema 负责生成编辑面板,数据源和埋点配置负责扩展业务能力,事件总线和绑定协议负责组件通信。
这些能力都要围绕同一个原则设计:编辑器可以复杂,但最终页面 DSL 必须清晰;组件可以丰富,但组件之间不能互相强依赖;配置可以灵活,但运行时必须可控、可调试、可校验。