Skip to content
Go back

拖拽这件事,最后改的其实是一棵组件树

低代码营销平台的编辑器界面看起来很直观:左边是组件库,中间是预览区域,右边是属性编辑面板。用户把组件拖到页面里,点击组件后修改参数,然后发布页面。

但真正实现时,不能把它理解成“拖一个 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 组件更新,就会出现循环。或者一个筛选组件变化后触发十几个组件同时请求接口,页面瞬间出现大量并发请求。

所以通信机制需要几个约束:

可以给每次联动加上下文:

eventBus.emit("carFilter.change", payload, {  transactionId: createTransactionId(),  sourceComponentId: "filter-1"});

运行时在调度时记录调用链。如果发现同一个 transaction 内某个组件动作被重复触发,就可以跳过或报错。

编辑器要保留可调试性

低代码平台的一个难点是:页面不是开发者手写出来的,而是配置出来的。出了问题以后,不能只让研发去读最终页面代码。

编辑器应该提供调试视图:

这样当一个组件数据没有刷新时,可以看到是事件没有发出、绑定关系没有命中、参数映射错误,还是 API 请求失败。

小结

低代码编辑器的核心不是拖拽动画,也不是属性面板长什么样,而是能否把用户操作稳定地转换成结构化配置。

拖拽引擎负责维护组件树,属性 schema 负责生成编辑面板,数据源和埋点配置负责扩展业务能力,事件总线和绑定协议负责组件通信。

这些能力都要围绕同一个原则设计:编辑器可以复杂,但最终页面 DSL 必须清晰;组件可以丰富,但组件之间不能互相强依赖;配置可以灵活,但运行时必须可控、可调试、可校验。


Share this post on: