可行的“变种”低代码方案

333 阅读9分钟

写在开头!

本文小编只大致讲解了一下 form 表单,没有讲解 dialog、search、table、button组件。
button 支持跳转、请求;table 与 search 相互结合,都可调用dialog(包括系统原有和自行增补)。

核心思想都是一样的,都是数据驱动dom,携带与用户的交互,最终与服务端进行数据交互。
代码量太多,没办法细细描述每一个组件,重点是一定梳理核心数据的设计、解析和执行,其他的都是辅助。
废话不多说,直接开始。

项目背景

本产品的定义是大电商系统的数据中台,维度扩展新福利平台、新采购平台,中台子包利用乾坤挂载各地政采,所以前端就会产生大量的表单、列表等重复页面。
本来这是正常工作,但是吧,上面就觉得重复的工作量太多,看能不能想个办法优化掉这部分工作量,好让精力专注于具体的业务逻辑细节,不要在不重要的功能上面浪费时间。

需求调研

项目本身是vue2,所以当时只考虑了vue,换一门语言上面肯定是接受不了的

  • 直接借助其他低代码平台,拖拉拽按照页面需求实现页面后,导出代码使用到自己的平台

    • 调研之后,弃用这种方式,没有合适的方案可以完美兼容
    • 有些平台需要单独的部署才可以使用,且单独组件的配置项太多,时间成本太大
    • 有些平台不能导出生成的代码,有些平台的代码不能直接用到项目中等等
  • 决定从0开始,搭建一套独属于本项目的可拖拽低代码平台

    • 这种方案最终的结果就是,时间、金钱投入成本太大,不通过 (diss: 哎~ 还准备玩玩呢)
  • 最后一套方案,还是自行开发,但是是一个“变种”,为此我当时还写了一个手稿

    • 手机相册拍照的图片
    • 利用vue的数据双向绑定,全量解析json进行渲染
    • 设计一个简易的解析器加方法执行器,用来支撑json的解析渲染和方法执行
    • 这种方案的优势在于,没有UI界面进行交互,只需要维护解析器和json文件,增补组件,极大的节省了维护成本
    • 因为本身就是前端进行开发,所以最后整理了一个内部使用文档,共同维护,文档整理到最后,主要还是给我整理的。W^W~

方法库

数据获取

// require 本地文件,直接导入
const resultJson = require('./demo.json')
this.codeData = resultJson;
// 服务端文件返回,这种适用于服务端控制逻辑
const resultJson = this.$get('xxx').then(res => ... )
this.codeData = resultJson;

模板中参数的解析

/*
formatTemplate 解析低代码中的双花括号
@params domThis 必传,用于接受低代码传过来的模板 domThis <当前“使用者”组件的this>
@parsms template 必传,需要解析的低代码模板,格式 {{domThis.$router.xxx / domThis.xxx}}
@return 处理完成之后的返回值
*/
function formatTemplate(domThis, template) {
    let res = template;
    if(/^{{(.*?)}}$/.test(template)){
        template = template.replace(/^{{(.*?)}}$/).trim();
        // eval 是 JavaScript 中的一个全局函数,它可以将传入的字符串当作 JavaScript 代码来执行。
        // 所以应该就能理解为什么要传 domThis 了吧
        eval('res=' + template + ';');
    }
    return res;
}

其他方法库

/*
生成标准位数的唯一标识
@params length 标准位数,默认为8
@return 返回生成的唯一标识
*/
function uniQueId(length = 8){
    const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    let result = "";
    for(let i = 0; i < length; i++){
        const randomIndex = Math.floor(Math.random() * charset.length)
        result += charset[randomIndex]
    }
    return result;
}

/*
时间数据格式化,任何时间,标准格式
@params dateTime 接收到的需要处理的时间
@params fmt 需要处理成的时间格式,默认为 'yyyy-MM-dd hh:mm:ss'
@return 返回处理完成的时间
*/
function timeStampTurnTime(dateTime = null, fmt = 'yyyy-MM-dd hh:mm:ss'){
    // 如果为null,则格式化成当前时间
    const timeStamp = dateTime ? new Date(dateTime) : new Date()
    
    // 星期的中文名称
    const weekdays = ['周日''周一''周二''周三''周四''周五''周六'];
    
    // 创建一个对象,包含要格式化的日期时间各部分
    var o = {
      "M+": timeStamp.getMonth() + 1//月份 
      "d+": timeStamp.getDate(), //日 
      "h+": timeStamp.getHours(), //小时 
      "m+": timeStamp.getMinutes(), //分 
      "s+": timeStamp.getSeconds(), //秒 
      "q+"Math.floor((timeStamp.getMonth() + 3) / 3), //季度 
      "S": timeStamp.getMilliseconds(), //毫秒 
      "x+": weekdays[timeStamp.getDay()]
     };
 
    // 处理年份的格式
    if (/(y+)/.test(fmt)) {
        fmt = fmt.replace(RegExp.$1, (timeStamp.getFullYear() + "").substr(4 - RegExp.$1.length));
    }
    
    // 循环遍历对象o中的每个键,替换fmt中的格式符号
    for (let k in o) {
        if (new RegExp("(" + k + ")").test(fmt)) {
            // 根据符号长度选择填充
            fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length)));
        }
    }
    
    // 返回格式化后的日期字符串
    return fmt
}

