低代码平台思维设计&基础实现

8,039 阅读9分钟

参考资料:

1、gitlab引导线实例

2、amis低代码平台

3、github开源项目:mometa

什么是低代码?

低代码(Low-Code Development,LCD),开发者主要通过图形化用户界面和配置来创建应用软件,而不是像传统模式那样主要依靠手写代码。低代码开发模式的开发者,通常不需要具备非常专业的编码技能,或者不需要某一专门领域的编码技能,而是可以通过平台的功能和约束来实现专业代码的产出。

一、思维设计

1、概念

低代码开发平台(英文全称:low-code development platform,简称LCDP),是一种方便产生应用程序的平台软件,软件会开发环境让用户以图形化接口以及配置编写程序,而不是用传统的程序设计编码方式。

低代码开发平台(LCDP):低代码量,高复用、高效率、易维护&逻辑可控。

核心能力 or 基本点:

  • 可视化配置面板
  • 具有可拓展能力:组件、模板、逻辑复用
  • 生命周期管理:开发管理、页面管理、部署管理

图片.png

2、应用场景

目的:不做大而全,针对具体领域做精;

应用场景:标准化程度高的场景,例如门户、广告、营销(给运营使用)、中后台页面搭建后者相对复杂的页面(给开发使用,低代码基础上二次开发)。目前市面上的产品有易企秀、云凤蝶、宜搭。

图片.png

3、模式/流程转变

  • 开发方式:
    • 应用模式的改变 => 开发流程的转变
  • 角色转变:
    • 产品 & 设计师: 单个割裂app的设计 => 通用规范 or 行业领域的设计;
    • 技术专家 & 开发: 单个page的开发 => 领域模型的抽象、设计、开发;
    • QA:页面测试机器 => 规范 & 通用逻辑的守门员。

图片.png

4、架构设计

一般分为四个模块:物理堆(组件库)、舞台(配置画布)、编辑面板(配置项)、顶栏(全局/页面配置)

1、物料堆
  • 功能:内置组件和物料市场
    • 内置组件:标准化组件
    • 物料市场:由外部提供物料组件,给用户扩展能力,提供搜索和导入功能
      • 对运营来说:选货目标
      • 对开发来说:可提供cli工具(结合CI/CD流程)、云编辑器online-edition
      • CI/CD基本流程:
        • 流程:开发环境、测试、CR(代码审核)、灰度发布、入库
        • 对应环境:dev、test、staging、prod
  • 架构师设计:元组件、布局组件、复合组件(元组件+布局组件)
    • 基础组件:元组件(不可嵌套)、布局组件(可嵌套)
    • 复合组件:由元组件 + 布局组件 + 复合组件组成
  • 开发设计:
    • 元组件:使用普通组件实现
    • 布局组件:壳slot或vnode,布局使用杉格(grid-layout)或flex布局

图片.png

2、主舞台

1、类别:

  • 所见及所得:渲染引擎一体化,优缺点:可探索,复杂度较高,逻辑集成度高;
  • 多态舞台:分为配置舞台和渲染引擎,优缺点:状态分离、效率高、同时两拨人维护,eg:配置使用h5,渲染为小程序后flutter。

图片.png 2、相关思想:

  • 微内核思想:操作的是DSL(json树),f(state)->view
    • 组合和渲染层隔离
    • render runtime(渲染引擎sdk) + dsl(json) = 页面
  • 事件:DND拖拽、Mouse Event
  • 画布分层技术:借鉴浏览器渲染模型,使用冒泡机制,走到所有层
    • 第一层:div,负责渲染,render(dsl, document.querySelector("#root"))
    • 第二层:加一层div,只负责处理右键事件
    • 第三层:加一层div,只处理快捷键
    • event bus:所有层可通过event bus进行通信
  • 画布功能及拓展:无限画布、引导线(衫格)、吸附对齐、旋转、快捷键、右键、缩放
    • 无限画布:监听滚动事件,每次给画布加宽带
    • 引导线:使用div画线(高度和宽带为1px)、绝对定位可拖动,下方
    • 吸附对齐:计算想尽的dom节点,定6个点和3个线,距离相近时,设置距离为0
