找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1472

积分

0

好友

191

主题
发表于 10 小时前 | 查看: 1| 回复: 0

RxEditor 是一款开源企业级可视化低代码前端,目标是可以编辑所有 HTML 基础的组件,例如支持 React、Vue、小程序等,目前仅实现了 React 版。

RxEditor低代码设计器运行界面

  • 项目地址:github.com/rxdrag/rxeditor
  • 演示地址 (Vercel 部署):rxeditor.vercel.app/

本文将详细介绍 RxEditor 的设计与实现方法,涵盖技术选型、软件架构、具体实现中的关键点,以及预览渲染、物料热加载、前端逻辑编排等内容。

注:为了方便理解,文中引用的代码滤除了细节,是实际实现代码的简化版。

设计原则

  • 尽量减少对组件的入侵,最大程度使用已有组件资源。
  • 配置优先,脚本辅助。
  • 基础功能原子化,组合式设计。
  • 物料插件化、逻辑组件化,尽可能动态插入系统。

基础原理

项目的设计目标,是能够通过拖拽的方式操作基于 HTML 制作的组件,例如调整这些组件的包含关系,并设置组件属性。

不管是 React、Vue、Angular、小程序,还是别的类似前端框架,最终都是要把 JS 组件以 DOM 节点的形式渲染出来。

RxEditor连接组件树与DOM树

编辑器 (RxEditor) 需要维护一个树形模型,这个模型描述组件的隶属关系以及 props。同时它还能跟 DOM 树交互,通过各种 DOM 事件来操作组件模型树。

这里关键的一点是,编辑器需要知道 DOM 节点与组件节点之间的对应关系。在不侵入组件的前提下,并且还要忽略前端库的差异,比较理想的方法是给 DOM 节点赋予一个特殊属性,并与模型中组件的 ID 对应。在 RxEditor 中,这个属性是 rx-id,例如在 DOM 节点中这样表示:

<div rx-id="one-uuid">
</div>

编辑器监听 DOM 事件,通过事件 target 的 rx-id 属性,就可以识别其在模型中对应的组件节点。也可以通过 document.querySelector([rx-id="${id}"]) 方法,查找组件对应的 DOM 节点。

除此之外,还增加了 rx-node-typerx-status 这两个辅助属性。rx-node-type 属性主要用来识别是工具箱的 Resource、画布内的普通节点还是编辑器辅助组件;rx-status 计划用于多模块编辑,不过目前该功能尚未实现。

rx-id 是设计器的基础性原理,它给设计器内核抹平了前端框架的差异,几乎贯穿设计器的所有部分。

Schema 定义

编辑器操作的是 JSON 格式的组件树。设计时,设计引擎根据这个组件树渲染画布;预览时,执行引擎根据这个组件树渲染实际页面;代码生成时,可以把这个组件树生成代码;保存时,直接把它序列化存储到数据库或者文件。这个组件树是设计器的数据模型,通常被称为 Schema。

像阿里的 formily,它的 Schema 依据的是 JSON Schema 规范,并做了一些扩展,它在描述父子关系时使用的是 properties 键值对。然而,使用键值对的方式存储子组件 (children) 有几个明显的问题:

  • 在渲染预览界面时,一个字段只能绑定一个控件,因为 key 值唯一。
  • 键值对不携带顺序信息,存储到 JSON 类型字段时不能保证顺序。
  • 设计器引擎内部操作时用的是数组,传输到后端存储时不得不进行转换。

鉴于上述问题,RxEditor 采用了数组的形式来记录 Children,这是一种与 React 和 Vue 组件更接近的方式。同时,RxEditor 的 Schema 原生支持卡槽 (slots),这可以很大程度上支持现有组件,例如很多 React Antd 组件不需要封装就可以直接拉到设计器里使用。

需要注意的是 x-fieldx-reactionsx-field 是表单数据的定义,x-reactions 是组件控制逻辑,通过前端编排来实现,这两个后面会详细介绍。

组件形态

项目中的前端组件需要在两个地方渲染:一是设计引擎的画布,另一处是预览页面。这两处使用不同的渲染引擎,对组件的要求也不一样,所以把组件定义为两个形态:

  • 设计形态:在设计器画布内渲染,需要提供 ref 或者转发 rx-id,有能力跟设计引擎交互。
  • 预览形态:预览引擎使用,渲染机制跟运行时渲染一样,相当于普通的前端组件。