/*
数据值格式化
@params obj 当前层级的数据集合 
@params obj.format Template(模版变量)/Integer(整型)/String(字符串)/DateTime(时间格式化)/Array(数组)/Bool(布尔)
@params value 需要处理的数据
@params that 如果需要调用Template的时候,必须传 this 
@return 返回值,处理之后的返回值
*/
function formatDataValue(obj, value, that = {}) {
    // 深拷贝
    let _value = _.cloneDeep(value);

    if (obj?.format && _value) {
        obj.format.split(',').forEach(item => {
            switch (item) {
                case 'Template':
                    _value = this.formatTemplate(that, _value);
                    break;
                case 'Integer':
                    const intValue = parseInt(_value);
                    if (!isNaN(intValue)) {
                        _value = intValue;
                    }
                    break;
                case 'String':
                    _value = String(_value);
                    break;
                case 'dateTime':
                    if (!obj?.format_value) {
                        console.warn(`${obj.key} 缺少 format_value 必要参数,已为您自动格式化为 yyyy-MM-dd hh:mm:ss`);
                    }
                    _value = this.timeStampTurnTime(_value, obj?.format_value ?? '');
                    break;
                case 'Array':
                    _value = Array.from(_value);
                    break;
                case 'Bool':
                    _value = Boolean(_value);
                    break;
                default:
                    console.warn(`未知格式类型: ${item}`);
            }
        });
    }

    return _value;
}

效果展示

form 表单

Snipaste_2024-10-13_22-05-35.png

table 加筛选项

Snipaste_2024-10-13_22-05-50.png

代码实现

vue2 + element UI
下文默认都是js方式书写json, md文档中js相对比json好看

公共项目

// 数据来源,支持三种方式
"data_source": { // 公共数据源 
    "type""Resource",  // 系统资源
    "data""{{ domThis.$store.state.common_info['goods.source.type.list'] }}"  // 固有格式,请确保在normal中已经获取了该资源

},
"data_source": { // 枚举数据源 
    "type""Enum",
    "data": [
        {
            "key"0,  //  对应 parameters 的key值
            "value""普通"  //  对应 parameters 的label值
        },
        {
            "key"1,
            "value""虚拟"
        },
        {
            "key"2,
            "value""卡密"
        }
    ]
},
"data_source": { // api数据源 
    "type""Api",
    "api": {
        "url""/brand/list",
        "method""GET",
        "parameters": {} // 附加提交的接口的对象
    }
},

form壳子 数据结构解析

// 对于 is_filter,如果回显配置了,则添加、编辑也一定要配置,且配置一定要相反
// is_filter 只能配置成 #->.  或者  .->#,主要是服务端数据  a.b.c 是正常,而前端不行。
// 这个需求只在当前项目使用!

{
    "type""component.form"// 根组件
    "parameters": { // 附加属性
        "title""我是标题"// form 标题
        "sub_title""我是副标题" // form 副标题
    },
    "api": { // form 与服务端交互接口(数据提交)
        "url""/brand/brand_add",
        "method""POST",
        "parameters": { // 提交接口的参数
            "page"1,
            "limit"100,
        },
        "is_filter""#->."  // 上文
    },
    "data_source": { // 数据源,页面的数据根据此配置加载(获取详情)
        "type""Api", 
        "api": {
            "url""{{ '/brand/brand_detail/' + domThis.$route.query.id }}"// 此处会借用 方法库的 formatTemplate 进行解析
            "method""GET",
            "parameters": {},
            "is_filter"".->#" // 对于服务端 返回的 a.b.c 前端需要转化,否则前端展示会异常,如果不需要请不要填写此字段!
        }
    },
    "is_back"true// 是否需要返回上一页
    "components": [ // 参考组件实例下的form相关组件
  ...
    ]
}

form 每一项组件的实现