3、编辑面板
  • 相同类型使用同一面板,可使用class类
  • 单个类型为实例,config配置挂载至vnode

图片.png

4、操作栏目

操作解耦:进行分层设计,数据、视图、操作解耦

  • 功能:撤销、重做、预览、提测、发布
    • 重做:使用队列,指针移动
    • 预览:render(dsl: {type, key,props{},animate{},actions:{},attrs:{},children[]})
  • 中间层:权限控制、数据操作(转换),暴露部分api
  • 底层:提供plugins、hooks等可扩展的插件机制,可使用:webpack/taple
//webpack/taple, umi/plugins, rollup/plugins
//eg: a.plugin.js
export const a = (dsl = {}, api) => {
  name: "undo",
  label: "xxx",
  apply(){},
  inited(){}, //生命周期
  beforePublish(){}, 
  //...
}

//dsl: 树形结构
dsl: {
  type: "",
  key: "",
  props: {},
  animate: {},
  actions: {}, //逻辑的配置,flow(流程图)
  attrs: {}, //配置键盘信息
  children: []
}
5、输出
  • 符合语法规范可二次开发的源代码,减少还原ui的工作量,开发人员将导出源码放入工程内补充逻辑部分即可。

    • 原理:字符串拼接,可使用nodejs后端进行拼接和打包
    • 扩展:在线编辑功能,使用manoco编辑器,预览使用iframe沙箱隔离展示,两者通过postMessage通信
    • 模版保存:使用mongodb数据库存储数据库,mongoose驱动库
  • 导出json文件:render runtime(渲染引擎sdk) + dsl(json) = 页面

6、扩展功能

历史记录和版本、模版、权限页面、分享、主题

进阶:协同编辑、定时任务、微前端(集成到其他系统的能力)、混合开发

  • 混合开发:组件(ts、flow)和json混合开发
  • vscode插件:打开可视化面板,拖拽生成代码
  • 逻辑的配置:使用流程图(flow),最后生成业务逻辑
  • 协同编辑:crdt算法使用yjs、ot算法(语雀)
  • amis:json层级、借鉴原型链实现伪作用域(继承、react的context)

二、基础实现

1、物料区

  • 元组件和布局组件,contanier、cInput、cButton
// cButton.vue
<template>
    <el-button>{{ btn }}</el-button>
</template>
<script>
// 默认按钮组件
export default {
    name: 'cButton',
    data () {
        return {
            btn: '我是按钮组件'
        }
    }
}
</script>



// cInput.vue
<template>
    <el-input></el-input>
</template>
<script>
// 默认输入组件
export default {
    name: 'cInput',
    data () {
        return {
            value: '123'
        }
    },

    mounted () {
        this.$nextTick(() => {
            this.$emit('viewMounted', this)
        })
    }
}
</script>


// container.vue
<template>
    <div class="container" @dragover.prevent @drop.stop="handleDrop">
        <slot></slot>
    </div>
</template>
<script>
// 默认输入组件
export default {
    name: 'container',
    props: {
        jsonSchema: {
            type: Object,
            default: function () {
                return {}
            }
        }
    },
    data () {
       return {}
    },

    methods: {
        // 组件被放入container回调
        handleDrop (e) {
            this.$emit('drop', e, this)
        }
    }
}
</script>
  • 解析器
// parser-button.js
import cButton from './cButton'
// 做逻辑层处理
export default {
    name: 'CButton',
    components: {
        cButton
    },

    render (h, section, children) {
        const _this = this
        const _propsOn = {
            nativeOn: {
                click: e => {
                    e.stopPropagation()
                    _this.$emit('pickType', 'cButton')
                }
            }
        }
        return (
            <cButton
            { ..._propsOn }
            ></cButton>
        )
    }
}

