Low code 之从零搭建一个h5可视化平台

16,152 阅读13分钟

7天假,5天雨。闲着无聊撸了个可视化编辑器,优化放到后面去吧哈哈哈

前言:h5可视化平台有什么用?

小白今年工作时间有一半都花在low code上面,且无论是b端or c端都有一些接触。接下来谈一下我对low code在业务和开发上带来的感受。

对于B 端项目

以后台管理形为主。后台管理形项目的主要特点一般是crud+crud,虽然b端项目也是有可视化搭建的,但是据我了解比较普遍还是由开发人员去写json。然后json再交由低代码相关的框架去拼接数据映射到相应技术栈的组件完成渲染。

问:那么它相比传统的react+antd或者vue+element来说有什么优点呢?

答:快,超级快。如果你对某一款mis相关的low code框架比较熟悉的话。可以这么说,你项目干完了并且摸了两个小时鱼的时候瞅一眼那个用传统技术栈开发的童鞋,他可能还没进行到一半。

可能这时候你有个疑问,既然这玩意这么强。感觉为啥用的人还是那么少呢?

其实任何东西都是有两面性的。它在一个方面很出色,在另一个方面就可能很拉垮。对于mis形的low code 来说它有以下缺点

  1. 定制性差

  2. 严重依赖框架本身,排错比较困难

如果你接触过一些low code相关的东西,可以很容易发现。基本上lowcode的东西它里面的所有组件和他本身是强耦合的。

比如

{
  type:'dialog',
  config:{
    ...
  }
}

这么一段shema来说,一般的lowcode框架只是映射到它指定的那个dialog组件。而不能想我们使用传统技术开发时,发现这个组件库不合适可以立即换一个新的,再或者二次开发 or 自己搞都是比较容易的。

可这个放到lowcode技术栈中,就不那么好玩了。

对于它的排错来说,我还是深有体会的😮‍💨。我第一次接触的low code框架是百度的amis,那个时候amis的文档还是让人一言难尽的时候,而恰好我当时的项目还是有一些定制性的。为了排错、为了减弱amis在开发中的各种限制。我是一边开发一边扒源码。最后项目让我搞了一层又一层的代理...属实恶习吐了。

对于C 端项目

对于C端偏多的就是利用一些编辑器平台通过拖拽生成h5页面了。哪些业务场景应用的比较多呢,不知道大家发现没有。其实大多数的一些落地推广页都是比较相似的。而且这些落地页的开发虽然说没有什么复杂性,但是也是要消耗掉一定的人力的。同时等到这些落地页积累到一定数量的时候,对于一些有技术追求的开发人员来说也是比较痛苦的。因为他们的工作慢慢由开始的前端工程师真正转换成了CV工程师,工作热情大大降低。

于是可视化搭建就应运而生了,可视化搭建平台的使用者一般都是非技术人员。被解放出来的CV童鞋变成了编辑器组件的开发者,于是大家又开开心心的写起了代码。

一:项目基本功能

首先介绍之前,我先自我检讨,没有ui我就是个fw😭。先请兄弟们忽略我丑到爆炸的页面!!!

虽然功能实现的不是很多,但是对于传统的H5可视化搭建项目来说,核心功能已经基本齐全了。

1. 支持组件拖拽

示例1.gif

2. 支持组件配置

示例2.gif

3. 支持schme下载

示例3.gif

4. 支持项目预览

示例4.gif

5. 支持schema导入

示例5.gif

二:项目基本架构

2.1 一些理论

对于公司来讲,可能每一个bu的童鞋采用的技术栈都各有不同。或react、或vue或者各种小程序。

所以在动手之前,我们就应该提前设计好一些东西

  1. 编辑器怎么能支持多平台代码
  2. 思考平台的基本架构组成
  3. 思考每个子模块项目以什么方式存在

问:为什么编辑器要做成支持多平台的?

答:编辑器不支持多平台,那么每一种技术栈的物料(组件)是不是都要给他搞一份相应技术栈的编辑器呢?显然很不合理

问:那么怎么实现呢?

答:最简单的实现方式就是编辑器只识别所有物料(组件)的schema信息

问:具体怎么说呢?比如编辑器是要有随时预览展现组件功能的,不允许编辑器去读react or vue代码。怎么实现展示呢?

