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-designelement uitaro 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-rendererreact-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. 搭建协议

通常搭建流程如下

  1. 对组件进行物料化,让组件具备在设计器编排等能力,此时关键协议是 assets.json

  2. 通过绑定数据源、设置器配置、UI设置等方式,实现 n * 组件 -> 页面 的转换,此时关键协议是 schema.json

  3. 根据 assets.json + schema.json 实现页面渲染

  4. 多个页面组合,形成应用

整体组成和流程如下,接下来通过源码,具体分析每一步操作

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

主要分两部分:

  1. 根据assets.json,挂载到 editor.context,并按分类渲染对应组件的title、icon等

  2. 监听相关事件变化,如 onChangeAssets,并重新初始化组件列表

  3. 通过 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;
}

整体流程如下:

  1. 在引擎初始化的时候,初始化多个 Sensor

  2. 当拖拽开始的时候,开启 mousemovemouseleavemouseover 等事件的监听。

  3. 拖拽过程中根据 mousemoveMouseEvent 对象封装出 LocateEvent 对象,继而交给相应 sensor 做进一步定位处理。

  4. 拖拽结束时,触发 dragend 事件根据拖拽的结果进行 schema 变更和视图渲染。

  5. 最后关闭拖拽开始时的事件监听

根据拖拽的对象不同,我们将拖拽分为几种方式:

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[] 列表数组行数据设置器 ArraySetter
BoolSetter boolean 布尔型数据设置器 BoolSetter
ClassNameSetter string 样式名设置器 ClassNameSetter
ColorSetter string 颜色设置器 ColorSetter
DateMonthSetter 日期型 - 月数据设置器
DateRangeSetter 日期型数据设置器,可选择时间区间
DateSetter 日期型数据设置器
DateYearSetter 日期型 - 年数据设置器
EventSetter function 事件绑定设置器 EventSetter
IconSetter string 图标设置器 IconSetter
FunctionSetter function 函数型数据设置器 FunctionSetter
JsonSetter object json 型数据设置器 JsonSetter
MixedSetter any 混合型数据设置器 MixedSetter
NumberSetter number 数值型数据设置器 NumberSetter
ObjectSetter Record<string, any> 对象数据设置器,一般内嵌在 ArraySetter 中
RadioGroupSetter string | number | boolean 枚举型数据设置器,采用 tab 选择的形式展现
SelectSetter string | number | boolean 枚举型数据设置器,采用下拉的形式展现 SelectSetter
SlotSetter Element | Element[] 节点型数据设置器

分别对应前端开发过程中,样式设置、事件绑定、属性配置等等,下面分别介绍如何实现

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。

  • SettingsSettingField 的集合。

  • 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 方法简化如下,主要实现几个功能:

  1. dom 节点的创建

  2. 相关类增加

  3. 通过 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 到页面渲染的过程