文章内容是基于作者个人在真实项目中的技术实践(脱离业务)与独立思考,旨在输出经验,共同探讨。
gitHub:
一、方案概述
1.1 方案选型
此方案是基于一款以数据为引擎的表单渲染器FormCreate来搭建表单设计器,gitHub地址:htt ps://github.com/xaboy/form-create。 该渲染器与笔者所在业务线代码技术栈适配度较高,表现在:
- 技术栈支持Vue2+element-ui及Vue3+elmenent-plus,与作者所支持的业务线应用技术栈匹配; 2.内置组件丰富,element的组件都可作为内置组件使用,且支持自定义组件,无需维护大量的基础vue组件;
- 官方文档友好,每种类型组件schame较简洁,语义清晰; 4.通过JSON数据驱动表单渲染,全程重点关注数据处理、DSL生成以及组件新旧schema转换 和适配,FormCreate可根据DSL自动完成UI编排区、面板配置区以及预览表单的渲染,能较好 的定制工单业务的表单设计器。
1.2 整体设计
此方案目标是为运营方提供通用的动态表单简洁、灵活的表单配置能力,支持业务方在表单各个流程节点多样性、个性化的交互需求,且须兼容旧版配置,不影响海量存量表单数据的展示,定制适合业务方的表单设计器,效果如下图:
实现上述效果图的技术总体方案如下图所示。