答:首先要清楚对于一个可视化搭建平台来讲,编辑器是绝对的核心。而且目前已经确定的是编辑器只会拿一个技术栈去写,这一点是绝对不会变的。对于上面那个问题我们完全可以把编辑器和预览拆分成两个项目,要实现随时展现预览功能仅需要将预览项目包裹在ifame放到编辑器中即可

问:那么这个时候平台的基本架构组成是什么样子的?

答:首先上面已经确定的是编辑器和预览已经被拆分成两个项目,那么物料区更应该是要被抽离出来。物料作为schma和组件的产出者。schema需要交予编辑器去消费,编辑器消费完吐出经过它处理过的schema交予预览项目。预览项目依赖于物料产出的组件和编辑器产出的最新schema完成页面的渲染。那么一个比较完整的流程就出来了。

截屏2021-10-06 下午6.05.15.png

2.2 我的demo

整个项目我采用的技术栈是react,编辑器已经是和物料技术栈无关了

因为涉及多个子项目,故我采用了一下lerna。

  • Super-tempalte 物料,采用组件库的开发模式即可,rollup打包
  • Super-show 预览,这个比较简单直接采用cra创建的架子即可
  • Super-editor 编辑器,umi一把梭
  • Super-server 一些简单的平台后端代码 express

截屏2021-10-06 下午6.05.59.png

三:基本实现

3.1 简单设计一套schema规则 【easy】

首先最基本的是每个组件都要有一套它自己的schema。这套scheme是要分别给编辑器和预览项目进行消费的,思考一下编辑器和预览项目需要什么呢?

主要看编辑器

中间区域是iframe,可以先不用去管。两侧可是需要根据物料提供的schema去渲染的

截屏2021-10-06 下午6.06.41.png

拿我自己这套简单的玩意来讲一些思路

button组件对应的schema及其本身

{
  "name": "button",
  "compId": "Button",
  "description": "按钮组件",
  "pic":     		"https://img12.360buyimg.com/ddimg/jfs/t1/206278/28/8822/54487/615539f5E4f4cb5ab/49773bdc89799e5c.png",
  "config": [{
      "name": "bgcColor",
      "label": "按钮颜色",
      "type": "string",
      "format": "color"
    },
    {
      "name": "btnText",
      "label": "按钮文案",
      "type": "string",
      "format": "text"
    },
  ],
  "defaultConfig": {
    "btnText": "这是一个按钮",
    "bgcColor": "#333333"
  }
}
import React from "react";
import "./index.less";

const Button = ({ defaultConfig }) => {
  const { bgcColor, btnText } = defaultConfig;
  return (
    <div className="super-btn" style={{ background: `${bgcColor}` }}>
      <button
        onClick={() => {
          console.log(111);
        }}
      >
        {btnText}
      </button>
    </div>
  );
};

export default Button;

name、compId、description这几个属性先不用去管。

pic: 编辑器左侧缩略图,因为编辑器左侧的缩略图仅仅作为展示。故简单来说放个图就好了😳

config:本组件可支持配置的属性

defaultConfig:本组件支持配置的属性的默认值

3.2 编辑器渲染左侧组件缩略图展示区域【easy】

编辑器开始的时候就是要渲染出所有的组件缩略图的【组件多的时候可搞个虚拟列表优化一下】,但是物料中是每一个组件跟着一个schema。在哪做一下整合呢?

这路我偷了个懒,我将所有的json文件改为js文件。然后在组件导出的时候做了一层整合...然后编辑器直接对其进行渲染即可

物料

截屏2021-10-06 下午6.07.48.png

截屏2021-10-06 下午6.08.31.png

编辑器

import { schameMap } from 'super-template/build/bundle';
循环渲染...

3.5 设计预览项目【easy】

截屏2021-10-06 下午6.08.57.png

问:为什么还要设计两个主要路由呢?

答:在编辑器中,用户选择什么组件、组件的顺序和组件的配置我们都是不知道的,这些都是比较即时性的。即用户在编辑器中完成拖拽操作,预览项目就要做到立即完成响应的。而在线上的情况就好太多了,一般这个时候组件的所有信息已经是固定的了。

所以如果两个路由合成一块的话,对于线上展示来说不仅是功能过剩,出现问题,排错也有一定阻碍。而且不如抽离开来清晰。

