《低代码》一文理清编排本质
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来实现,需要写这么多代码:
<button type="button" class="ant-btn ant-btn-primary">
<span>Primary Button</span>
</button>
而如果使用React写法,则可以把代码简化成:
<Button type="primary">Primary Button</Button>
可以看出,使用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语法的组件元素:
<dialog>
<button className="blue" />
<button className="red" />
</dialog>
而同样的组件元素,可以使用如下的JSON表示:
{
type: 'dialog',
props: {
children: [{
type: 'button',
props: { className: 'blue' }
}, {
type: 'button',
props: { className: 'red' }
}]
}
}
对普通的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协议,具体内容如下:
{
"version": "1.1.13",
"packages": [
{
"title": "fusion组件库",
"package": "@alifd/next",
"version": "1.23.0",
"urls": [
"https://g.alicdn.com/iotx-industry-fe/iotx-industry-cdn-other/0.0.15/next.min.css",
"https://g.alicdn.com/code/lib/alifd__next/1.23.18/next-with-locales.min.js",
"https://g.alicdn.com/iotx-industry-fe/iotx-industry-cdn-other/0.0.15/variables.min.css"
],
"library": "Next"
}
],
"components": [
{
"componentName": "Link",
"title": "链接",
"npm": {
"package": "@alifd/ali-lowcode-components",
"version": "latest",
"exportName": "Link",
"main": "",
"destructuring": true,
"subName": ""
},
"props": [
{
"name": "href",
"title": {
"label": {
"type": "i18n",
"zh_CN": "超链接",
"en_US": "Link"
},
"tip": {
"type": "i18n",
"zh_CN": "属性:href | 说明:超链接地址",
"en_US": "prop: href | description: link address"
}
},
"propType": "string",
"defaultValue": "https://fusion.design"
}
],
"configure": {
"supports": {
"style": true,
"events": [
"onClick"
]
},
"component": {
"isContainer": true
},
"props": [
{
"name": "href",
"title": {
"label": {
"type": "i18n",
"zh_CN": "跳转链接",
"en_US": "Link"
},
"tip": {
"type": "i18n",
"zh_CN": "属性:href | 说明:超链接地址",
"en_US": "prop: href | description: link address"
}
},
"setter": "StringSetter",
"condition": {
"type": "JSFunction",
"value": "condition(target) {\n return target.getProps().getPropValue(\"linkType\") === 'link';\n }"
}
}
]
},
"experimental": {
"initials": [
{
"name": "linkType",
"initial": {
"type": "JSFunction",
"value": "() => 'link'"
}
}
],
"filters": [],
"autoruns": []
},
"icon": "",
"category": "常用"
}
]
}
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 ,被拖拽对象 类型声明如下:
export interface DragNodeObject {
type: DragObjectType.Node;
nodes: Node[];
}
export interface DragNodeDataObject {
type: DragObjectType.NodeData;
data: NodeSchema | NodeSchema[];
thumbnail?: string;
description?: string;
[extra: string]: any;
}
export interface DragAnyObject {
type: string;
[key: string]: any;
}
当拖拽一个Link标签时,DragObject 是内容如下,data是渲染时需要的组件和props
{
"type": "nodedata",
"data": {
"componentName": "Link",
"title": "链接",
"props": {
"href": "https://fusion.design",
"target": "_blank",
"children": "这是一个超链接"
}
}
}
DropLocation
类型声明如下:
export interface LocationData {
target: ParentalNode; // shadowNode | ConditionFlow | ElementNode | RootNode
detail: LocationDetail;
source: string;
event: LocateEvent;
}
ISensor
拖拽敏感板, 类型声明如下:
export interface ISensor {
/**
* 是否可响应,比如面板被隐藏,可设置该值 false
*/
readonly sensorAvailable: boolean;
/**
* 给事件打补丁
*/
fixEvent(e: LocateEvent): LocateEvent;
/**
* 定位并激活
*/
locate(e: LocateEvent): DropLocation | undefined | null;
/**
* 是否进入敏感板区域
*/
isEnter(e: LocateEvent): boolean;
/**
* 取消激活
*/
deactiveSensor(): void;
/**
* 获取节点实例
*/
getNodeInstanceFromElement(
e: Element | null,
): NodeInstance<ComponentInstance> | null;
}
LocateEvent
,定位事件,类型声明如下:
export interface LocateEvent {
readonly type: 'LocateEvent';
/**
* 浏览器窗口坐标系
*/
readonly globalX: number;
readonly globalY: number;
/**
* 原始事件
*/
readonly originalEvent: MouseEvent | DragEvent;
/**
* 拖拽对象
*/
readonly dragObject: DragObject;
/**
* 激活的感应器
*/
sensor?: ISensor;
// ======= 以下是 激活的 sensor 将填充的值 ========
/**
* 浏览器事件响应目标
*/
target?: Element | null;
/**
* 当前激活文档画布坐标系
*/
canvasX?: number;
canvasY?: number;
/**
* 激活或目标文档
*/
documentModel?: DocumentModel;
/**
* 事件订正标识,初始构造时,从发起端构造,缺少 canvasX,canvasY, 需要经过订正才有
*/
fixed?: true;
}
整体流程如下:
- 在引擎初始化的时候,初始化多个 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渲染时,所需要的所有内容:
{
"componentName": "Block",
"fileName": "block-1",
"props": {
"className": "luna-page",
"style": {
"background": "#dd2727"
}
},
"children": [{
"componentName": "Button",
"props": {
"text": {
"type": "JSExpression",
"value": "this.state.btnText"
}
}
}],
"state": {
"btnText": "submit"
},
"css": "body {font-size: 12px;}",
"lifeCycles": {
"componentDidMount": {
"type": "JSFunction",
"value": "function() {\
console.log('did mount');\
}"
},
"componentWillUnmount": {
"type": "JSFunction",
"value": "function() {\
console.log('will unmount');\
}"
}
},
"methods": {
"testFunc": {
"type": "JSFunction",
"value": "function() {\
console.log('test func');\
}"
}
},
"dataSource": {
"list": [{
"id": "list",
"isInit": true,
"type": "fetch/mtop/jsonp",
"options": {
"uri": "",
"params": {},
"method": "GET",
"isCors": true,
"timeout": 5000,
"headers": {}
},
"dataHandler": {
"type": "JSFunction",
"value": "function(data, err) {}"
}
}],
"dataHandler": {
"type": "JSFunction",
"value": "function(dataMap) { }"
}
},
"condition": {
"type": "JSExpression",
"value": "!!this.state.isShow"
}
}
2.2.2 当拖一个组件过来时,发生了什么?
在 Designer 这个类中,会分别初始化拖拽开始、拖拽中、拖拽结束的监听,其中拖拽结束监听函数,会触发 document model 的 insertChildren 方法,实现节点插入状态的改变
this.dragon.onDragend((e) => {
const loc = this._dropLocation;
nodes = insertChildren(
loc.target,
[...dragObject.nodes],
loc.detail.index,
copy,
);
loc.document.selection.selectAll(nodes.map((o) => o.id));
setTimeout(() => this.activeTracker.track(nodes![0]), 10);
})
至此,从拖拽到结束,目前已分析至触发插入节点的状态改变,2.3 节渲染
会 告诉你,画布如何根据状态,渲染正确的页面
2.2.3 组件如何绑定数据源?
添加一个简单HTTP数据源界面如下: fetch 流程 这个插件通过xstate构建一个状态机,包含对以下状态的追踪,代码见 src/editor/plugin-datasource-pane/src/utils/stateMachine.ts
type DataSourcePaneStateEvent =
| { type: 'START_DUPLICATE' }
| { type: 'FINISH_IMPORT' }
| { type: 'SHOW_EXPORT_DETAIL' }
| { type: 'SHOW_IMPORT_DETAIL' }
| { type: 'START_EXPORT' }
| { type: 'START_SORT' }
| { type: 'START_CREATE' }
| { type: 'DETAIL_CANCEL' }
| { type: 'START_EDIT' }
| { type: 'FILTER_CHANGE' }
| { type: 'FINISH_SORT' }
| { type: 'SORT_UPDATE' }
| { type: 'FINISH_EXPORT' }
| { type: 'FINISH_CREATE' }
| { type: 'FINISH_EDIT' }
| { type: 'UPDATE_DS' }
| { type: 'REMOVE' }
| { type: 'START_EXPORT' }
| { type: 'SAVE_SORT' }
| { type: 'CANCEL_SORT' }
| { type: 'START_VIEW' }
| { type: 'EXPORT.toggleSelect' };
如,触发 FINISH_CREATE 这个状态,执行以下内容:
<Button text type="primary" onClick={this.handleOperationFinish}>
{current.context.detail.okText || '确认'}
</Button>
handleOperationFinish = () => {
const { current } = this.state;
if (current.matches('detail.create')) {
this.detailRef?.current?.submit().then((data) => {
if (data) {
this.send({
type: 'FINISH_CREATE',
payload: data,
});
}
});
}
}
{
on: {
FINISH_CREATE: {
target: 'idle',
actions: assign({
dataSourceList: (context, event) => {
return context.dataSourceList.concat(event.payload);
},
detail: {
visible: false,
},
}),
}
}
}
最后通过监听状态机的状态修改事件,把内容同步给全局 schema
// 注册监听
componentDidMount() {
this.serviceS = this.context?.stateService?.subscribe?.((state: any) => {
this.setState({ current: state });
// 监听导入成功事件
if (
state.changed &&
(state.value === 'idle' || state.event?.type === 'FINISH_IMPORT')
) {
// TODO add hook
this.props.onSchemaChange?.({
list: state.context.dataSourceList,
});
}
});
this.send({ type: 'UPDATE_DS', payload: this.props.initialSchema?.list });
}
// 同步修改 project 模型的
handleSchemaChange = (schema: DataSource) => {
const { project, onSchemaChange } = this.props;
if (project) {
const docSchema = project.exportSchema(common.designerCabin.TransformStage.Save);
if (!_isEmpty(docSchema)) {
_set(docSchema, 'componentsTree[0].dataSource', schema);
project.importSchema(docSchema);
}
}
onSchemaChange?.(schema);
};
至此,只是完成数据源的添加,那么已有的数据源,如何和组件进行绑定呢? 如下图所示,通过变量绑定,可以把数据源对应的变量绑定给组件 具体渲染数据源变量代码如下,可以看到,组件通过绑定数据源id的方式,绑定了该变量
getDataSource(): any[] {
const schema = this.exportSchema();
const stateMap = schema.componentsTree[0]?.dataSource;
const list = stateMap?.list || [];
const dataSource = [];
for (const item of list) {
if (item && item.id) {
dataSource.push(`this.state.${item.id}`);
}
}
return dataSource;
}
2.2.4 组件如何通过设置器实现定制化渲染?
lowcode-engine-ext 预置了大量的 Setter,常见的如下:
预置 Setter | 返回类型 | 用途 | 截图 |
---|---|---|---|
ArraySetter | T[] | 列表数组行数据设置器 | |
BoolSetter | boolean | 布尔型数据设置器, | |
ClassNameSetter | string | 样式名设置器 | |
ColorSetter | string | 颜色设置器 | |
DateMonthSetter | 日期型-月数据设置器 | ||
DateRangeSetter | 日期型数据设置器,可选择时间区间 | ||
DateSetter | 日期型数据设置器 | ||
DateYearSetter | 日期型-年数据设置器 | ||
EventSetter | function | 事件绑定设置器 | |
IconSetter | string | 图标设置器 | |
FunctionSetter | any | 函数型数据设置器 | |
JsonSetter | object | json型数据设置器 | |
MixedSetter | any | 混合型数据设置器 | |
NumberSetter | number | 数值型数据设置器 | |
ObjectSetter | Record<string, any> | 对象数据设置器,一般内嵌在ArraySetter中 | |
RadioGroupSetter | string | number | boolean | 枚举型数据设置器,采用tab选择的形式展现 | |
SelectSetter | string | number | boolean | 枚举型数据设置器,采用下拉的形式展现 | |
SlotSetter | Element | Element[] | 节点型数据设置器 | |
StringSetter | string | 短文本型数据设置器,不可换行 | |
StyleSetter | 样式设置器 | ||
TextAreaSetter | string | 长文本型数据设置器,可换行 | |
TimePicker | 时间型数据设置器 | ||
VariableSetter | any | 变量型数据设置器, |
分别对应前端开发过程中,样式设置、事件绑定、属性配置等等,下面分别介绍如何实现
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
// SettingTopEntry
setPropValue(propName: string, value: any) {
this.nodes.forEach((node) => {
node.setPropValue(propName, value);
});
}
// node
setPropValue(path: string, value: any) {
this.getProp(path, true)!.setValue(value);
}
// prop
setValue(val: CompositeValue) {
...
if (oldValue !== this._value) {
const propsInfo = {
key: this.key,
prop: this,
oldValue,
newValue: this._value,
};
editor?.emit(GlobalEvent.Node.Prop.InnerChange, {
node: this.owner as any,
...propsInfo,
});
this.owner?.emitPropChange?.(propsInfo);
}
这里主要实现两个逻辑:
- 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 节点 内容如下:
{
"__events": {
"eventDataList": [
{
"type": "componentEvent",
"name": "onFetchData",
"relatedEventName": "testFunc"
}
],
"eventList": [
{
"name": "onFetchData",
"disabled": true
},
{
"name": "onSelect",
"disabled": false
},
{
"name": "onRowClick",
"disabled": false
},
{
"name": "onRowMouseEnter",
"disabled": false
},
{
"name": "onRowMouseLeave",
"disabled": false
},
{
"name": "onResizeChange",
"disabled": false
},
{
"name": "onColumnsChange",
"disabled": false
},
{
"name": "onRowOpen",
"disabled": false
},
{
"name": "onShowSearch",
"disabled": false
},
{
"name": "onHideSearch",
"disabled": false
}
]
},
"onFetchData": {
"type": "JSFunction",
"value": "function(){this.testFunc.apply(this,Array.prototype.slice.call(arguments).concat([])) }"
}
}
2.2.4.3 属性配置
属性配置可以自定义组件的入参(类比 react 的props ),由组件开发者配置每个参数的setter生成
{
"configure": {
"props": [
{
"title": "资源名称",
"name": "clazz",
"setter": {
"componentName": "StringSetter",
"isRequired": true,
"initialValue": ""
}
},
]
}
}
最终每个组件定义出来的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
import { Component, PureComponent, createElement, createContext, forwardRef } from 'react';
import ReactDOM from 'react-dom';
import { adapter } from '@alilc/lowcode-renderer-core';
adapter.setRuntime({
Component,
PureComponent,
createContext,
createElement,
forwardRef,
findDOMNode: ReactDOM.findDOMNode,
});
adapter.setRenderers({
PageRenderer: pageRendererFactory(),
ComponentRenderer: componentRendererFactory(),
BlockRenderer: blockRendererFactory(),
AddonRenderer: addonRendererFactory(),
TempRenderer: tempRendererFactory(),
DivRenderer: blockRendererFactory(),
});
adapter.setConfigProvider(ConfigProvider);
function factory(): types.IRenderComponent {
const Renderer = rendererFactory();
return class ReactRenderer extends Renderer implements Component {};
}
export default factory();
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到底是如何实现的呢?
import ReactRenderer from '@ali/lowcode-react-renderer';
import ReactDOM from 'react-dom';
import { Button } from '@alifd/next';
const schema = {
componentName: 'Page',
props: {},
children: [
{
componentName: 'Button',
props: {
type: 'primary',
style: {
color: '#2077ff'
},
},
children: '确定',
},
],
};
const components = {
Button,
};
ReactDOM.render((
<ReactRenderer
schema={schema}
components={components}
/>
), document.getElementById('root'));
核心代码是 src/editor/renderer-core/src/renderer/base.tsx,下面我们一步步探索,该模块实现了哪些功能? 最终 schema 其实都是转换成 React 的 createElement 来实现组件的渲染,代码如下:
const child = engine.createElement(
Comp,
{
...data,
...this.props,
ref: this.__getRef,
className: classnames(
getFileCssName(__schema?.fileName),
className,
this.props.className,
),
__id: __schema?.id,
...otherProps,
},
this.__createDom(),
);
包含内容如下:
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 这个组件,简化后代码如下:
class Content extends Component<{ host: BuiltinSimulatorHost }> {
render() {
const sim = this.props.host;
const { disabledEvents } = this.state;
const { viewport } = sim;
const frameStyle: any = {
transform: `scale(${viewport.scale})`,
height: viewport.contentHeight,
width: viewport.contentWidth,
};
return (
<div className="lc-simulator-content">
<iframe
name="SimulatorRenderer"
className="lc-simulator-content-frame"
style={frameStyle}
ref={(frame) => sim.mountContentFrame(frame)}
/>
</div>
);
}
}
这里通过react 的 ref,实现 sim.mountContentFrame 的初始化调用,并异步创建 createSimulator,createSimulator 流程如下: 此时构造的 html 结构如下:
其中包含 react-simulator-renderer 的初始化,主要实现 renderer 方法的赋值import renderer from './renderer';
if (typeof window !== 'undefined') {
(window as any).SimulatorRenderer = renderer;
}
mountContentFrame 此时拿到 renderer 实例后,进行初始化
const renderer = await createSimulator(this, iframe, vendors);
renderer.run();
run 方法简化如下,主要实现几个功能:
- dom 节点的创建
- 相关类增加
- 通过 createElement 创建 SimulatorRendererView ,并通过 render 方法挂载到 app 节点
run() {
const containerId = 'app';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
document.body.appendChild(container);
container.id = containerId;
}
// ==== compatible vision
document.documentElement.classList.add('engine-page');
document.body.classList.add('engine-document'); // important! Stylesheet.invoke depends
reactRender(
createElement(SimulatorRendererView, { rendererContainer: this }),
container,
);
host.project.setRendererReady(this);
}
进一步分析 SimulatorRendererView,通过遍历documentInstances,加载 Routes组件,构建数据,最终调用 react-renderer 实现页面渲染
@observer
class Renderer extends Component<{
rendererContainer: SimulatorRendererContainer;
documentInstance: DocumentInstance;
}> {
startTime: number | null = null;
render() {
const { documentInstance, rendererContainer: renderer } = this.props;
if (!container.autoRender) return null;
return (
<LowCodeRenderer
schema={documentInstance.schema}
deltaData={documentInstance.deltaData}
deltaMode={documentInstance.deltaMode}
components={container.components}
appHelper={container.context}
designMode={designMode}
device={device}
documentId={document.id}
suspended={renderer.suspended}
self={renderer.scope}
getSchemaChangedSymbol={this.getSchemaChangedSymbol}
setSchemaChangedSymbol={this.setSchemaChangedSymbol}
getNode={(id: string) => documentInstance.getNode(id) as Node}
rendererName="PageRenderer"
thisRequiredInJSE={host.thisRequiredInJSE}
__host={host}
__container={container}
onCompGetRef={(schema: any, ref: ReactInstance | null) => {
documentInstance.mountInstance(schema.id, ref);
}}
/>
);
}
其中的 LowCodeRenderer 即为上面描述的 ReactRenderer 由于组件添加了observer 装饰器,那么只需要 mobx 相对应的 model 发生改变,render 方法就会被调用 至此,完成从 schema 到页面渲染的过程