ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

低代码拖拽设计器架构解析:从页面JSON到React渲染

低代码拖拽设计器架构解析:从页面JSON到React渲染 先说一个可能颠覆你认知的结论低代码拖拽设计器的难点从来不在拖拽本身。拖个组件到画布这是任何一个前端都能两天搞定的活儿。真正难的是拖完之后的事——组件放到画布上以什么数据结构存下来属性面板改了值之后怎么同步到渲染层用户配好的页面如何保存到 Node 服务端、再重新加载出来更进一步这份 JSON 怎么在服务端还原成一个可运行的 React 页面甚至导出成独立代码我花了大半年时间边做边填坑把一个基于 React Node 的可视化拖拽设计器从零搭了起来可以拖拽生成表单、管理后台页面、可视化报表界面。这篇文章把整体架构、核心机制、模块设计和踩坑过程完整梳理一遍想自己动手做类似工程的朋友可以直接抄作业。1. 难的不是拖拽而是把页面变成一种可描述的数据结构1.1 页面即数据低代码设计器的第一性原理很多人刚开始做低代码第一反应是我要做一个拖拽系统于是先去研究 HTML5 Drag and Drop API、去封装组件移动逻辑。方向没错但顺序错了。低代码平台的第一性原理是页面即数据。你在画布上看到的每一个组件、每一段布局、每一个绑定事件都必须能够被序列化成一份结构化的 JSON然后存进数据库、通过网络传输、在任意时刻重新渲染。拖拽只是生成和维护这份 JSON 的一种交互方式而已。我最初的设计非常简单整个页面就是一个组件树节点结构长这样// 页面描述文件的顶层结构 { page: { title: 我的订单管理页, layout: plain, // 布局模式自由画布/垂直流式/栅格布局 width: 1920, height: 1080, background: #f5f6fa }, components: [ { id: cmp_8f3k2d, type: Table, parentId: null, props: { columns: [ { title: 订单号, dataIndex: orderNo }, { title: 金额, dataIndex: amount } ], dataSource: ${orders} }, style: { left: 240, top: 180, width: 800, height: 420, zIndex: 1 }, events: { rowClick: openOrderDetail, rowDoubleClick: editOrder } } ] }每个组件节点都包含唯一 id、组件类型、父级 id、属性 props、样式 style、事件 events。这份 JSON 就是设计器运行时唯一的事实来源。理解了这个模型你会发现低代码系统的所有模块都围绕一件事转展示 JSON、编辑 JSON、保存 JSON、根据 JSON 渲染真实页面。画布是编辑态的可视化映射组件库是映射的素材属性面板是 JSON 的编辑器Node 端是 JSON 的存储和分发中心。想清楚这一点再开始写代码后面的路会顺很多。1.2 组件描述文件让每个组件都知道自己长什么样既然页面是一棵组件树那组件库里每一个组件都应该自带一份说明书告诉设计器三件事它能配置哪些属性、属性用什么控件编辑、它有哪些事件可以暴露。我在系统里把这份说明书叫ComponentSchema组件描述文件。// 以 Button 组件为例的组件描述文件 export default { type: Button, label: 按钮, group: 基础组件, icon: circle, defaultProps: { text: 按钮, type: primary, disabled: false, loading: false, size: middle }, propEditorSchema: [ { field: text, label: 按钮文案, editor: Input, // 编辑器控件类型 defaultValue: 按钮 }, { field: type, label: 按钮类型, editor: Select, options: [ { label: 主要按钮, value: primary }, { label: 默认按钮, value: default }, { label: 虚线按钮, value: dashed }, { label: 危险按钮, value: danger }, { label: 文本按钮, value: text } ] }, { field: disabled, label: 是否禁用, editor: Switch } ], availableEvents: [onClick, onDoubleClick, onMouseEnter], allowedChildrenTypes: [] // 空数组表示不允许嵌子组件 }凡是需要注册到组件库里的组件都必须提供这样一份描述。组件面板根据group分组展示拖到画布后系统把defaultProps和defaultStyle浅拷贝一份塞进组件树点击组件时属性面板读取propEditorSchema动态渲染出表单控件。这套机制的收益是以后每增加一个新组件不需要改设计器本身的任何代码只需要写组件 写 schema然后注册进组件库。设计器的代码量和真实业务组件的数量解耦了这也是低代码平台能够扩展的根本原因。2. 技术选型React 负责渲染Node 负责收尾中间靠这套约定干活2.1 前端为什么选 React 而不是 Vue市面上低代码平台用 Vue 的不少因为 Vue 模板语法贴近业务开发上手快。但我最终选了 React核心原因是 React 的函数式组件和状态驱动模型跟JSON 驱动渲染这件事天然契合。React 的React.createElement允许我在运行时根据一个字符串类型的组件名动态创建组件而 Vue 需要component :is来做动态组件虽然也能做但在根据数据递归渲染一颗组件树的场景下React 写起来更像纯函数心智负担更小。更重要的是 React 生态里有 antd 这样组件覆盖面极广的 UI 库表格、表单、日期选择、树、弹窗、上传全是低代码平台的高频组件。我不用再从零开发组件antd 直接作为内置组件库接入需要扩展的只是写 schema。2.2 Node 在低代码链路里的四个角色很多教程会把 Node 端简单理解成存页面 JSON 的接口服务这个理解太浅了。实际项目中Node 至少承担四个角色存储与版本管理页面保存后要入库而且需要有预览版和已发布版两个状态设计器里改了一版草稿不能直接覆盖线上页面需要在接口层做版本管理。页面元信息管理页面所属租户、路由地址、访问权限、SEO 信息、依赖的组件库版本这些结构化管理起来是后续做平台化运营的基础。代码生成服务用户点导出代码或者部署时Node 端读取页面 JSON通过模板生成一套可运行的 React 工程代码或静态 HTML。这个动作放在 Node 端而不放在浏览器端是为了统一规范、也方便在生成过程中执行自定义插件。静态资源与预览服务发布后的页面需要以 iframe 的方式在浏览器里被实时预览Node 提供预览接口动态返回渲染后的 HTML 或 JS bundle。这四个角色不是一开始就要全部做出来。我的建议是第一版只做存储 预览代码生成和版本管理放到第二版再上避免过度设计。2.3 拖拽引擎选择dnd-kit 还是 react-dnd拖拽这块除非你的需求极其特殊否则别自己从零封装 Drag and Drop API。我在两个主流方案里做了一轮对比最终选了 dnd-kit。维度dnd-kitreact-dnd手势与触摸屏支持内置不用额外处理需要额外引入 HTML5 backend 插件拖拽性能基于指针事件精细控制渲染更新依赖 HTML5 拖拽事件跨浏览器表现有差异排序、移动、缩放等能力提供 Sortable、DragOverlay 等高级能力排序需要自己包一层 DndProvider学习曲线概念清晰Hook 设计符合 React 习惯文档偏陈旧Context 层级要求比较严格一个让我决定彻底放弃 react-dnd 的细节是react-dnd 在拖拽时只能传输一个data对象如果你需要在拖拽过程中实时预览组件在半空中的状态html5 拖拽事件里能做的定制非常有限。而 dnd-kit 提供了DragOverlay可以单独渲染一个跟随指针移动的组件副本视觉表现力强很多对用户感知的提升非常明显。如果纯粹做表单类组件流式布局其实手写一个基于onPointerDown onPointerMove的简易拖拽也够用。但要做可视化大屏那种自由绝对定位的画布dnd-kit 的 Overlay、碰撞检测、传感器机制能省掉大量造轮子的时间。3. 从拖拽到画布组件是怎么落下来的3.1 左侧组件面板的实现思路组件面板不是简单地列一下组件名它要解决两个问题分组展示和拖拽数据传递。我用了 antd 的 Collapse 组件做分组每个组里渲染若干组件项每个可拖拽项的身份就是它的组件描述文件。dnd-kit 里可拖拽组件的核心是useDraggableHookimport { useDraggable } from dnd-kit/core; function DraggableComponentItem({ schema }) { const { attributes, listeners, setNodeRef, isDragging } useDraggable({ id: drag-${schema.type}, data: { componentSchema: schema, // 拖拽数据里携带整个组件描述 source: componentPanel // 标记来源用于区分画布内移动 } }); return ( div ref{setNodeRef} classNamecomponent-item style{{ opacity: isDragging ? 0.4 : 1 }} {...listeners} {...attributes} span classNamecomponent-item-icon{schema.icon}/span span{schema.label}/span /div ); }这里最关键的写法是data必须携带完整的componentSchema而不是只传一个组件 type 字符串。因为如果是只传字符串Droppable接收到数据后还要再去组件注册表里查一遍组件描述多一次查找逻辑不说还容易出错。直接把对象塞给 data落画布时直接深拷贝一份默认属性就行了。3.2 画布区域接收拖拽数据、生成节点、实时渲染画布是整个设计器里最容易混乱的部分。它至少要处理三类事情作为 Droppable接收左侧拖来的组件确定落点位置渲染画布上现有组件并支持在画布内部拖拽移动管理选中态、hover 态、快捷删除、复制、撤销等交互我用的 dnd-kit 方案里画布用一个大的useDroppable包裹落点位置的计算基于指针坐标import { useDroppable } from dnd-kit/core; function Canvas({ onDropComponent, children }) { const { setNodeRef, rect } useDroppable({ id: canvas }); return ( div ref{setNodeRef} classNamecanvas onDragOver{(e) e.preventDefault()} {children} /div ); }onDragEnd是整个拖拽流程的终点在这里完成把数据写进组件树的关键动作function onDragEnd(event) { const { active, over } event; // 拖拽数据里取到组件描述 const schema active.data.current?.componentSchema; if (!schema) return; // 计算落点坐标使用 dnd-kit 返回的 delta const canvasRect canvasRef.current.getBoundingClientRect(); const dropPoint { x: event.active.rect.current.translated?.left - canvasRect.left, y: event.active.rect.current.translated?.top - canvasRect.top }; const newNode createNodeFromSchema(schema, dropPoint); addNodeToTree(newNode); }你会发现这个过程几乎没有直接操作 DOM所有的逻辑都在操作一棵组件树数据。画布组件只是把这个数据渲染成元素并且给每个组件绑定选中、拖拽移动的事件。实时渲染的关键是写一个递归渲染函数遍历组件树每个节点根据 type 从 registry 里取出真正的 React 组件function renderNode(node, treeContext) { const ComponentClass componentRegistry[node.type]?.component; if (!ComponentClass) return null; const props resolveProps(node.props, treeContext); return ( ComponentClass key{node.id} {...props} style{resolveStyle(node.style)} {node.children?.map((child) renderNode(child, treeContext))} /ComponentClass ); }resolveProps中会做一层属性解析比如把字符串形式的数字12转成数字、把${orders}这种数据绑定表达式转换成上下文里的实际数据。这层转换是低代码里非常关键的胶水层也是后面讲事件绑定的基础。3.3 选中的交互层高亮、拖拽移动、快捷键画布上的组件在编辑态不能直接像正式页面那样干干净净地展示它外面要包一层编辑态外壳用来显示选中边框、提供缩放手柄、展示组件名称标签。我是这样处理的每个画布内的渲染节点外面都包裹一个WrapperBox选中时显示蓝色四角边框 右上角显示组件类型名称hover 时显示虚线边框点击时选中该节点右侧属性面板联动Delete 键删除节点CtrlD复制节点方向键微调位置function CanvasNode({ node, isSelected, onSelect }) { return ( div className{canvas-node (isSelected ? selected : )} style{{ position: absolute, left: node.style.left, top: node.style.top, width: node.style.width, height: node.style.height, zIndex: node.style.zIndex }} onClick{(e) { e.stopPropagation(); onSelect(node.id); }} {renderNode(node)} {isSelected div classNamenode-label{node.type}/div} /div ); }这里有个很容易踩的坑画布里的组件嵌套越深点击事件越容易被内部元素截获。比如 Table 组件里单元格有点击排序表格区域自身有点击选中外层又是画布的点击空白取消选中三层 click 事件互相干扰。我的解决办法是编辑态下所有真实业务组件的事件都用 CSSpointer-events: none屏蔽掉只让外层 WrapperBox 接收事件。需要交互时比如预览模式再动态切换为pointer-events: auto。这样画布的事件处理逻辑会简单非常多。4. 属性面板与事件绑定让搭积木变成做应用4.1 属性面板不做定制开发用配置驱动属性面板如果每个组件做一套组件一多代码就爆炸。正确做法是用一个 SchemaForm 通用组件根据选中节点的propEditorSchema动态渲染表单。比如一个 Input 编辑器的 schema 配置了editor: Input面板就渲染一个 antd Input并把当前节点的props.text作为 value 传进去用户输入时触发onChange把值写回组件树里对应节点。整个属性面板只有一个表单组件驱动它的是不同组件各自带的那份 schema。function PropertyPanel({ selectedNode, onUpdate }) { if (!selectedNode) { return div classNamepanel-empty请先在画布中选择组件/div; } const schema componentRegistry[selectedNode.type].schema; return ( div classNameproperty-panel h3{schema.label} 属性/h3 SchemaForm editors{schema.propEditorSchema} values{selectedNode.props} onChange{(key, value) onUpdate(key, value)} / /div ); }onUpdate的实现要注意不可变性每次修改都生成一棵新组件树。这部分和 React 的 setState 一样永远不要去直接改selectedNode.props[key]而是通过map遍历组件树找到目标 id生成新的 props 对象再 setState。4.2 事件体系低代码里的事件到底怎么描述事件绑定是为难了很多人一个设计点。用户在按钮上绑定了一个点击后打开弹窗的行为这个行为在 JSON 里怎么描述我的方案是事件绑定 触发事件 动作列表。每个动作是一个对象有动作类型和参数。比如onClick对应的动作是openModal参数是modalId: modal_1。// 组件事件描述 events: { onClick: [ { actionType: openModal, args: { modalId: modal_create_order } }, { actionType: setFieldValue, args: { targetField: orderNo, value: ${currentRow.orderNo} } } ], onRowClick: [ { actionType: openDrawer, args: { drawerId: order_detail, size: large } } ] }运行时渲染器拿到这段配置后会把onClick代理到一个统一的事件分发函数async function dispatchActions(actions, context) { for (const action of actions) { const handler eventActionRegistry.get(action.actionType); if (handler) { await handler(action.args, context); } } }eventActionRegistry是一个动作处理器的注册表每个动作类型对应一个函数比如openModal对应的函数就是触发全局 Modal 状态管理的打开方法setFieldValue就是把值写入表单上下文。这套设计把事件和具体业务逻辑解耦了用户拖拽配置的事件最终落到注册表里对应的处理函数上。4.3 实时预览同页面渲染还是 iframe 隔离属性面板改了值用户要立刻看到效果。这就有两种实时预览思路同页面渲染的问题很明显设计器本身的 CSS、运行组件页面的 CSS、弹窗、消息提示全都混在一起样式相互污染。而且用户在属性面板里输入字符串时如果这个输入框事件冒泡到画布组件会出现奇怪的现象。我最终选择了iframe 隔离预览。设计器编辑器左边是画布右侧属性面板上面的预览区域或者独立预览页通过 iframe 加载一个运行时渲染路由。这个路由接收页面 JSON用同样的递归渲染函数在 iframe 里渲染出一个真实页面。iframe 方案带来的额外好处是安全隔离设计器里用户填写的配置、自定义代码如果写挂了最多崩了 iframe不会影响编辑器的稳定性。代价是每次预览需要记录一次渲染不能无延迟实时刷新。我的取舍是属性面板改动时做 300ms 防抖防抖结束才重新渲染 iframe大的布局拖动则实时同步到画布但不刷新预览等鼠标松开会再从整体刷新一下。这样性能和实时性平衡得还不错。5. Node 端的设计从页面 JSON 到可部署应用的全流程5.1 后端要管的数据不只是 JSON 字符串Node 端的第一个版本很容易把接口设计成存一个 JSON 字符串这么简单。但项目一旦做深数据模型一定要细分。我用的是 MongoDB页面集合的结构大致是// 页面集合pages collection { _id: 642e9f1c91a24e0011f5d3d8, title: 订单管理后台, route: /admin/orders, status: draft, // draft / released / archived version: 22, layoutMeta: { width: 1920, height: 1080 }, componentTree: [...], // 真正的页面组件树 variables: [...], // 全局变量定义 createdBy: u_1001, updatedAt: 2024-06-01T12:00:00Z }需要注意componentTree直接放在 page 文档里在数据库层面就是一份大 JSON。这么做的好处是读取一次接口就能拿到完整页面数据渲染渲染器、代码生成器都不用做额外的关联查询。缺点是页面特别大时超过几百个组件更新会有压力可以从第 500 个组件开始考虑拆分成多文档分段存储。接口层面我设计了几个核心 API方法路径说明POST/api/pages创建页面GET/api/pages/:id获取完整页面数据PUT/api/pages/:id保存页面草稿整体更新POST/api/pages/:id/release发布页面记录版本GET/api/pages/:id/preview获取预览数据 渲染配置保存草稿和发布分开是必须的。设计器里每按一次 CtrlS 都调用一次保存接口把整棵组件树提交到 Node 端。而发布接口做的事情更多检查版本号、生成发布快照、调用代码生成服务生成产物、最后才把状态置为 released。5.2 代码生成器的实现思路用模板引擎兜底代码生成是低代码平台的王炸功能——用户在设计器里拖完页面点导出代码直接拿到一个可运行的 React 项目。这个功能我在第二版实现用的是一个很朴素的思路字符串模板拼接。Node 端拿到组件树后遍历每个节点递归生成对应 JSX 代码。比如一个 Button 组件function generateJSX(node) { const { type, props, children } node; // 处理 props把 JSON 值转成 JSX 属性字符串 const propStr Object.entries(props) .map(([key, value]) { if (typeof value string) { return ${key}${value}; } return ${key}{${JSON.stringify(value)}}; }) .join( ); // 如果有子组件递归生成 const childrenStr children?.map(generateJSX).join(\n) || ; return ${type} ${propStr}${childrenStr}/${type}; }然后套一层页面级的模板const pageTemplate (jsx, imports) import React from react; ${imports.join(\n)} export default function GeneratedPage() { return ( div classNamepage-container ${jsx} /div ); } ; // 写入文件、压缩、打包最后返回下载链接生成代码这件事放在 Node 端而不是前端的另一个好处是后续可以集成更完善的流水线。比如接 ESLint 做代码规范校验接 Babel 做版本兼容转化甚至直接推到 Git 仓库触发 CI/CD 构建。这些在浏览器端做都非常别扭。5.3 发布预览iframe 动态渲染与构建缓存预览分为两种。一种是编辑态的实时预览前面说过用 iframe 加载动态 JSON 渲染另一种是发布预览即预览已发布版本这个应该走正式渲染链路。我在 Node 端做了一个轻量渲染服务/preview/:pageId接口接收页面 ID从数据库查到 published 版本的数据用服务端模板渲染出 HTML 返回。这里的技巧是预览不需要每次都打包一份 React 应用那样太慢直接把页面 JSON 以scriptwindow.__PAGE_DATA__ {...}/script的形式内嵌到 HTML 页中浏览器加载同一个运行时 JS从window.__PAGE_DATA__里读数据渲染。!DOCTYPE html html head title{{pageTitle}}/title scriptwindow.__PAGE_DATA__ {{{pageDataJson}}}/script script src/runtime/preview-runtime.js/script /head body div idroot/div /body /html这套方案理论上也可以用 SSR但低代码页面运行时的状态管理很复杂先做纯客户端渲染是性价比最高的。6. 实操中的那些坑拖拽性能、撤销重做、组件联动6.1 拖拽闪烁与 DragOverlay 的预览节点问题第一个让我头疼的问题是拖拽过程中原组件项消失但拖拽半透明的幽灵没有出现或闪了一下就没了。这个问题的根源是 dnd-kit 的DragOverlay需要稳定渲染不能在拖拽开始时才异步渲染一个组件。解决办法是把DragOverlay放在DndContext内部并且用useMemo缓存被拖拽组件的渲染结果const activeSchema active?.data.current?.componentSchema; DragOverlay {activeSchema ? div classNamedrag-overlay-item{activeSchema.label}/div : null} /DragOverlay注意 Overlay 里的内容必须使用绝对定位的根节点且不能依赖组件库的某些受控组件。我之前把 Overlay 渲染成了 antd 的 Button结果拖拽时按钮里的波纹动画疯狂重绘直接把帧率拖到了个位数。6.2 属性面板高频刷新导致画布卡顿属性面板里如果一个输入框的 onChange 每次都更新一整棵组件树画布上所有组件都会重新渲染一旦超过一百个节点卡顿感会非常明显。这一步必须做性能优化。我做两件事onChange 提交防抖输入类编辑器Input、TextArea在 300ms 防抖后才把值写回组件树选择器、颜色选择器这类本身交互就应该持续反馈的不防抖但只做局部更新。组件树 setState 后只重绘有变化的节点给每个 CanvasNode 包一层React.memo传入的节点对象如果引用没变就不重新渲染。修改属性时不要整个组件树 setState而是只更新目标节点和祖先节点的引用。实测下来两百个组件的页面在属性面板输入时能保持流畅。如果将来组件数更多可以考虑引入useSyncExternalStore配合 immutable 数据结构进一步提升细粒度更新能力。6.3 撤销重做不能让快照拖垮内存撤销重做是低代码设计器的标配功能一开始图省事每次操作都深拷贝整个组件树存进历史栈数组。结果用户连续操作几十次之后浏览器内存直接飙升到几百兆。优化后的做法是每次操作不再存整棵树而是存操作类型 受影响的节点 id 变更前后的属性值。撤销时根据操作记录反向更新组件树。只有发生结构性变化新增节点、删除节点、跨父级移动时才保存完整快照。简单说属性修改走操作记录模式结构修改才走快照模式。虽然实现起来复杂一点但内存占用减少了 90% 以上。6.4 表单联动、校验与可视化大屏扩展最后说两个扩展方向都是我在实际项目里验证过价值的。表单类场景低代码拖拽设计器最常见的落地场景是表单。表单不只是把 Input、Select 拖上去那么简单还必须有联动某个下拉框选择了某个值之后另一个输入框才显示某些字段必须满足正则校验才能提交。我在表单组件外围加了一层表单上下文组件 props 里可以配置dependencies依赖的其他字段运行时监听这些字段的变化触发联动和校验。这套机制加进去之后这个工具才算真正能用于业务系统搭建。可视化大屏场景原来这个设计器主要服务后台表单类页面后来有同事拿它拖大屏页面于是我在组件库新增了一批图表组件折线图、柱状图、饼图、数字翻牌器、滚动表格。图表组件的 schema 里style的编辑优先级比props更高因为大屏场景下位置和尺寸比内容更重要。自由绝对定位 辅助对齐线 缩放手柄这几项能力补齐之后模板页面也能拖出很有质感的大屏。如果你也准备做一套自己的低代码设计器我的建议是别一上来就想着做多平台、多租户、插件市场这些大而全的东西。先把拖拽 属性配置 保存 预览这条主线跑通把数据结构和组件描述机制设计好后面扩展组件、扩展场景都会非常快。工具的价值是慢慢长出来的不是一天堆出来的。
返回列表