实现对schema的渲染

无论是用于编辑器的或者用于实际展示的两个主要路由页面,其最核心的方法就是渲染传过来的json信息。

首先来看一下,我编辑器最终传过来的json是什么样子 (先忽略clientHeight属性,这个是用于解决拖拽容器高度问题的)

{
  "currentCacheCopm": [
    {
      "name": "button",
      "compId": "Button",
      "description": "按钮组件",
      "pic": "https://img12.360buyimg.com/ddimg/jfs/t1/206278/28/8822/54487/615539f5E4f4cb5ab/49773bdc89799e5c.png",
      "config": [
        {
          "name": "bgcColor",
          "label": "按钮颜色",
          "type": "string",
          "format": "color"
        },
        {
          "name": "btnText",
          "label": "按钮文案",
          "type": "string",
          "format": "text"
        }
      ],
      "defaultConfig": { "btnText": "这是一个按钮", "bgcColor": "#333333" },
      "clientHeight": 100
    },
    {
      "name": "dialog",
      "compId": "Dialog",
      "description": "弹窗组件",
      "pic": "https://img11.360buyimg.com/ddimg/jfs/t1/97204/11/18195/74905/61553bb8E9ba92a0d/8d59c5db08ccd759.png",
      "config": [
        {
          "name": "dialogText",
          "label": "请填写弹框文案",
          "type": "string",
          "format": "text"
        }
      ],
      "defaultConfig": { "dialogText": "默认弹框文案" },
      "clientHeight": 100
    },
    {
      "name": "button",
      "compId": "Button",
      "description": "按钮组件",
      "pic": "https://img12.360buyimg.com/ddimg/jfs/t1/206278/28/8822/54487/615539f5E4f4cb5ab/49773bdc89799e5c.png",
      "config": [
        {
          "name": "bgcColor",
          "label": "按钮颜色",
          "type": "string",
          "format": "color"
        },
        {
          "name": "btnText",
          "label": "按钮文案",
          "type": "string",
          "format": "text"
        }
      ],
      "defaultConfig": { "btnText": "这是一个按钮", "bgcColor": "#333333" },
      "clientHeight": 100
    }
  ]
}

那么我们就可以这样去渲染它

import * as template from "super-template/build/bundle";

/**
 * 解析json,进行组件渲染
 */

const renderJson = (schema) => {
  const { compId, defaultConfig } = schema;
  const Comp = template[compId];
  return <Comp defaultConfig={defaultConfig} />;
};
export default renderJson;
  • 对于线上展示路由页面来说,他就是拿到scheme去渲染
  • 对于需要与编辑器交互的,正好可以和拖拽一块说一下

3.6 实现跨ifame拖拽通信【core】

这里可以是一个比较核心的点了,首先要实现拖拽我们有两种方案

  • 直接使用原生的H5拖拽API
  • 找一个合适的轮子

不要以为使用轮子就要比使用原生简单。拖拽对于原生来讲就是几个事件,但是它的兼容性还是有点恶心人的。那不想自己封装,怎么办呢?

害,遇事不决问谷歌

这个时候有一款非常nice的拖拽轮子就展现在眼前了,react-dndredux作者又一神器

但是呢,读这个库的文档比看一下原生api的难度可要大不少。所以我读文档的时候顺便翻译了一下,后面可能也会发一下。

那么这里我就直接用了,来再看一下页面捋一下要实现的思路

截屏2021-10-06 下午6.09.24.png

拖拽首先有两个目标 1. 拖拽目标 2. 放置目标

那么这里很明显 编辑器左侧的组件缩略图就是我们的拖拽目标,中间的ifame容器就是我们的放置目标。拖拽是视觉上的,拖拽操作的真实目的还是组件通信。

那么这里看起来就简单多了。首先拖拽是有状态的,拖拽行为刚开始时给到iframe一个占位标签,拖拽行为结束判断拖拽目标是否成功被放置到放置目标上。

是,那就以真实组件信息替换掉占位标签;否,那就仅干掉占位标签

这里还有一个对于新玩家来说的小小难点,如何实现下面的功能呢?

示例6.gif

好像猛一感觉思路有点受堵,但其实也是灰常简单的

开始我们上来的思路是让整个预览iframe区域都是一个放置目标,如果只是这样我们还真是有些无从下手。因为无法拿到一个变化的索引。