1)物料库: 在 FormCreate 中,组件生成规则(rule)定义了如何通过JSON 配置生成表单,基于这个 特性,拉取到的模板字段库,将旧json中的配置和业务数据转换、适配生成相应组件规范化 的rule; 为满足业务方对表单在各种场景下多样性、个性化的需求,增加了辅助组件库及布局组件 库,以增加界面效果,如若内置组件无,则自定义该组件,并按照FormCreate规则生成对 应rule,全局注册。
2)UI编排区: 自定义基于vuedraggable的支持拖拽组件dragBox.vue以及选择组件操作(移动、删除) dragTool.vue,按照规则生成JSON,物料库中拖拽进入组件rule,推入到其属性children, 后面章节3.4详细介绍JSON结构。JSON与FormCreate组件绑定,即可完成表单编排的能 力;
3)配置面板: 左侧物料库每种组件的schema中定义了需要配置的属性,选中组件时,解析出需要配置的 props,生成配置表单的JSON,JSON与FormCreate组件绑定,即可完成配置表单,配置数据 联动到目标表单rule; 组件联动能力不使用FormCreate官方方案,与工单表单配置一期方案对接,通过调用封装 好的formApi设置联动组件的属性,配置体验变化极小。
4)工具栏: 导入JSON:根据JSON快速生成UI编排区和配置; 导出JSON: 从UI编排区解析出目标表单JSON,即对应渲染模块输入的DSL; 保存:将目标表单DSL关联模板和流程节点,通过localstorage保存,方便预览时验证表单 配置效果; 预览:表单配置所见即所得; 发布:将目标表单DSL关联模板和流程节点,保存到后台,即发布到线上; 清除:清除UI编排区编排,重新开始。
二、前端实施方案
2.1 组件Rule
每种类型组件,按照FormCreate规则定义rule,包含与组件库关联信息,表单渲染组件对应的 rule规则,配置面板信息,以el-input组件为示例,对应的规则如下:
其中目标表单组件及支持拖拽组件数据结构如下:
js
export type Rule = {
// 生成组件的名称,例如 'input', 'select' 等
type: string;
// 表单字段名称,用于数据绑定
field?: string;
// 组件的唯一标识符
name?: string;
// 字段标签
title?: string;
// 组件的提示信息
info?: string;
// 组件的默认值
value?: any;
// 是否原样生成组件,不嵌套的`FormItem`中
native?: boolean;
// 组件的属性配置
props?: Object;
// 组件的内联样式
style?: string | Object;
// 组件的 class 样式类
class?: string | Array<string>;
// 设置组件的 id
id?: string | Array<string>;
// 组件事件处理函数
on?: { [key: string]: Function | Function[] };
// 组件的自定义事件处理函数
_on?: Object;
// 组件的生成周期回调
hook?: { [key: string]: Function | Function[] };
// 插槽名,用于组件嵌套
slot?: string;
// 组件的 key,通常用于列表渲染时的唯一标识
key?: string;
// 是否必填
$required?: boolean | string | Object;
// 组件的选项列表,适用于 `radio`, `select`, `checkbox` 等组件
options?: Array<any>;
// 选项插入的目标属性,默认插入到 `props.data` 中
optionsTo?: string;
// 是否隐藏组件(不会渲染 DOM 元素)
hidden?: boolean;
// 是否显示组件(有 DOM 渲染,但可能不可见)
display?: boolean;
// 是否开启事件注入
inject?: boolean | Object;
// 表单提交时是否忽略该字段, 当等于'hidden'时组件隐藏时忽略
ignore?: boolean | 'hidden';
// 组件的验证规则
validate?: Object[];
// 子组件列表,用于嵌套子组件
children?: Rule[] | string[];
// 组件的联动控制,控制其他组件的显示与否
control?: Array;
// 动态计算组件的指定字段,当依赖值发生变化时将自动重新计算并更新结果
computed?: Object;
// FormItem 的配置
wrap?: Object;
// 设置组件的布局规则
col?: Object;
// 自定义属性,如远程数据获取等
effect?: {
// 加载远程数据
fetch?: Object;
};
// 设置组件的前缀内容,通常用于在输入框前显示图标或文本
prefix?: string | Rule;
// 设置组件的后缀内容,通常用于在输入框后显示图标或文本
suffix?: string | Rule;
// 设置组件的自定义指令
directives?: object;
// 是否缓存组件,只触发一次渲染
cache?: boolean;
// 设置回调函数,用于动态更新组件的内容
update?: (
value: any,
api: Api,
origin: {
// init初始化触发,link关联触发,value变化触发
origin: 'change' | 'init' | 'link';
// 关联触发的字段名
linkField?: string;
},
) => bool | undefined;
// 通过函数渲染插槽
renderSlots?: {
default?: (scope) => Vnode | Vnode[];
[slot: string]: (scope) => Vnode | Vnode[];
};
// 配置哪些字段变化时会触发当前组件的 `update` 回调
link?: string[];
// 设置`props`中需要双向绑定属性的名称
sync?: string[];
// 使用`emit`方式监听的事件名
emit?: string[];
// 自定义组件 `emit` 事件的前缀,默认是组件的 `field` 字段
emitPrefix?: string;
// 定义用于当前规则渲染的自定义组件
component?: boolean;
// 其他扩展属性
[key: string]: any;
};
// 拖拽组件描述规则
export interface DragRule {
//组件id,不能重复
name: string;
//组件的名称
label: string;
//组件的图标
icon: string;
//插入的分类
menu?: MenuName;
//是否支持样式配置
style?: boolean;
//如果是子表单组件,需要定义`value`的类型
subForm?: 'object' | 'array';
//组件,不建议使用
component?: Component;
//多语言配置项
languageKey?: string[];
//组件的生成规则
rule(arg: { t: t }): Rule;
//组件属性配置的规则
props(rule: Rule, arg: { t: t; api: Api }): Rule[];
//导出规则时通过这个方法转成最终规则
parseRule?: (rule: Rule) => void;
//导入规则时通过这个方法转成设计器中的渲染规则
loadRule?: (rule: Rule) => void;
//当props中的字段变化时触发
watch?: {
[key: string]: (arg: { value: any; rule: Rule; api: Api; field: string }) => void;
};
//是否有配套的子组件,例如Row和Col
children?: string;
//初始化时渲染几个子组件
childrenLen?: number;
//当前组件的操作容器是否显示在组件内部,为false时操作容器包裹当前组件
inside?: true | boolean;
//是否可以拖入其他组件到当前组件内部
drag?: true | string | boolean;
//是否显示拖拽按钮
dragBtn?: false | boolean;
//控制操作操作按钮是否显示,显示哪些
handleBtn?: true | boolean | Array<'create' | 'copy' | 'addChild' | 'delete'>;
//隐藏基础配置中的字段
hiddenBaseField?: string[];
//是否显示遮罩, 避免对组件操作. 建议有子组件时为true,其他为false
mask?: false | boolean;
//是否是表单组件
input?: boolean;
//是否只能拖入一个
only?: boolean;
//当前组件支持的事件
event?: string[];
//当前组件`value`的数据类型
validate?: string[] | boolean;
}
2.2 自定义组件
若 FormCreate 的内置组件不足以满足当前需求时,可自定义组件。以配置面板中的组件联动组件 eventConfig.vue 为例,按照 FormCreate 规则自定义组件步骤如下:
- 封装所需的 vue 组件,定义输入输出,一般 vue 组件父子通信的 props 对应输入,modelValue 以及其他的 emits 对应输出;
- 全局注册 eventConfig.vue 组件;
- 定义对应组件的数据结构(按 3.1 组件 Rule),与 vue 组件中的输入输出对应起来,组件联动组件 eventConfig.vue 对应的 schema 为
{ type: 'eventConfig', field: '_on', title: '组件联动', props: { comType: name }, value: '' }。 这样自定义组件在渲染表单组件、配置面板属性就可以建立起联系,3.5 章节有具体的代码实现。 JSON 规则和组件映射关系参看官方文档:www.form-create.com/v3/guide/ru...