// parser-input.js
import cInput from './cInput'
import store from '../store'
export default {
    name: 'CInput',
    components: {
        cInput
    },
    render (h, section, children) {
        const _this = this
        const _propsOn = {
            nativeOn: {
                click: e => {
                    e.stopPropagation()
                    _this.$emit('pickType', 'cInput')
                }
            },
            on: {
                viewMounted: e => {
                    store.dispatch('props/addWhere', {
                        id: e._uid,
                        where: e.value
                    })
                }
            }
        }
        return (
            <cInput
            { ..._propsOn }
            ></cInput>
        )
    }
}

// parser-container.js
import container from './container'
export default {
    name: 'Container',
    components: {
        container
    },

    render (h, section, children) {
        const _this = this
        // 从上往下
        const _props = {
            props: {
                jsonSchema: section
            }
        }

        // 从下往上
        const _propsOn = {
            on: {
                dragover: _this.handleDragOver,
                drop: _this.handleDrop
            },
            nativeOn: {
                click: () => {
                    _this.$emit('pickType', 'container')
                }
            }
        }

        return (
            <container
            { ..._props }
            { ..._propsOn }
            > { children } </container>
        )
    }
}

2、主舞台

1、封装渲染引擎

  • 添加渲染组件层,与元组件解耦合,使用require.context自动导入组件和渲染组件使用
  • 定义vnode树形数据结构,渲染函数逐级解析vnode树,最终调用渲染组件的render函数进行渲染,可使用jsx简化渲染函数。

2、物料区和主舞台联动的拖拽实现:使用h5 draggable属性、ondrop事件(@dragover.prevent,默认不给drop)、ondrag事件实现。

  • 不使用渲染引擎:渲染可使用动态组件切换<component :is="item" />,使用selectType变量或者event确定拖拽类型
<!-- 物料区 -->
<ul>
     <li
     v-for="item in stack"
     :key="item.code"
     class="component"
     :draggable="true"
     @drag="handleDrag(item)"
     >
     {{ item.value }}
     </li>
 </ul>
 <!-- 主舞台 -->
 <div class="stage block" @dragover.prevent @drop.stop="handleDrop">
     <li v-for="(item, index) in components" :key="index">
           {{ item }}
         <component :is="item" />
     </li>
 </div>
 methods:{
     handleDrag (item) { // 拾取被配置节点
         this.selectedType = item
     },
     handleDrop () { // 放手
         const _type = this.selectedType
         this.components.push(_type)
     },
 },
 data(){
     return {
         components:[], //组件数组
     }
 },
 components:{
     ...modules //动态组件使用,需引入全部对象
 }
  • 使用渲染引擎,渲染引擎传入jsonSchema、addNode数据,container组件、parser-container组件、渲染引擎添加事件处理。

    1. 全部放置在container根组件下
    2. container中添加drop事件,通过事件抛出,向渲染引擎推送被放入的container组件信息
    3. 渲染引擎接收到事件后,对container组件的jsonSchema添加children达到添加组件的目的
//container组件添加事件,并使用emit向上抛出事件,通过props接收参数用于组件渲染
props: {
   // 从上到下:从parse-container中接收参数
   jsonSchema: {
       type: Object,
       default: function () {
           return {};
       },
   },
},
@dragover.prevent @drop.stop="handleDrop"
handleDrop(e) {
 this.$emit("drop", e, this);
},

//parser-container添加对于的事件和属性
render(h, vnode, children) {
   const _this = this
   const _props = {
       props: {
           jsonSchema: vnode,
       },
   };
   const _propsOn = {
       on: {
           drop: _this.handleDrop,
       },
   };
   return (
       <container {..._props} {..._propsOn}>
           {children}
       </container>
   );
}
//renderEngine添加事件处理,用于拖拽处理
handleDrop(event, vm) {
 const _json = vm.jsonSchema; //被拖入container的实例
 if (_json && _json.type === 'container') {
   if (!_json.children) {
     this.$set(_json, 'children', [])
   }
   _json.children.push({
     type: this.addNode
   })
 }
},

3、配置面板