设计形态的组件与预览形态的组件,对应的是同一份 schema,只是在渲染时使用不同的组件实现。接下来以 React 为例,详细介绍两者的区别与联系,以及如何制作设计形态的组件。

有 React ref 的组件

这部分组件最简单,直接拿过来使用即可,它们的设计形态与预览形态是一样的。

无 ref,但可以把未知属性转发到合适的 DOM 节点上

这类组件除了已知的属性,会将其他属性原封不动地转发到根 DOM 节点上。设计引擎渲染时,直接将 rx-id 作为属性传入即可。

通过组件 id 拿到 ref

有的组件既不能提供合适的 ref,也不能转发 rx-id,但是这个组件有 id 属性。可以通过唯一的 id 来获得对应 DOM 的 ref,并提取成高阶组件。

嵌入隐藏元素

如果一个组件通过上述方式安插 rx-id 都不合适,但这个组件恰好有 children 的话,可以在 children 里面插入一个隐藏元素,通过隐藏元素 DOM 的 parentElement 获取 ref。

调整 ref 位置

有的组件提供了 ref,但 ref 指示的位置并不合适。例如,基于这个 ref 画编辑轮廓线会显得别扭。这时可以加入一个转换 ref 的高阶组件来调整位置。

组件外层包一个 div

如果一个组件既不能提供合适的 ref,不能转发 rx-id,没有 id 属性,也没有 children,可以直接在组件外层包一个 div,使用 div 的 ref。这种方式凭空添加了一个 div,可能会隔离 CSS 上下文,需要注意。

带卡槽 (slots) 的组件

Vue 中有卡槽,分为具名卡槽跟不具名卡槽 (即 children)。React 中没有明确的卡槽概念,但是 React.ReactNode 类型的 props 就相当于具名卡槽了。

在可视化设计器中,卡槽可以非常清晰地区分组件的各个区域,并能很好地复用逻辑。如果 schema 不支持卡槽,通常需要特殊封装组件。为了避免这种复杂繁琐的工作并遵循“尽量减少对组件入侵”的原则,RxEditor 把卡槽 (slots) 放入了原生 schema。

用这样的方式处理卡槽,卡槽不能被直接拖入组件,只能通过属性面板的配置打开或者关闭卡槽:

组件卡槽属性配置面板

需要独立制作设计形态的组件

通过上述各种高阶组件、schema 原生支持的 slots,已有组件基本上不需要修改就可以纳入可视化设计。但是也有例外,有些组件仍需独立制作设计形态,主要基于两方面考虑:用户体验和业务逻辑复杂。

例如,Antd 的 Button 组件的 children 可以是 text 文本,而文本不是一个组件,在编辑器中很难被拖入。虽然可以嵌套一个 Text 组件来解决拖放问题,但会大量增加画布中控件的数量,影响用户体验。这种情况下,最好重写 Button 组件,提取为高阶组件。

业务逻辑复杂的典型例子是 Table 组件,其设计形态与预览形态区别明显:

设计形态 Table
Table组件设计形态界面

预览形态 Table
Table组件预览形态界面

这种组件需要特殊制作,具体实现请参考源码。

Material,物料的定义

一个 Schema 只描述一个组件,而这个组件相关的配置,比如多语言信息、在工具箱中的图标、编辑规则等,需要一个配置来描述,这就是物料的定义。

物料定义包含了一个组件的所有内容,直接注册进设计器就可以使用。

物料的热加载

一个不想支持热加载的低代码平台,不是一个有“出息”的平台。虽然当前版本尚未实现,但这里简单分享前几个版本的热加载经验。

一个物料的定义是一个 JS 对象,只要能拿到这个对象就可以直接使用。热加载要解决的就是如何“拿到”。有几种可能的方式:

  1. import:JS 原生 import 可以引入远程定义的物料,但明显缺点是不能跨域。
  2. Webpack 组件联邦:根据网上介绍似乎可行。
  3. src 引入:这种方式可行,并且在以前的版本中已成功实现。具体做法是在编译的物料库里,把物料的定义挂载到全局 window 对象上,在编辑器里动态创建一个 script 元素,在 load 事件中从全局 window 对象上拿到定义。

需要注意的是,设计器的画布目前使用的 iframe,相当于一个应用启动了两套 React。如果从设计器通过 window 对象把物料传给 iframe 画布,React 会报错。所以需要在 iframe 内部单独热加载物料。

状态管理

如果不考虑其它前端库,只考虑 React,状态管理很可能会选择 Recoil。但要考虑 Vue、Angular 等其它前端,就只能从 Redux、Mobx、RxJS 中选择。