2.3 表单字段旧json结构适配转换
若当前方案从0到1接入业务,字段对应Schema按照Formcreate的rule规范。若想重构已有的配置方案,兼容历史数据,需要编写胶水代码,去做新旧Schema的适配转换以及无法对齐属性的兜底处理。
2.4 编排区生成
自定义基于 vuedraggable 的支持拖拽组件 dragBox.vue,以及选择组件操作(移动、删除)dragTool.vue,物料库中拖拽进入编排区的组件 rule,用 dragTool 组件包裹,即放置到 dragTool 的 children 中,dragTool 组件放到 dragBox 组件的 children 中,按此规则生成 JSON,JSON 与 FormCreate 组件绑定,即可完成表单编排的能力。
javascript
<script>
// 基于vuedraggable的支持拖拽组件dragBox.vue
export default {
name: 'DragBox',
props: ['rule', 'tag', 'formCreateInject'],
render(ctx) {
// 合并属性
const attrs = { ...ctx.$props.rule.props, ...ctx.$attrs };
// 拼接样式类
let _class = `_fd-${ctx.$props.tag}-drag _fd-drag-box`;
// 用于显示"空容器"提示样式
if (!Object.keys(ctx.$slots).length) {
_class += ' drag-holder';
}
attrs.class = _class;
// 拖拽数据源
attrs.modelValue = [...ctx.$props.formCreateInject.children];
// 目的:拖拽排序后,能根据数据的 key 找到对应的 vnode,保证渲染和数据一一对应。
const keys = {};
if (ctx.$slots.default) {
const children = ctx.$slots.default();
console.log(children, 'children');
children.forEach((v) => {
if (v.key) {
keys[v.key] = v;
}
});
}
// 渲染 draggable,每一项通过 item 插槽自定义
return h(Draggable, attrs, {
item: ({ element, index }) => {
const key = element?.__fc__?.key;
if (key) {
// 优先通过 slot 名查找 vnode
let vnode = keys[`_${element.slot}`];
if (vnode) {
vnode.children.forEach((v) => {
if (v.key === `${key}fc`) {
vnode = v;
}
});
} else {
vnode = keys[`${key}fc`];
}
if (vnode) {
return h('div', { class: `_fc-${ctx.$props.tag}-item _fd-drag-item` }, [vnode]);
}
}
return h('div', { class: `_fc-${ctx.$props.tag}-item _fd-drag-item` });
},
});
},
};
</script>
vue
<!-- 选择组件操作(移动、删除)dragTool.vue -->
<template>
<div class="_fd-drag-tool" @click.stop="active" :class="{ active: fcx.active === id }">
<div class="_fd-drag-mask" v-if="mask"></div>
<div class="_fd-drag-l">
<div class="_fd-drag-btn" v-if="fcx.active === id && dragBtn !== false">
<i class="fc-icon icon-move"></i>
</div>
</div>
<div class="drag-r">
<slot name="handle">
<div class="_fd-drag-btn" @click="$emit('create')">
<i class="fc-icon icon-add"></i>
</div>
<div class="_fd-drag-btn _fd-drag-danger" @click="$emit('delete')">
<i class="fc-icon icon-delete"></i>
</div>
</slot>
</div>
<slot name="default"></slot>
</div>
</template>
<script setup lang="ts" name="DragTool">
const emit = defineEmits(['delete', 'active']);
const props = defineProps({
dragBtn: Boolean,
children: String,
mask: Boolean,
formCreateInject: Object,
only: Boolean,
});
// 注入fcx
const fcx = inject('fcx', null);
// 计算id
const id = computed(() => props.formCreateInject?.id);
// 激活方法
function active() {
if (fcx && fcx.active === id.value) return;
if (fcx) fcx.active = id.value;
emit('active');
}
</script>
javascript
// 引入官方FormCreate,参考官方demo方案
import { designerForm } from '@/utils/form';
// 注册组件
components: {
FormDesign: designerForm.$form(),
},
html
<!-- 绑定rule生成编排区UI -->
<FormDesign :rule="dragForm.rule" :option="formOptions" v-model:api="dragForm.api" />
2.5 配置面板生成与配置数据同步到表单 Json
通常表单组件外层可能会有包裹,例如 el-input 通常外层会有 el-form-item 包裹,组件绑定属性有绑定到 el-input,例如 disabled、max-length、placeholder 等等,也有绑定到 el-form-item,例如 required、label、display 等等,这里通过配置面板中 field 属性是否加 $props_ 来区分,解析配置面板数据到表单 dsl 的关键代码如下:
javascript
// input.js对应的属性面板配置
props() {
return [
{
type: name,
field: 'title',
value: '',
title: '字段名称',
props: {
disabled: true,
},
},
{ type: 'input', field: 'value', title: '默认值' },
{ type: 'switch', field: '$required', title: '是否必填' },
{ type: 'switch', field: 'display', title: '是否显示', value: true },
{ type: 'switch', field: '$props_disabled', title: '是否禁用' },
{ type: 'switch', field: '$props_clearable', title: '是否显示清除按钮' },
{ type: 'input', field: '$props_placeholder', title: '输入框占位文本' },
{ type: 'input', field: 'info', value: '', title: '提示信息' },
{ type: 'inputNumber', field: '$props_maxlength', title: '最大输入长度' },
{ type: 'eventConfig', field: '_on', title: '组件联动', props: { comType: name } },
];
},
html
<!-- FormCreate渲染的配置面板,其中propsForm.rule对应上述input.js中的props内容 -->
<FormCreate
v-show="propsForm.isShow && activeTab === 'component'"
v-model:api="propsForm.api"
:rule="propsForm.rule"
:option="propsForm.options"
@change="propChange"
></FormCreate>
javascript
// 右侧面板配置变化更新表单dsl
propChange(key, value, field, _, fapi) {
const f = this;
if (key?.includes('$props_')) {
const attr = key.replace('$props_', '');
f.activeRule.props = { ...f.activeRule.props, [attr]: value };
} else {
f.activeRule[key] = value;
}
},
配置面板变化时,官方提供的 change 事件会触发,执行 propChange,从而更新选中组件的 rule。
2.6 目标表单渲染
表单编排时,表单组件会被 dragTool、dragBox 这些支持拖拽移动、添加、删除、选中等能力的工具组件包裹,目标表单 DSL 需要从其中提取出来,绑定到渲染模块中来实现预览或者流程节点中渲染的效果。解析出目标表单 DSL 关键代码如下:
javascript
/**
* 采用 reduce 累加方式,把符合要求的规则填入最终 initial 数组
* @param {Array} children - 规则数组
* @param {String} pSlot - 父级 slot
* @returns {Array}
*/
parseRule(children, pSlot) {
const f = this;
return [...children].reduce((initial, rule) => {
let slot = pSlot;
// dragBox 类型递归展开
if (rule.type === 'dragBox') {
initial.push(...f.parseRule(rule.children, slot || rule.slot));
return initial;
} else if (rule.type === 'dragTool') {
// dragTool 只取其第一个子项
slot = rule.slot || pSlot;
rule = rule.children[0];
if (rule.type === 'dragBox') {
initial.push(...f.parseRule(rule.children, slot || rule.slot));
return initial;
}
}
if (!rule) return initial;
rule = { ...rule };
// 处理 slot
if (slot && slot !== 'default') {
rule.slot = slot;
}
// 递归处理子项
if (rule.children && rule.children.length) {
rule.children = f.parseRule(rule.children);
}
// 删除设计器内部字段
if (rule._menu) {
delete rule._menu;
}
if (!rule.slot) {
delete rule.slot;
}
// 删除 props 中为 null 的属性
rule.props &&
Object.keys(rule.props).forEach((k) => {
const v = rule.props[k];
if (isNull(v)) {
delete rule.props[k];
}
});
// 删除 __ 开头、空数组、空对象等冗余字段
Object.keys(rule)
.filter(
(k) =>
k.indexOf('__') === 0 ||
(Array.isArray(rule[k]) && rule[k].length === 0) ||
(is.Object(rule[k]) && Object.keys(rule[k]).length === 0),
)
.forEach((k) => {
delete rule[k];
});
initial.push(rule);
return initial;
}, []);
},
解析出目标表单 DSL 后,与 FormCreate 组件结合即可渲染出表单。
通常配置表单还需要有组件个性化交互以及关联逻辑联动效果,以更好的提高配置表单的操作体验。敬请关注下一章节"自定义组件联动事件"。