从0到1搭建通用低代码平台(-)— 表单设计器

文章内容是基于作者个人在真实项目中的技术实践(脱离业务)与独立思考,旨在输出经验,共同探讨。

gitHub:

一、方案概述

1.1 方案选型

此方案是基于一款以数据为引擎的表单渲染器FormCreate来搭建表单设计器,gitHub地址:htt ps://github.com/xaboy/form-create。 该渲染器与笔者所在业务线代码技术栈适配度较高,表现在:

  1. 技术栈支持Vue2+element-ui及Vue3+elmenent-plus,与作者所支持的业务线应用技术栈匹配; 2.内置组件丰富,element的组件都可作为内置组件使用,且支持自定义组件,无需维护大量的基础vue组件;
  2. 官方文档友好,每种类型组件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 规则自定义组件步骤如下:

  1. 封装所需的 vue 组件,定义输入输出,一般 vue 组件父子通信的 props 对应输入,modelValue 以及其他的 emits 对应输出;
  2. 全局注册 eventConfig.vue 组件;
  3. 定义对应组件的数据结构(按 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 组件结合即可渲染出表单。

通常配置表单还需要有组件个性化交互以及关联逻辑联动效果,以更好的提高配置表单的操作体验。敬请关注下一章节"自定义组件联动事件"。

相关推荐
吃饺子不吃馅1 小时前
那就和前端好好道个别吧
前端
_瑞1 小时前
试图教会你用 Xcode Instruments
前端·ios·xcode
海带紫菜菠萝汤2 小时前
WebCodecs API 实战:浏览器原生视频编解码的原理与性能测试
前端·javascript·音视频·视频编解码
一位正在转型AI全栈的前端工程师2 小时前
AI 全栈学习之旅 - Week2:从零搭建一个可部署的 AI 聊天应用
前端
aixingpan2 小时前
aixingpan.cn API开发文档:api_docs_trichart_natal_solararc_transit2接口指南
前端·php
Ayayoyo2 小时前
公平随机转盘的前端实现:Web Crypto API、拒绝采样与加权抽取
前端
程序员黑豆3 小时前
鸿蒙应用开发:@Link 装饰器实现父子组件双向同步
前端·后端·harmonyos
前端炒粉3 小时前
简易实现ssr
开发语言·前端·javascript
顶级自由人3 小时前
【前端菜鸟的补课01】Zod 与 PostgreSQL 全栈数据工程教学
前端·后端·程序员