索引,哪的索引是跟随拖拽行为处于变化中的并且能跟随位置呢?

答案就在于iframe中的每一个组件区域内。下面我们再把iframe中的每一个组件都设置为放置目标

来个图吧

截屏2021-10-06 下午6.10.02.png

看一下插入占位后,如果我们的鼠标放到了组件1处,我们这时候是不是可以容易的拿到此时所在位置的索引2 。这时候我们仅需要做的操作就是改掉整个iframe区域的组件排放位置。即把索引1的占位移动到索引2的后面

比如现在整个iframe的数据源为[1,2,3,4]

那么这鼠标移动到组件1处时要执行的操作为[1,2,3,4].splice('占位',1).splice(currentIndex,'占位'),currentIndex就是此时的组件1处索引2

即现干掉占位元素,然后再在鼠标当前元素下补回来。

到此时看样子,已经有些雏形了。但是现在我有一个非常坏的消息要告诉你,react-dnd现在还不支持跨iframe拖拽。

那这时候可能有暴躁老哥发问了,不支持你上面说了那么一堆说了个🔨啊。

我...

不知道有没有人注意到我上面说了一句话:拖拽是视觉上的,拖拽操作的真实目的还是组件通信 。即使是iframe也是一样,可能就是稍微麻烦了一点,由组件通信换成了父页面与iframe子页面之间的通信罢了。

因为篇幅原因,那么这篇的拖拽先到这里,下一篇主要详细的写一下跨iframe拖拽的实现。

3.7 编辑器右侧属性配置区设计实现【easy】

示例7.gif

属性配置区:首先点击iframe的某一个组件,由编辑器去读此组件schema里的config信息,进行config表单的渲染。

这里点击的目标是iframe内的,即也有一个小小的通信问题我们可以先忽略。

这里主要来看是怎么渲染config表单的吧

直接看代码,下面这个组件接收被选中的组件schema作为props。然后拿到schema中的config,根据config的format字段去渲染相应的表单项

const EditorConfigForm: FC<EditorConfigFormProps> = ({
  compSchema,
  currentCacheCopm,
  compActiveIndex,
}) => {
  const { config, defaultConfig } = compSchema;
  const onFinish = (values) => {
    // 找到当前组件在所有组件中的索引
    // 通知iframe更新组件信息
  };
  return (
   
      <Form
        name="basic"
        wrapperCol={{ span: 16 }}
        initialValues={{ remember: true }}
        autoComplete="off"
        initialValues={defaultConfig}
        onFinish={onFinish}
        // onFinishFailed={onFinishFailed}
      >
        {config.map(({ name, format, label }) => {
          let SuperFormItem: any;
          switch (format) {
            case 'text':
              SuperFormItem = SuperText;
              break;
            case 'color':
              SuperFormItem = SuperColor;
              break;
            case 'seleter':
              SuperFormItem = SuperSeleter;
              break;
            case 'upload':
              SuperFormItem = SuperUpload;
              break;
            default:
              break;
          }

          return (
            <Form.Item key={name} label={label} name={name}>
              {/* 这里antd的表单项会给自组件注入属性,下面函数式调用组件传参的方式不可使用 */}

              {/* {SuperFormItem({
                defaultConfig: defaultConfig[name],
                value,
              })} */}
              <SuperFormItem defaultConfig={defaultConfig[name]} />
            </Form.Item>
          );
        })}
        <Form.Item wrapperCol={{ span: 12, offset: 6 }}>
          <Button type="primary" htmlType="submit">
            保存
          </Button>
        </Form.Item>
      </Form>
    
  );
};

四: 写到最后

因为篇幅原因,本篇文章主要讲的都是一些思路。但是对于此类项目来言,其实最重要的还是它的思想,思想明白了实现基本不成问题。如有兴趣,可参看源码: 项目地址

同时一般投入生产中的此类项目的组件物料都是与业务有一定偶合的,而不会出现我示例中一个button组件的形式。所以除了单独为表单设计的拖拽平台,站在页面的角度上细化组件不易受控且不太合理。不管我们平台的使用者是不是不是技术人员,简单易用容易组合页面才是硬道理。

如有帮助,感谢star

参考

react-dnd.github.io/react-dnd/

juejin.cn/user/380836…