RxJS 没有使用经验暂时放弃。Mobx 与“尽量减少对组件入侵”的设计原则相悖,也放弃了。最后,选择了 Redux。

虽然 Redux 的代码看起来繁琐一些,但好在可视化项目本身的状态并不多,这种繁琐度是可以接受的。在使用过程中发现,Redux 做低代码状态管理有很多不错的优势:足够轻量、数据流向清晰、可以精确控制订阅,并且 Redux 对配置友好,在可视化业务编排里配置订阅其状态数据非常方便。

目前项目里有三个地方用到了 Redux,这三处位置以后会独立成三个 npm 包,所以各自维护自己的状态树 Root 节点。这三个状态树分别是:

  1. 设计器状态树:设计器引擎逻辑上维护一棵节点树,节点树与带 rx-id 的 DOM 节点一一对应。设计引擎会把 schema 转换成节点树,然后展平存储在 Redux 里面。
  2. 数据模型状态树fieldy 模块的数据模型主要用于管理页面的数据模型,树状结构。数据模型中的数据,通过 schema 的 x-field 属性绑定到具体组件。预览页面、右侧属性面板都使用这个模型。
  3. 逻辑编排设计器状态树:用于控制页面的业务逻辑以及组件间的联动关系。

软件架构

软件被划分为两个比较独立的部分:

  • 设计器:用于设计页面,消费的是设计形态的组件。生成页面 Schema。
  • 运行时:把设计器生成的页面 Schema 渲染为正常运行的页面,消费的是预览形态的组件。

采用分层设计架构,上层依赖下层。

设计器架构

设计器的最底层是 core 包,在它之上是 react-corevue-core,再往上就是 shell 层,例如 Antd shell、Mui shell 等。

RxEditor设计器分层架构图

  • core 包是整个设计器的基础,包含了 Redux 状态树、页面互动逻辑、编辑器的各种状态等。
  • react-core 包定义了 React 相关的基础组件,把 core 包功能封装为 hooks。
  • react-shells 包,针对不同组件库的具体实现,比如 antd 或者 mui。

运行时架构

运行时包含三个包:ComponentRenderfieldyminions,前者依赖后两者。

RxEditor运行时架构图

  • fieldy 是数据模型,用于组织页面数据,比如表单、字段等。
  • minions (小黄人) 是控制器部分,用于控制页面的业务逻辑以及组件间的联动关系。
  • ComponentRender 负责把 Schema 渲染为正常运行的页面。

core 包的设计

Core 包是基于接口的设计,清晰了模块间的依赖关系,封装了具体实现细节,能方便地单独替换某个模块。Core 包含的模块:

RxEditor设计器引擎核心模块图

设计器引擎是 IDesignerEngine 接口的具体实现,也是 Core 包入口,通过它可以访问包内的其它模块。核心模块包括:

  • 监视器 (IMonitor):提供订阅接口,发布设计器状态。
  • 动作管理 (IActions):把部分常用的 Redux actions 封装成通用接口。
  • 文档模型 (IDocument):Redux store 存储了文档的状态数据,文档模型直接使用 Redux store,并将其封装为更直观的接口。
  • 组件管理器 (IComponentManager):管理组件信息(组件注册、获取等)。
  • 资源管理器 (IResourceManager):管理工具箱的组件、模板资源。
  • Shell 管理 (IDesignerShell):与界面交互的通用逻辑,基于事件模型实现。

RxEditor Shell事件驱动类图

  • 插件 (IPlugin):RxEditor 是组合式的编辑器。为了在编辑器退出时统一销毁资源,定义了一个简单的 IPlugin 接口。创建 IDesignerEngine 时直接传入不同的 Plugin 工厂即可组合功能。
  • 装饰器管理 (IDecoratorManager):装饰器用于给画布内的节点插入 HTML 标签或者属性。这些插入的元素不依赖于节点的编辑状态,比如给所有节点加入辅助的 outline。

react-core 包

这个包是使用 React 对 core 进行的封装,并提供一些通用 React 组件,不依赖具体的组件库 (如 antd, mui)。

上下文 (Contexts)

主要的上下文用于下发核心实例:

  • DesignerEngineContext:下发 IDesignerEngine 实例。
  • DesignComponentsContext:下发设计形态组件。
  • PreviewComponentsContext:下发预览形态组件。
  • DocumentContext:下发文档模型 (IDocument)。
  • NodeContext:下发节点 (ITreeNode)。

画布 (Canvas)

