低代码平台

October 04, 2023

前端发展至今,各大公司推出了不同的可视化低代码平台,腾讯阿里等都有关于低代码的建设,对比传统的开发,可视化低代码能快速产出页面,适用于大量逻辑简单的推广展示页面、活动页面等,成熟的系统还可以让运营人员直接配置,解放开发人力。本文就主要对低代码技术进行研究,了解其架构和关键的技术。

在正式探究前,先想一想这些问题:

  1. 在页面编辑器中,拖拽出来的容器、图片等元素,怎么转换成页面数据
  2. 编辑出来的页面数据,如何生成一个页面
  3. 如何组装js与css逻辑到页面
  4. 如何在dom节点上绑定配置的事件,点击事件、双击事件等等
  5. 编辑器面板设计, 如何实现拖拽
  6. 撤销与回滚

数据结构与渲染逻辑

页面本身是一种描述性的语言,通过html描述页面的各个节点,css描述节点的样式,js描述逻辑与人机交互等。

在日常的组件化模块化开发中,一个页面由组件A+组件B+组件C+模块A+模块B等等组成,如果抽象成纯数据结构来表示这个页面,就可以用json来描述这个页面由组件a组件b组件c等组成。

 "content": {
  "root": {
   "id": "root",
   "type": "root",
   "parentId": null,
   "childIds": ["a", "b"],
   "style": {},
  },
  "a": {
   "id": "a",
   "type": "Section",
   "parentId": "root",
   "childIds": ["c"],
   "style": {
    "position": "relative",
    "minHeight": "100px"
   },
  },
  "c": {
   "id": "c",
   "type": "Image",
   "parentId": "a",
   "childIds": [],
   "props": {
    "key": "WwvxTt",
    "src": "var(--猪猪.png)"
   },
   "style": {
    "position": "relative",
    "display": "flex",
    "width": "150px",
    "height": "150px",
    "backgroundColor": "red"
   }
  },
  "b": {
   "id": "b",
   "type": "Button",
   "parentId": 0,
   "childIds": [],
   "props": {
    "key": "b",
    "content": "点击显示列表",
    "type": "primary",
    "events": [{
     "id": "9a28f1766bd7",
     "triggerType": "CLICK",
     "action": {
       "name": "showList",
       "params": {}
     }
    }]
   },
   "style": {
    "marginTop": "10px",
    "width": "110px",
    "height": "43px",
    "fontSize": "14px"
   }
  }
 },

用一段json表示dom结构,每个是一个node,记录一个节点的父节点子节点、样式、dom类型、响应事件等信息。 每个节点都有一些通用的行为,例如获取当前节点信息、更新节点样式、子节点的增删改查、修改父节点指向等。 那对于整段节点的结构来说,就应该有新增节点、移除节点、移动节点的操作。

了解到存储的数据结构之后,那么用户对于编辑器中通过拖拽方式的添加组件、挪动组件、删除组件、设置组件样式等等的操作,其实都是在往这个json数据上做调整,数据变更驱动视图的更新。

那么json数据如何映射到我们的画布上呢?

渲染器的渲染逻辑是从根节点开始,通过不断递归解析这份树状结构的视图数据,通过动态组件的方式显示到页面中。 拿react举例, 渲染的关键代码:

function renderContent() {
  const rootNodes = Object.values(data).filter(item => item.parentId === null);
  function getChildren(node) {
    const { type: Type, style, props } = node;
    return <Type style={style} props={props}>
        { node?.childIds?.map((item, index) => getChildren(data[item])) }
    </Type>
  }

  return rootNodes.map(item => getChildren(item))
}

实际情况上述代码会更加复杂,比方修改的属性如何传递到组件中,类名拼接等等,每个组件都要经历相同的数据加工、注入一些必要功能等的过程,此时可以运用高阶组件来抽离逻辑。

上述json里的type都是dom支持的元素,拿a来说,a的type是Section, 渲染逻辑返回的就是<Section />, 那列表怎么办,并没有一个<List />,要如何渲染?

列表渲染:

列表: 组件选择列表,拖动到编辑中,在编辑器中编辑的是每一项数据item的样式,非整个列表。编辑完的列表的json数据结构like:

{
  "a": {
    "type": "List",
    "childIds": ["b"],
    "props": {
      "dataKey": "list",
      "data": []
    },
  },
 "b": {
    "type": "Section",
    "childIds": ["c,d"]
  },
  "c": {
    "type": "Image",
    "childIds": [],
    "props": {
      "key": "WwvxTt",
      "src": "",
      "dataKey": "listImage"
    },
  },
  "d": {
    "type": "p",
    "childIds": [],
    "props": {
      "key": "WwvxTta",
      "contentText": "",
      "dataKey": "listRank"
    },
  }
}

上面的json结构表示a是一个列表,绑定的数据是list。列表的children只有一个是b, b表示列表中每个item的节点,由图片节点c和文本节点d组成。配置节点d的文本对应列表数据字段是listRank, 图片c的资源路径对应列表数据字段的listImage。

假设list数据是这样的:[{listRank: 1, listImage: ‘image1’ }, {listRank: 2, listImage: ‘image2’}],那我们需要把数据转变为json上节点数据。