// 所有数据项
{
    "type""component.form.switch"// 组件type,唯一
    "key""is_mixed_pay",  // 表单提交key值
    "label""开关"// 表单label提示语
    "format""Integer",  // 可选-组件赋值时、组件提交时的值校正类型,防止服务端返回类型错误
    "default"0// 可选-组件默认值
    "data_source": {}, // 可选-组件数据来源,参考公共项目
    "parameters": { // 可选-组件附带参数,每个组件不一样,
        "placeholder""请选择商品标签"// 可选-组件提示语
        "toast""警告-请勿操作"// 可选-红色提醒文字
        "tips""默认提示语"// 可选-灰色默认提示语
        "type""datetimerange"// 可选-适用于时间选择器,配置当前选择器的类型
        "is_picker_options""datetimerange"// 可选-适用于时间选择器
        "default_time": [ // 可选-适用于时间选择器,配置当前选择器默认时间
            "00:00:00",
            "23:59:59"
        ], 
        "range_separator""至"// 可选-适用于时间选择器,配置当前选择器中间间隔文本
        "start_placeholder""开始日期"// 可选-适用于时间选择器,配置当前选择器左边提示语
        "end_placeholder""结束日期"// 可选-适用于时间选择器,配置当前选择器右边提示语
        "format""yyyy-MM-dd"// 可选-适用于时间选择器,配置当前选择器的组件过滤
        "is_disabled"false// 可选-适用于所有input组件,是否禁用
        "key""id"// 可选-下拉类组件的 option 渲染,详情参考 element-ui下拉组件api
        "label""name"// 可选-下拉类组件的 option 渲染
        "value""id"// 可选-下拉类组件的 option 渲染
        "is_multiple"true// 可选-适用于可多选组件是否多选(下拉、图片选择器)
        "is_filter"true// 可选-适用于级联选择器<1、数据项 和 组件需要的key值不等 2、末级需要处理数据的undefined>
        "show_word_limit"true// 可选-适用于多行文本,是否展示文本框 右下角 限制字数
        "maxlength"true// 可选-适用于输入类文本框 长度限制
        "max_rows"6// 可选-适用于多行文本,最大展示几行
        "append"6// 可选-适用于input的后置插槽展示文字
        "precision"2// 可选-适用于 步进器组件 的步进精度
        "max"2// 可选-适用于 步进器组件 的最大值
        "min"2// 可选-适用于 步进器组件 的最小值
        
    }, 
    "rules": [ // 可选-表单验证规则
        { // 可选-必填验证
            "required"true,
            "message""请选择商品分类",
            "trigger""blur"
        },
        { // 可选-附加验证
            "type""array",
            "len"3,
            "message""商品分类必须满足三级分类",
            "trigger""change"
        }
    ] ,
    "image_width"200// 可选-图片上传专用
    "image_height"100// 可选-图片上传专用
},
表单下的时间选择器
// 参数详解
<!-- 
    type  时间选择器类型,
        - datetime 即可在同一个选择器里同时进行日期和时间的选择  
        - datetimerange 即可选择日期和时间范围
    default-time  默认时间(可选)
        - datetime  default-time="12:00:00"
        - datetimerange  default-time="['12:00:00', '08:00:00']"
            进行范围选择时,在日期选择面板中选定起始与结束的日期,时间默认为00:00:00
            数组1为起始时间,2为结束时间
    format 绑定值的格式 默认:yyyy-MM-dd  可选: yyyy-MM-dd HH:mm:ss / yyyy-MM-dd
    is_picker_options 是否是选择器选项
        - datetime 对应  datetime_options
        - datetimerange 对应  datetimerange_options
 -->
 
// 配置项
{
    "type": "component.form.date-time-picker",
    "key": "datetime",
    "label": "商品时间",
    "default": "",
    "data_source": {},
    "parameters": {
        "type": "datetimerange",
        "default_time": [
            "00:00:00",
            "23:59:59"
        ],
        "range_separator": "至", // 时间范围分隔符
        "format": "yyyy-MM-dd",
        "is_disabled": false, // 是否禁用
        "is_picker_options": "datetimerange",
        "placeholder": "选择日期",
        "start_placeholder": "开始日期",
        "end_placeholder": "结束日期"
    },
    "rules": []
}

// template
<el-date-picker v-model="valueee" :type="code_data.parameters.type" clearable
    :placeholder="code_data.parameters?.placeholder" :start-placeholder="code_data.parameters?.start_placeholder"
    :end-placeholder="code_data.parameters?.end_placeholder" :disabled="code_data.parameters?.is_disabled"
    :format="code_data.parameters?.format" :default-time="code_data.parameters?.default_time"
    :range-separator="code_data.parameters?.range_separator"
    :picker-options="code_data.parameters?.is_picker_options && this[code_data.parameters.is_picker_options + '_options']" unlink-panels>