一类组件配置一套配置面板,设置config文件夹,自动导入配置面板组件

  1. 添加configPanel组件,添加配置面板组件,根据输入的pickType数据,自动渲染该类型的配置面板。
  2. parse-xx组件添加click原生监听事件,点击时触发pickType事件,发送配置类型数据。
  3. 主面板mainPage中对parse-xx的父组件渲染组件添加pickType事件,回调函数中修改当前currentPickType值,传给configPanel更新。
  4. 扩展思路微内核思想,数据驱动 + 配置驱动
//mainPage.vue组件
 <render-engine
     :jsonSchema="currentJson"
     :addNode="selectedType"
     @pickType="handlePickType"
 ></render-engine>
 <config-panel :currentPickType="currentPickType"></config-panel>
 data(){
     return {
         currentPickType: ""
     }
 },
 methods:{
     handlePickType(type){
         this.currentPickType = type;
     }
 }

 //parser-input.vue
 const _propsOn = {
   nativeOn: {
     click: (e) => {
       e.stopPropagation();
       this.$emit("pickType", "cInput");
     },
   },
 };
 return <cInput {..._propsOn}></cInput>;

 //configPanel.js
 props: {
     pickType: {
     type: String,
     default: "cButton",
     },
 },
 components: {
     ...components,
 },
 methods: {
     renderPanel(h, type) {
         if (!type) return;
         const components = this.$options.components;
         return components[type].render.call(this, h);
     },
 },
 render(h) {
     const _type = this.pickType;
     let _panel = this.renderPanel(h, _type);
     return _panel;
 },

4、场景处理组件通讯

组件间事件传递(上述场景)、总对分(中央集中管理),以下是总对分场景:

  • 自建event bus(总线引导传值、收集广播):a组件发出事件head、payload、target(c),c收到后挂载事件。
  • 使用vuex实现,收集各组件渲染数据,在统一render时使用。

图片.png

三、其他相关

  1. height: 100vh与height: 100%: vh表示当前屏幕可见高度的1%,也就是说height:100vh == height:100%,但是当元素没有内容时候,设置height:100%,该元素不会被撑开,此时高度为0,但是设置height:100vh,该元素会被撑开屏幕高度一致。

  2. require.context()输出为函数 webpackContext(req)

  • webpackContext函数,入参为路径,出参为模块,可取出defalut模块
  • 函数也是对象,可有属性,输出的函数有三个属性,keys、id、resolve
    • resolve{Function} -接受一个参数request,request为test文件夹下面匹配文件的相对路径,返回这个匹配文件相对于整个工程的相对路径
    • keys {Function} -返回匹配成功模块的名字组成的数组
    • id {String} -执行环境的id,返回的是一个字符串,主要用在module.hot.accept
  1. array.reduce(function(total, currentValue, currentIndex, arr), initialValue)
  • total 必需,初始值, 或者计算结束后的返回值
  • currentValue 必需,当前元素
  • currentIndex 可选,当前元素的索引
  • arr 可选,当前元素所属的数组对象
  • initialValue 可选,传递给函数的初始值
  1. <element draggable="true|false|auto"> 拖拽事件参考:拖拽drag&拖放drop事件浅析h5新增属性draggable,属性规定元素是否可拖动 在拖放的过程中会触发以下事件:
  • 在拖动目标上触发事件 (源元素): ondragstart - 用户开始拖动元素时触发 ondrag - 元素正在拖动时触发 ondragend - 用户完成元素拖动后触发
  • 释放目标时触发的事件: ondragenter - 当被鼠标拖动的对象进入其容器范围内时触发此事件 ondragover - 当某被拖动的对象在另一对象容器范围内拖动时触发此事件 ondragleave - 当被鼠标拖动的对象离开其容器范围内时触发此事件 ondrop - 在一个拖动过程中,释放鼠标键时触发此事件 注意: 在拖动元素时,每隔 350 毫秒会触发 ondrag 事件。
  1. array.map(function(currentValue,index,arr), thisValue) map不会改变原数组,返回新数组