即从[{listRank: 1, listImage: ‘image1’ }, {listRank: 2, listImage: ‘image2’}] 转变成

[
  {
    id: 'b',
    type: 'Section',
    childrens: [{
      id: 'c',
      type: 'image',
      props: {
        src: 'image1'
      }
    }, {
      id: 'd',
      type: 'p',
      props: {
        contentText: 1
      }
    }]
 },
 {
    id: 'b',
    type: 'Section',
    childrens: [{
      id: 'c',
      type: 'image',
      props: {
        src: 'image2'
      }
    }, {
      id: 'd',
      type: 'p',
      props: {
        contentText: 2
      }
    }]
  }
]

再改造一下渲染逻辑生成列表子节点。 List组件可以认为是业务组件的一种,其他自定义组件也可以封装,在内部处理数据与节点属性的映射,再对于基础组件进行动态渲染。

清楚了数据结构和渲染逻辑之后,那么存到服务器中的数据,也同样是这份json数据,页面链接根据id查询json结构, 客户端再通过渲染逻辑解析成真实的dom节点。

基本的网页由html+css+js组成,除了元素的布局之外,我们还需要有额外的逻辑处理和css样式注入,那这一部分如何注入到我们生成的页面中呢?

js与css注入

css和js在编辑器中编辑,保存在接口中。在打开页面时查询,js动态插入style

// 获取到接口css内容后执行
insertCss(css) {
  if (css) {
    const styleNode = document.createElement('style');
    styleNode.type = 'text/css';
    styleNode.appendChild(document.createTextNode(css));
    document.head.appendChild(styleNode);
  }
}

js的数据同理,接口保存打包编译好的js文件名,通过loadScript的方式加载脚本并执行。为了减少请求脚本的时长,也可以在生成页面时注入script数据。在打开页面时执行。

事件系统

页面通常需要交互,点击、双击等事件的绑定和监听必不可少,这就需要给我们的组件添加事件系统。

事件系统的核心就是运用观察者模式,分发注册的事件名,在代码编辑中做监听

编辑器中编辑一个button元素点击事件为onBtnClick,生成的数据结构如:

"a": {
  "type": "button",
  "childIds": [],
  "props": {
    "key": "WwvxTta",
    "contentText": "点击",
    "trigger": [{
      "type": "CLICK",
      "name": "onBtnClick"
    }]
  },
}

dom元素事件可以通过高阶组件包裹,赋予不同的事件的监听,onClick、onMouseEnter等等,当点击时trigger onBtnClick, 在代码编辑中

Event.on('onBtnClick', () => {
  // do something
})

以此实现一个对click事件的监听与响应。

编辑器设计

之前我们探究的是一份低代码数据如何生成一个页面,那编辑器的作用就是设置这份数据。

通常编辑器的布局是左边组件区,中间画布,右边为节点属性设置区。生成的页面布局数据结构同保存在服务端的一致,这样在画布中的渲染逻辑就可以复用,不同的是在画布中的元素渲染不需要事件的绑定,js与css的注入等等,但是需要可选中,移动等等。

在编辑器中最重要的功能就是拖拽,从组件区拖拽组建到画布来添加组件,或者是画布里组件之间的拖拽以改变布局。那拖拽功能的实现可以利用react-dnd库,监听drop事件来决定是添加组件还是改变父节点指向。

一个react-dnd示例

import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

const DraggableBodyRow = ({ index, moveRow, className, style, ...restProps }) => {
  const type = 'DraggableBodyRow';
  const ref = useRef();
  const [{ isOver, dropClassName }, drop] = useDrop({
    accept: type,
    collect: monitor => {
      const { index: dragIndex } = monitor.getItem() || {};
      if (dragIndex === index) {
        return {};
      }
      return {
        isOver: monitor.isOver(),
        dropClassName: dragIndex < index ? ' drop-over-downward' : ' drop-over-upward',
      };
    },
    drop: item => {
      moveRow(item.index, index);
    },
  });
  const [, drag] = useDrag({
    type,
    item: { index },
    collect: monitor => ({
      isDragging: monitor.isDragging(),
    }),
  });
  drop(drag(ref));

  return (
    <tr
      ref={ref}
      className={`${className}${isOver ? dropClassName : ''}`}
      style={{ cursor: 'move', ...style }}
      {...restProps}
    />
  );
};

<DndProvider backend={HTML5Backend}> // DndProvider包裹需要拖动和监听的元素
  <Table
    columns={cols}
    data={stars}
    components={components}
    onRow={(record, index) => ({
      index,
      moveRow,
    })}
    pagination={false}
    scroll={{ y: 350 }}
    bordered={true}
    rowKey={item => item.kugouId}
    emptyText="暂无数据"
  />
</DndProvider>

如果固定死画布层的宽度,会影响实际页面的效果尤其是移动端的网页,需要做页面适配。因此画布层可以通过iframe来展示,结合根结点font-size+rem,来模拟实际效果。

同时为了渲染逻辑清晰,避免与修改节点逻辑杂糅,画布可分为辅助工具层、渲染层、逻辑层。

撤销与回滚

在编辑器中撤销的功能就是数据状态回退,适用redux-undo这个库可以轻松实现Redux 状态树中的撤销和重做。

……to be continued


Profile picture

百事可乐

Let it snow, let it snow, let it snow