1. 搭建三棵树
我们在使用低代码引擎进行可视化搭建时,需要关注UI相关的“三棵树”——HTML DOM树
、React虚拟DOM树
,以及低代码引擎实现的文档模型树(DocumentModel)
。 当然,在这里要看的重点是文档模型树(DocumentModel)
,这个模型模块的重要程度,按照官方文档的说法:“编排的本质是生产符合《阿里巴巴中后台前端搭建协议规范》的数据,在这个场景里,协议是通过 JSON 来承载的”。
1.1 HTML DOM树
首先看低代码引擎可视化搭建相关的第一棵树——HTML DOM树。
使用低代码引擎进行可视化搭建的运行环境是浏览器,而HTML是我们跟浏览器打交道的协议。 我们通过使用结构化的HTML、在浏览器中开发UI;而浏览器也通过提供HTML DOM相关的API、让我们可以进行动态调整UI。
1.2 React虚拟DOM树
虽然可以直接使用浏览器提供的原生HTML DOM API进行UI/界面的开发,但是现在项目上基本都在使用“组件式开发”——比如使用三大框架“React”“Vue”“Angular”——通过使用框架提供的“组件”这种更高维度的抽象、对HTML DOM的相关API进行底层封装,确实能够提高开发效率,尤其是在有比如ant-design
、element ui
、taro ui
等等第三方UI库的情况下,能够极大地提高开发效率。
以ant-design
中的Button
组件为例,如果使用原生的HTML+CSS来实现,需要写这么多代码:
HTML
而如果使用React写法,则可以把代码简化成:
React JSX
可以看出,使用React的JSX语法书写的代码量,得到了明显地简化。 以官方提供的react-simulator-renderer、react-renderer为例,我们需要关注的第二棵树就是“React组件树”,而React组件树在运行时的表现、就是React虚拟DOM树(React Fiber)。
1.3 文档模型树DocumentModel
接下来,我们就看看这第三棵树——文档模型树/DocumentModel。 既然我们能够使用React这种框架、使用JSX这种语法、进行UI开发了,那么为什么需要这第三棵树呢? 这是为了对“搭建”更友好——考虑一下,如果直接使用React的JSX语法、使用对JSX的抽象语法树(AST)进行可视化搭建的操作,那么对搭建的开发者要求就提高了——你需要懂编译原理啥的。 既然直接操作JSX的AST难度太高,那么能不能把问题、转化成我们能解决的问题呢? 当然是可以的! 比如,下面是一个使用JSX语法的组件元素:
React JSX
而同样的组件元素,可以使用如下的JSON表示:
JSON
对普通的JSON对象进行操作,相信绝大部分前端程序员都能做到——而且JSON这种形式,也正好适合在浏览器中进行操作的形式。 以上,通过分析Dom树、虚拟Dom树和文档模型,我们可以简单理解,低代码搭建的UI,其实可以通过JSON配置,直接渲染而来,可见搭建的核心是JSON文件,我们暂时称它为搭建协议,那么具体如何实现呢?我们继续分析
2. 搭建协议
通常搭建流程如下
- 对组件进行物料化,让组件具备在设计器编排等能力,此时关键协议是 assets.json
- 通过绑定数据源、设置器配置、UI设置等方式,实现 n * 组件 → 页面 的转换,此时关键协议是 schema.json
- 根据 assets.json + schema.json 实现页面渲染
- 多个页面组合,形成应用
整体组成和流程如下,接下来通过源码,具体分析每一步操作
2.1 入料
相关设计思想可见:入料模块设计分析,这里偏源码分析
2.1.1 assets.json 组成
]
协议最顶层结构如下,包含 5 方面的描述内容:
- version { String } 当前协议版本号
- packages{ Array } 低代码编辑器中加载的资源列表
- components { Array } 所有组件的描述协议列表
- sort { Object } 用于描述组件面板中的 tab 和 category
其中主要是components协议,具体内容如下:
2.1.2 如何渲染到组件面板?
2.1.2.1 组件库渲染
核心代码见:src/editor/plugin-components-pane/src/Icon/index.tsx
主要分两部分:
- 根据assets.json,挂载到 editor.context,并按分类渲染对应组件的title、icon等
- 监听相关事件变化,如 onChangeAssets,并重新初始化组件列表
- 通过 dragon 注册 拖拽监听
2.1.2.2 拖拽原理分析
src/editor/designer/src/designer/dragon.ts
以下是dragon核心方法,里面涉及几个概念
- 被拖拽对象 -
DragObject
- 拖拽到的目标位置 -
DropLocation
- 拖拽感应区 -
ISensor
- 定位事件 -
LocateEvent
DragObject ,被拖拽对象 类型声明如下:
TypeScript
当拖拽一个Link标签时,DragObject 是内容如下,data是渲染时需要的组件和props
JSON
DropLocation
类型声明如下:
TypeScript
ISensor
拖拽敏感板, 类型声明如下:
TypeScript
LocateEvent
,定位事件,类型声明如下:
TypeScript
整体流程如下:
- 在引擎初始化的时候,初始化多个 Sensor。
- 当拖拽开始的时候,开启 mousemove、mouseleave、mouseover 等事件的监听。
- 拖拽过程中根据 mousemove 的 MouseEvent 对象封装出 LocateEvent 对象,继而交给相应 sensor 做进一步定位处理。
- 拖拽结束时,触发 dragend 事件根据拖拽的结果进行 schema 变更和视图渲染。
- 最后关闭拖拽开始时的事件监听
根据拖拽的对象不同,我们将拖拽分为几种方式: 1)画布内拖拽: 此时 sensor 是 simulatorHost,拖拽完成之后,会根据拖拽的位置来完成节点的精确插入。 2)从组件面板拖拽到画布:此时的 sensor 还是 simulatorHost,因为拖拽结束的目标还是画布。 3)大纲树面板拖拽到画布中:此时有两个 sensor,一个是大纲树,当我们拖拽到画布区域时,画布区域内的 simulatorHost 开始接管。 4)画布拖拽到画布中:从画布中开始拖拽时,最新生效的是 simulatorHost,当离开画布到大纲树时,大纲树 sensor 开始接管生效。当拖拽到大纲树的某一个节点下时,大纲树会将大纲树中的信息转化为 schema,然后渲染到画布中。
2.2 编排
相关设计思想可见:编排模块设计分析,这里偏源码分析
编排的本质,实际上是在操作生成 schema.json
2.2.1 schema.json 组成
协议最顶层结构如下,包含5方面的描述内容:
- version { String } 当前协议版本号
- componentsMap { Array } 组件映射关系
- componentsTree { Array } 描述模版/页面/区块/低代码业务组件的组件树
- utils { Array } 工具类扩展映射关系
- i18n { Object } 国际化语料
其中最重要的是componentsTree,内容如下,包含组件通过React渲染时,所需要的所有内容:
JSON
2.2.2 当拖一个组件过来时,发生了什么?
在 Designer 这个类中,会分别初始化拖拽开始、拖拽中、拖拽结束的监听,其中拖拽结束监听函数,会触发 document model 的 insertChildren 方法,实现节点插入状态的改变
TypeScript
至此,从拖拽到结束,目前已分析至触发插入节点的状态改变,2.3 节渲染
会 告诉你,画布如何根据状态,渲染正确的页面
2.2.3 组件如何绑定数据源?
添加一个简单HTTP数据源界面如下:
fetch 流程
这个插件通过xstate构建一个状态机,包含对以下状态的追踪,代码见 src/editor/plugin-datasource-pane/src/utils/stateMachine.ts
TypeScript
如,触发 FINISH_CREATE 这个状态,执行以下内容:
TypeScript
TypeScript
最后通过监听状态机的状态修改事件,把内容同步给全局 schema
TypeScript
至此,只是完成数据源的添加,那么已有的数据源,如何和组件进行绑定呢? 如下图所示,通过变量绑定,可以把数据源对应的变量绑定给组件
具体渲染数据源变量代码如下,可以看到,组件通过绑定数据源id的方式,绑定了该变量
TypeScript
2.2.4 组件如何通过设置器实现定制化渲染?
lowcode-engine-ext 预置了大量的 Setter,常见的如下:
分别对应前端开发过程中,样式设置、事件绑定、属性配置等等,下面分别介绍如何实现
2.2.4.1 css 样式设置
样式设置,这里只行内样式的调整,主要包含以下内容
通过样式设置组件,可以调整布局、文字、背景等等信息,那么调整后的内容,如何传递给全局状态呢?
src/editor/editor-skeleton/src/components/settings/settings-pane.tsx
在 settings-pane 里面,每个配置项,都会被动态创建,并传入 onChange 事件,触发 field.setValue 操作 setValue 的整体触发流程如下,最终实际操作了 node 的 setPropValue SettingField → SettingPropEntry → SettingEntry → SettingTopEntry → setPropValue
TypeScript
这里主要实现两个逻辑:
- UI的修改,最终反馈到 Prop 对应属性上
- 触发 PropChange 事件,实现画布内容更新
2.2.4.2 事件绑定
src/editor/lowcode-engine-ext/src/setter/events-setter/index.tsx
代码相对比较简单,主要是渲染已有绑定事件,触发打开绑定事件窗口(eventBindDialog.openDialog),事件绑定窗口操作完成之后,触发 ${setterName}.bindEvent 事件,event-setter实现了监听函数,并触发 onchange 回调,最终把结果写回 node 节点的创建
内容如下:
JSON
2.2.4.3 属性配置
属性配置可以自定义组件的入参(类比 react 的props ),由组件开发者配置每个参数的setter生成
JSON
最终每个组件定义出来的props,都会统一挂载到node节点上,用于渲染
2.2.5 如何实现注入 js/css 源代码
TODO
2.2.6 插件注册机制
TODO
2.3 渲染
相关设计思想可见:渲染模块设计分析,这里偏源码分析 整体渲染,由以下模块组成:
下面详细介绍每个模块的作用
2.3.0 核心概念介绍
renderer-core
src/editor/renderer-core
核心渲染器,对外暴露adapter、pageRendererFactory、componentRendererFactory 等适配器、工厂函数; 对内实现 virtual-dom、context、hoc、renderer 等模块
xxx-renderer
src/editor/react-renderer
xxx-renderer 是一个纯 renderer,即一个渲染器,通过给定输入 schema、依赖组件和配置参数之后完成渲染。 向 renderer 透传具体实现,如 createElement
TypeScript
xxx-simulator-renderer
src/editor/react-simulator-renderer
xxx-simulator-renderer 通过和 host进行通信来和设计器打交道,提供了 DocumentModel 获取 schema 和组件。将其传入 xxx-renderer 来完成渲染。 另外其提供了一些必要的接口,来帮助设计器完成交互,比如点击渲染画布任意一个位置,需要能计算出点击的组件实例,继而找到设计器对应的 Node 实例,以及组件实例的位置/尺寸信息,让设计器完成辅助 UI 的绘制,如节点选中。
react-simulator-renderer
以官方提供的 react-simulator-renderer 为例,我们看一下点击一个 DOM 节点后编排模块是如何处理的。
- 首先在初始化的时候,renderer 渲染的时候会给每一个元素添加 ref,通过 ref 机制在组件创建时将其存储起来。在存储的时候我们给实例添加 Symbol(‘_LCNodeId’) 的属性。
- 当点击之后,会去根据 __reactInternalInstance$ 查找相应的 fiberNode,通过递归查找到对应的 React 组件实例。找到一个挂载着 Symbol(‘_LCNodeId’) 的实例,也就是上面我们初始化添加的属性。
- 通过 Symbol(‘_LCNodeId’) 属性,我们可以获取 Node 的 id,这样我们就可以找到 Node 实例。
- 通过 getBoundingClientRect 我们可以获取到 Node 渲染出来的 DOM 的相关信息,包括 x、y、width、height 等。
通过 DOM 信息,我们将 focus 节点所需的标志渲染到对应的地方。hover、拖拽占位符、resize handler 等辅助 UI 都是类似逻辑。
2.3.1 schema.json如何渲染成页面
通过以下代码,我们可以看到,通过简化版本的 schema + components 配置,即可通过ReactRender实现页面渲染,那么 ReactRender到底是如何实现的呢?
TypeScript
核心代码是 src/editor/renderer-core/src/renderer/base.tsx,下面我们一步步探索,该模块实现了哪些功能?
最终 schema 其实都是转换成 React 的 createElement 来实现组件的渲染,代码如下:
TypeScript
包含内容如下:
2.3.2 如何实现数据驱动渲染(实时预览)
上面分析了拿到 Schema 之后,如何渲染页面,但是在实际编辑的过程中,流程其实是通过UI交互,改变 schema,从而触发页面重新渲染,那么这个过程又是如何实现的呢? 此时需要引入 Simulator 的概念:
Simulator 介绍
设计模式渲染就是将编排生成的《搭建协议》渲染成视图的过程,视图是可以交互的,所以必须要处理好内部数据流、生命周期、事件绑定、国际化等等。也称为画布的渲染,画布是 UI 编排的核心,它一般融合了页面的渲染以及组件/区块的拖拽、选择、快捷配置。 画布的渲染和预览模式的渲染的区别在于,画布的渲染和设计器之间是有交互的。所以在这里我们新增了一层 Simulator 作为设计器和渲染的连接器。 Simulator 是将设计器传入的 DocumentModel 和组件/库描述转成相应的 Schema 和 组件类。再调用 Render 层完成渲染。我们这里介绍一下它提供的能力。
- Project:位于顶层的 Project,保留了对所有文档模型的引用,用于管理应用级 Schema 的导入与导出。
- Document:文档模型包括 Simulator 与数据模型两部分。Simulator 通过一份 Simulator Host 协议与数据模型层通信,达到画布上的 UI 操作驱动数据模型变化。通过多文档的设计及多 Tab 交互方式,能够实现同时设计多个页面,以及在一个浏览器标签里进行搭建与配置应用属性。
- Simulator:模拟器主要承载特定运行时环境的页面渲染及与模型层的通信。
- Node:节点模型是对可视化组件/区块的抽象,保留了组件属性集合 Props 的引用,封装了一系列针对组件的 API,比如修改、编辑、保存、拖拽、复制等。
- Props:描述了当前组件所维系的所有可以「设计」的属性,提供一系列操作、遍历和修改属性的方法。同时保持对单个属性 Prop 的引用。
- Prop:属性模型 Prop 与当前可视化组件/区块的某一具体属性想映射,提供了一系列操作属性变更的 API。
- Settings:SettingField 的集合。
- SettingField:它连接属性设置器 Setter 与属性模型 Prop,它是实现多节点属性批处理的关键。
- 通用交互模型:内置了拖拽、活跃追踪、悬停探测、剪贴板、滚动、快捷键绑定。
页面构成
画布渲染使用了设计态与渲染态的双层架构。
如上图,设计器和渲染器其实处在不同的 Frame 下,渲染器以单独的 iframe 嵌入。这样做的好处,一是为了给渲染器一个更纯净的运行环境(编辑器是基于Fusion、运行环境可能是Fusion、Ant desgin、或者小程序),更贴近生产环境,二是扩展性考虑,让用户基于接口约束自定义自己的渲染器
通讯方式
既然设计器和渲染器处于两个 Frame,它们之间的事件通信、方法调用是通过各自的代理对象进行的,不允许其他方式,避免代码耦合。整体逻辑如下:
了解了以上概念,我们来看看具体如何实现的?
Simulator分析
上面讲到,画布是通过iframe的方式加载进来的,从渲染插件(src/editor/plugin-designer/src/index.tsx)开始,整理流程如下
- Simulator 模拟器,可替换部件,有协议约束, 包含画布的容器,使用场景:当 Canvas 大小变化时,用来居中处理 或 定位 Canvas
- Canvas(DeviceShell) 设备壳层,通过背景图片来模拟,通过设备预设样式改变宽度、高度及定位 CanvasViewport
- CanvasViewport 页面编排场景中宽高不可溢出 Canvas 区
- Content(Shell) 内容外层,宽高紧贴 CanvasViewport,禁用边框,禁用 margin
- BemTools 辅助显示层,初始相对 Content 位置 0,0,紧贴 Canvas, 根据 Content 滚动位置,改变相对位置
我们主要看下 Content 这个组件,简化后代码如下:
TypeScript
这里通过react 的 ref,实现 sim.mountContentFrame 的初始化调用,并异步创建 createSimulator,createSimulator 流程如下:
此时构造的 html 结构如下:
其中包含 react-simulator-renderer 的初始化,主要实现 renderer 方法的赋值
TypeScript
mountContentFrame 此时拿到 renderer 实例后,进行初始化
TypeScript
run 方法简化如下,主要实现几个功能:
- dom 节点的创建
- 相关类增加
- 通过 createElement 创建 SimulatorRendererView ,并通过 render 方法挂载到 app 节点
TypeScript
进一步分析 SimulatorRendererView,通过遍历documentInstances,加载 Routes组件,构建数据,最终调用 react-renderer 实现页面渲染
TypeScript
其中的 LowCodeRenderer 即为上面描述的 ReactRenderer 由于组件添加了observer 装饰器,那么只需要 mobx 相对应的 model 发生改变,render 方法就会被调用 至此,完成从 schema 到页面渲染的过程