</el-date-picker>
表单步进器
// 配置项
{
    "type": "component.form.inputnumber_input",
    "key": "sort_number",
    "label": "inputnumber_input",
    "default": "100",
    "parameters": {
        "placeholder": "请输入排序值",
        "precision": 2, // 数值精度几位小数,默认0位
        "step": 2, // 步幅大小,默认1
        "max": 9999999.99,// 最大值
        "min": 0,// 最小值
        "append": "元", // 单位,不填请去掉改字段
        "toast": "红色提示文字",
        "tips": "灰色提示文字"
    },
    "rules": []
}

// template
<div :class="{ 'input-number-append-box': code_data.parameters?.append }">  // 兼容是否有后置单位
    <el-input-number :precision="code_data.parameters?.precision || 0" :step="code_data.parameters?.step || 1"
        :min="code_data.parameters?.min || 0" :placeholder="code_data.parameters?.placeholder"
        :max="code_data.parameters?.max || 99999" v-model="$formData[code_data.key]" :controls="false">
    </el-input-number>
    <span class="input-group__append" v-if="code_data.parameters.append">{{ code_data.parameters.append }}</span>
</div>
大form组件
<!-- 
	code 平台的下的 表单组件
        item.parameters.tips 是input的默认提示语,class使用 default-tips
        item.parameters.toast 是input的标红提示语,class使用 warning-toast
 -->
<template>
	<div class="class_container">
		<el-card>
			<div class="class_head" v-if="code_data.parameters?.title">
				<p>{{ code_data.parameters.title }}</p>
				<p class="sub-title" v-if="code_data.parameters?.sub_title">{{ code_data.parameters.sub_title }}</p>
			</div>
			<div class="class_addnav">
				<el-form :label-width="code_data.parameters.label_width ?? '100px'" :rules="formRules" :model="formData"
					:ref="component_id" autocomplete="off">
					<el-form-item v-for="(item, i) in code_data.components " :label="item?.label" :prop="item?.key">
						<!-- 
							component.form.collection  类似采集事件
							定义:左输入框,右点击事件
							事件:走api修改 formData 的值
							预留方法 接收 子组件传过来的值
							-->
						<component :is="item.type" :code_data="item" :$formData="formData"
							v-if="item.type == 'component.form.collection'" @changeChildCallback="childCallback">
						</component>
						<component :is="item.type" :code_data="item" :$formData="formData" v-else>
						</component>
						<div v-if="item.parameters?.tips" class="default-tips">{{ item.parameters.tips }}</div>
						<div v-if="item.parameters?.toast" class="warning-toast">{{ item.parameters.toast }}</div>
					</el-form-item>
				</el-form>
			</div>
		</el-card>
		<diy-footer-button>
			<el-button v-if="code_data.is_back" @click="go_back">取消</el-button>
			<el-button type="primary" @click="submit()" :loading="submitLoading">保存</el-button>
		</diy-footer-button>
	</div>
