写在开头!
本文小编只大致讲解了一下 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 表单
table 加筛选项
代码实现
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); } }, deep: true, immediate: true } }, -
进入到壳子解析页面,初始化加载页数据,动态加载组件。猜猜那么多组件,为什么都能动态加载进来呢,请看结尾
<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: { type: Object, required: true }, }, watch: { ['$route.params.name']: { handler(newVal, oldVal) { if (newVal) { this.init(); } }, deep: true, immediate: true } }, methods: { init() { // resource_status 控制页面显隐 if (this.code_data.resources && this.code_data.resources.length) { // loadResources 解析加载页面数据,可能涉及到 {{}}解析,上文代码有备注 this.code.loadResources(this, function(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)
然后就愉快的结束啦!