实现不依赖具体画布。Core 包定义了画布接口和不同的画布实现逻辑 (headless)。在 react-core 包,把画布的实现逻辑跟具体界面组件挂接。

画布的实现方式大概有三种,各有优缺点:

  1. div 实现方式:简单、性能好。缺点是 JS 上下文和 CSS 样式没有隔离,且无法模拟浏览器大小来触发响应式布局的 @media 查询。
  2. Web Component 沙箱方式:用 shadow DOM 作为画布,能有效隔离 JS 和 CSS。但同样无法触发 @media 查询。
  3. iframe 实现方式:会隔离 JS 和 CSS,并且 iframe 尺寸的变化能触发 @media 查询,是非常理想的实现方式。RxEditor 最终锁定了这种方式,采用 iframe.src 方式渲染,完美解决了上述各种问题,虽然初始化性能略差。

runner 包

这个包是运行时,以正常运行的方式渲染设计器生产的页面,消费的是预览形态的组件。设计器右侧的属性面板也是基于低代码实现,使用的是这个包。

Runner 包能渲染一个完整的前端应用,包含表单数据绑定和组件联动。它采用模型数据、行为、UI 界面三者分离的方式。

  • 数据模型在 fieldy 模块定义。
  • 控制逻辑在 minions 模块。
  • ComponentRender 负责渲染。

其核心思想是:通过组件对外接口 props 来控制组件,在组件外层包装一个控制器。控制器实例通过 Context 逐级下发,子组件可以调用所有父组件的控制器。ComponentView 渲染组件时,根据 schema 配置,如果配置了 x-reactions,就给组件包裹高阶组件 withController;如果配置了 x-field,就给组件包裹数据绑定的高阶组件 withBind

逻辑编排

逻辑编排可以将业务逻辑组件化,并进行可视化配置。以“打地鼠”游戏逻辑为例,说明其实现思路。

打地鼠的界面:
打地鼠游戏界面

左侧9个按钮是地鼠,每隔1秒会随机激活一只(变为蓝色),鼠标点击活动地鼠为击中(变为红色,积分器记1分),右侧上方的输入框为计分器,下面是开始/结束按钮。

游戏主控制器

在最顶层的组件 (antd Row) 上加一个游戏控制器,取名“游戏容器”:

游戏容器控制器配置界面

这个控制器的可视化配置:

逻辑编排流程图示例

这是一个基于数据流的逻辑编排引擎。数据从节点的输入端口(左侧)流入,经过处理后再从输出端口(右侧)流出。每个节点可以有自己的状态,这种机制相当于把业务逻辑组件化了,然后再把业务逻辑组件可视化。

在控制器的“初始化”事件中,实现了打地鼠的主逻辑:

打地鼠游戏主逻辑编排

监听“运行”变量,如果为 true,启动一个信号发生器(每1000毫秒产生信号),游戏开始;如果为 false则停止。信号传递给随机数生成器,生成的地鼠编号赋值给变量“活跃地鼠”。地鼠组件会订阅这个变量并做出反应。

地鼠控制器

在“初始化”事件中,地鼠订阅父组件“游戏容器”的“活跃地鼠”变量,判断是否与自己编号一致,从而控制自身状态。

“点击”事件的编排逻辑:

地鼠点击事件逻辑编排

danger 属性赋值 true(按钮变红),并调用游戏容器的“计分”方法增加积分。

这里只是初步介绍了逻辑编排的原理,RxEditor 通过这种方式将复杂的业务逻辑转化为可配置、可嵌套的“小黄人”(minions) 节点,实现了灵活的前端逻辑编排。

总结

本文介绍了一个开源可视化低代码前端 RxEditor 的实现原理,涵盖了可视化编辑、运行时渲染、逻辑编排等多个方面。所介绍的内容可以构建一个完整的低代码前端。RxEditor 通过 rx-id 抹平框架差异、原生 Schema 支持 slots、基于 Redux 的状态管理、组合式插件架构以及创新的逻辑编排引擎,力图在功能强大与对现有组件生态友好之间找到平衡。更多细节欢迎查阅开源项目源码或在技术社区交流。


本文涉及的技术概念,如 React 工程化、低代码平台架构、前端框架设计等,可在云栈社区找到更多深度讨论。




上一篇:Maven 4核心特性与升级指南:Java构建工具迎来重大重构
下一篇:Kafka高可用架构详解:从集群搭建到副本、分区与故障恢复机制
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-18 16:46 , Processed in 0.304979 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表