</template>
<script>
	export default {
		name: 'component.form',

		props: {//传递参数
			// v-model
			code_data: {
				type: Object,
				required: true
			},
		},
		data() {

			return {
				component_id: this.code.domId(this.code_data),
				formRules: {},
				formData: {},
				keyMap: {},
				requestData: {},
				is_multiple: false,
				uploadImge: {},
				submitLoading: false
			};
		},
		watch: {
			code_data: {
				handler(newVal, oldVal) {
					const value = Object.is(newVal, oldVal);
					if (!value) {
						this.init()
					}
				},
				deep: true,
				immediate: true
			}
		},
		created() {

		},
		methods: {
			init() {
				// 组件初始化
				let formRules = {};
				let formData = {};
				let keyMap = {};
				for (let i in this.code_data.components) {
					let component = this.code_data.components[i];
					//数据格式化
					component.id = this.code.domId(component);
					if (component.data_source && component.data_source.type != 'Api') {
						component.data_source.successCallback = function (domThis, res) {
							component.data_source['source_data'] = res;
						}
						this.code.formatDataSource(this, component.data_source);
					}

					formRules[component.key] = component.rules;
					formData[component.key] = this.code.formatDataValue(component, component?.default ?? '', this);
					keyMap[component.key] = component;
					// 很多情况下,组件会有很多的值,但是也都需要提交,所以把值取出来,并到父级大数据里面
					if (component?.sub_key) {
						component.sub_key.forEach(item => {
							formData[item] = component[item]
							keyMap[item] = component
						})
					}
					// 很多情况下,组件会有很多的子集,但是也都需要提交,所以把值取出来,并到父级大数据里面
					if (component?.diy_input) {
						component.diy_input.children.forEach(item => {
							formData[item.key] = this.code.formatDataValue(item, item?.default ?? '');
							keyMap[item.key] = item;
						})
					}
				}

				this.formRules = formRules;
				this.formData = formData;
				this.keyMap = keyMap;

				if (this.code_data.data_source) {
					this.code_data.data_source['successCallback'] = function (domThis, res) {
						if (domThis.code_data.data_source?.is_filter == '.->#') {
							let handleFrom = {}
							Object.keys(res).forEach(item => {
								handleFrom[item.split('.').join('#')] = res[item]
							})
							domThis.formData = { ...domThis.formData, ...handleFrom }
						} else {
							domThis.formData = res
						}
					}
					this.code_data.data_source['failCallback'] = function (domThis, err) {
					}
					this.code.formatDataSource(this, this.code_data.data_source);
				}

			},
			// 取消
			go_back() {
				this.$router.go(-1)
			},
			// 提交
			submit() {
				this.requestData = this.code.formatRequestData(this, this.data, this.keyMap, this.formData);
				this.$refs[this.component_id].validate((valid) => {
					if (valid) {
						let responseFrom = {};
						// 是否需要加工数据
						if (this.code_data.api?.is_filter == '#->.') {
							Object.keys(this.requestData).forEach(item => {
								responseFrom[item.split('#').join('.')] = this.requestData[item]
							})
						} else {
							responseFrom = this.requestData
						}
						this.submitLoading = true
						this.code.request(this, this.code_data.api, responseFrom, function (domThis, res) {
							if (res.code == 200) {
								domThis.$message.success(res.message);
								if (domThis.code_data.is_back) {
									domThis.$router.go(-1)
								} else {
									domThis.code.formatDataSource(domThis, domThis.code_data.data_source);
								}
							} else {
								domThis.$message.error(res.message);
							}
							domThis.submitLoading = false
						}, (domThis, error) => {
							domThis.$message.error(error.message);
							domThis.submitLoading = false
						});
					} else {
						return false;
					}
				})
			},
			childCallback(val) {
			}
		},
		mounted() {
		},
	};
</script>

整套流程的加载顺序

  • 路由进入到低代码页面,获取路由需要加载的json名称

    // index.vue
    watch: {
     ['$route.params.name']: {
      handler(newVal, oldVal) {
       if (newVal) {
        this.loadData(newVal);
       }
      },
      deeptrue,
      immediatetrue
     }
    },
    
  • 进入到壳子解析页面,初始化加载页数据,动态加载组件。猜猜那么多组件,为什么都能动态加载进来呢,请看结尾

    <div class="layout_base">
     <component v-for="(item, i) in code_data.components" :is="item.type" :code_data="item" v-if="resource_status"
      :ref="item.id"></component>
     <!-- layout.normal -->
    </div>
    
    props: {//传递参数
     // v-model
     code_data: {
      typeObject,
      requiredtrue
     },
    },
    watch: {
     ['$route.params.name']: {
      handler(newVal, oldVal) {
       if (newVal) {
        this.init();
       }
      },
      deeptrue,
      immediatetrue
     }
    },
    methods: {
        init() {
            // resource_status 控制页面显隐
      if (this.code_data.resources && this.code_data.resources.length) {
                // loadResources 解析加载页面数据,可能涉及到 {{}}解析,上文代码有备注
       this.code.loadResources(thisfunction(domThis) {
        domThis.resource_status = true;
       });
      } else {
       this.resource_status = true;
      }
     }
    }
    
  • 进入到单组件的壳子组件、上文提到的大form组件

    <component :is="item.type" :code_data="item" :$formData="formData"></component>
    

到此为止,低代码的解析流程就算是走完了

老生常谈,时间问题,没有太细处理。

其实你会发现,就是对 elementUi 需要的参数进行封装

上面遗留了一个问题,怎样不注册组件就可以直接使用呢

答案就是,利用vue的插件,将组件批量注册。

// src/components/lib/index.js

export default {
  install(Vue, options) {
    // 1.读取lib文件夹下的文件
    // const req = requeire.context('路径', 是否读取子文件夹, /正则匹配/)
    const req = require.context('../'true/.vue$/)
    // req是一个function函数(传入读取文件路径后可导入文件)
   
    req.keys().forEach((item) => {
       // req(item).default==导入了该文件路径
      const com = req(item).default
      // 全局注册
      Vue.component(com.name, com)
    })
   }
}

然后再 main.js use 一下就好

// 注册全局组件
import components from './components/lib'

Vue.use(components)

然后就愉快的结束啦!