Vue3引入Formily实践实录

此文章记录从调研、了解、熟悉、运用、延伸Formily的过程

调研

最近收到了一个任务,需要实现一个动态的表单。效果如下:

  • 图一

  • 图二

「主要逻辑」:第一个下拉框的数据能决定第二个下拉框的内容,第二个下拉框的内容是远程加载的;第三个下拉框的内容能决定后续控件的形式;后面的加号是增加行,括号是增加括号,删除是删除这一行。最后的检索式,是整体表单计算出来的。

「分析」:这个表单逻辑和联动都比较复杂和频繁,而且有些还是远程获取数据,并不是写死的。如果说用elementplus的组件来做,处理逻辑、联动和数据回显的时候都会比较麻烦,肯定不简洁。我之前有做过一个小型的复杂表单联动,有过这种手动处理逻辑和联动的麻烦经历。所以我就决定不使用之前的方式处理现在的业务,需要寻找一个新的方式来解决这个需求。

「方案」:在这里我就省去了找解决方案的过程,直接说答案,最后是决定使用Formily。原因有几个:

  • 大厂出品:后续有不会的地方,能在网上搜得到
  • 生态丰富:涵盖了vue和react,elementui、elementplus、antd等不同的版本

了解

我此次负责的项目的技术栈是Vue3,所以我主要去了解了以下几个模块:

  1. 「主站文档」首先是对Formily进行了解释,说明了Formily这个产品的定位和功能,其次的「场景案例」以及「进阶指南」的代码实例非常友好(案例使用react写的,使用vue的时候有一些差别)。读完此文档后对于Formily有了一定的了解;除此之外,主站中的API内容在后续实践中比较重要,也需要熟悉。

  2. 「Vue核心库」中,讲解了核心架构以及核心概念,核心概念中最重要的是三种开发模式:

  • Template 开发模式

    该模式主要是使用 Field/ArrayField/ObjectField/VoidField 组件

html 复制代码
<template>
  <FormProvider :form="form">
    <Field name="input" :component="[Input, { placeholder:'请输入' }]" />
  </FormProvider>
</template>

<script>
  import { Input } from 'ant-design-vue'
  import { createForm } from '@formily/core'
  import { FormProvider, Field } from '@formily/vue'
  import 'ant-design-vue/dist/antd.css'

  export default {
    components: { FormProvider, Field },
    data() {
      return {
        Input,
        form: createForm(),
      }
    },
  }
</script>
  • JSON Schema 开发模式

该模式是给 SchemaField 的 schema 属性传递 JSON Schema 即可

html 复制代码
<template>
   <FormProvider :form="form">
     <SchemaField :schema="schema" />
   </FormProvider>
 </template>

 <script>
   import { Input } from 'ant-design-vue'
   import { createForm } from '@formily/core'
   import { FormProvider, createSchemaField } from '@formily/vue'
   import 'ant-design-vue/dist/antd.css'

   const { SchemaField } = createSchemaField({
     components: {
       Input,
     },
   })

   export default {
     components: { FormProvider, SchemaField },
     data() {
       return {
         form: createForm(),
         schema: {
           type: 'object',
           properties: {
             input: {
               type: 'string',
               'x-component': 'Input',
               'x-component-props': {
                 placeholder: '请输入',
               },
             },
           },
         },
       }
     },
   }
 </script>
  • Markup Schema 开发模式

    该模式算是一个对源码开发比较友好的 Schema 开发模式,同样是使用 SchemaField 相关组件。

    Markup Schema 模式主要有以下几个特点:

    • 主要依赖 SchemaStringField/SchemaArrayField/SchemaObjectField...这类描述标签来表达 Schema
    • 每个描述标签都代表一个 Schema 节点,与 JSON-Schema 等价
    • SchemaField 子节点不能随意插 UI 元素,因为 SchemaField 只会解析子节点的所有 Schema 描述标签,然后转换成 JSON Schema,最终交给RecursionField渲染,如果想要插入 UI 元素,可以在 SchemaVoidField 上传x-content属性来插入 UI 元素
html 复制代码
<template>
  <FormProvider :form="form">
    <SchemaField>
      <SchemaStringField
        x-component="Input"
        :x-component-props="{ placeholder: '请输入' }"
      />
      <div>我不会被渲染</div>
      <SchemaVoidField x-content="我会被渲染" />
      <SchemaVoidField :x-content="Comp" />
    </SchemaField>
  </FormProvider>
</template>

<script>
  import { Input } from 'ant-design-vue'
  import { createForm } from '@formily/core'
  import { FormProvider, createSchemaField } from '@formily/vue'
  import 'ant-design-vue/dist/antd.css'

  const SchemaComponents = createSchemaField({
    components: {
      Input,
    },
  })

  const Comp = {
    render(h) {
      return h('div', ['我也会被渲染'])
    },
  }

  export default {
    components: { FormProvider, ...SchemaComponents },
    data() {
      return {
        form: createForm(),
        Comp,
      }
    },
  }
</script>
  1. 「Formily elementplus」

这个就类似于组件库,讲解具体组件如何使用。

熟悉

这个阶段熟悉了一些官网案例的用法。在三种使用模式中,最后选择了「JSON Schema」模式,这种模式组件看着更简洁,只需掌握配置规则。

运用

  • 因为 Element-Plus 是基于 Sass 构建的,如果你用 Webpack 配置请使用以下两个 Sass 工具
json 复制代码
"sass": "^1.32.11",
"sass-loader": "^8.0.2"
  • 安装
ruby 复制代码
$ npm install --save element-plus
$ npm install --save @formily/core @formily/vue @vue/composition-api @formily/element-plus

我的目录结构是这样:

核心的Formily代码在「filter.vue』中,JSON配置我提炼到了「form_obj.js」里

filter.vue:

html 复制代码
<template>
  <FormProvider :form="form" class="lkkkkkkk">
    <SchemaField :schema="schema" :scope="{ useAsyncDataSource, loadData }" />
    <div class="btn flex flex-right">
      <div class="btn-inner">
        <el-button type="primary" plain :disabled="valid" @click="saveFilter">保存</el-button>
        <el-button type="primary" plain @click="resetFilter">重置</el-button>
        <Submit plain @submit-failed="submitFailed" @submit="submit">查询</Submit>
      </div>
    </div>
  </FormProvider>
</template>

<script setup>
  import { createForm } from '@formily/core';
  import { FormProvider, createSchemaField } from '@formily/vue';
  import {
    Submit,
    FormItem,
    Space,
    Input,
    Select,
    DatePicker,
    ArrayItems,
    InputNumber,
  } from '@formily/element-plus';
  import conditionResult from './conditionResult.vue';
  import { onMounted, ref } from 'vue';
  import { action } from '@formily/reactive';
  import { getFormObj, arrToText } from './form_obj';
  import { setLocal, getLocal } from '@/utils';

  const { SchemaField } = createSchemaField({
    components: {
      FormItem,
      Space,
      Input,
      Select,
      DatePicker,
      ArrayItems,
      InputNumber,
      conditionResult,
    },
  });

  const form = createForm();
  const schema = ref();
  const fieldMap = new Map();
  const valid = ref(true);

  const fieldCollect = (arr) => {
    arr.forEach((item) => {
      fieldMap.set(item.value, item);
    });
  };
  // 模拟远程加载数据
  const loadData = async (field) => {
    const table = field.query('.table').get('value');
    if (!table) return [];
    return new Promise((resolve) => {
      setTimeout(() => {
        if (table === 1) {
          const arr = [
            {
              label: 'AAA',
              value: 'aaa',
            },
            {
              label: 'BBB',
              value: 'ccc',
            },
          ];
          resolve(arr);
          fieldCollect(arr);
        } else if (table === 2) {
          const arr = [
            {
              label: 'CCC',
              value: 'ccc',
            },
            {
              label: 'DDD',
              value: 'ddd',
            },
          ];
          resolve(arr);
          fieldCollect(arr);
        }
      }, 1000);
    });
  };

  // 远程数据处理
  const useAsyncDataSource = (service) => (field) => {
    field.loading = true;
    service(field).then(
      action.bound((data) => {
        field.dataSource = data;
        field.loading = false;
      })
    );
  };

  // 获取表数据
  const getTables = () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve([
          { label: '就诊信息表', value: 1 },
          { label: '诊断信息表', value: 2 },
        ]);
      }, 1000);
    });
  };

  // 初始化表单
  const initForm = async () => {
    const tables = await getTables();
    schema.value = getFormObj(tables);
    const originFilter = getLocal('formily');
    // 设置初始值或者是回显值
    if (originFilter) {
      form.setInitialValues(originFilter);
      // form.setInitialValues({
      //   array: [
      //     {
      //       table: 1,
      //       field: '',
      //       condition: 'contain',
      //       text: '',
      //       relationship: 'none',
      //       bracket: 'none',
      //     },
      //   ],
      //   escape: '',
      // });
    }
  };

  // 保存
  const saveFilter = () => {
    setLocal('formily', form.values);
  };
  // 重置
  const resetFilter = () => {
    form.setValues(getLocal('formily'));
  };
  // 查询
  const submit = (values) => {
    // 将数组转换成中文释义。
    const sentence = arrToText(fieldMap, values.array);
    console.log(sentence);
    // 将值设置到检索式中
    form.setValuesIn('escape', sentence);
    valid.value = false;
  };
  const submitFailed = () => {
    valid.value = true;
  };
  onMounted(() => {
    initForm();
  });
</script>

form_obj.js:

js 复制代码
...
// Formily配置
export function getFormObj(tables) {
  return {
    type: 'object',
    properties: {
      array: {
        type: 'array',
        'x-component': 'ArrayItems',
        'x-decorator': 'FormItem',
        title: '检索条件',
        items: {
          type: 'object',
          properties: {
            space: {
              type: 'void',
              'x-component': 'Space',
              properties: {
                sort: {
                  type: 'void',
                  'x-decorator': 'FormItem',
                  'x-component': 'ArrayItems.SortHandle',
                },
                table: {
                  type: 'string',
                  title: '信息表',
                  enum: tables,
                  required: true,
                  'x-decorator': 'FormItem',
                  'x-component': 'Select',
                  'x-component-props': {
                    style: {
                      width: '160px',
                    },
                  },
                },
                field: {
                  type: 'string',
                  title: '字段',
                  required: true,
                  // default: 1,
                  // enum: [
                  //   { label: '入院年龄', value: 1 },
                  //   { label: '主要诊断', value: 2 },
                  //   { label: '手术名称', value: 3 },
                  // ],
                  'x-decorator': 'FormItem',
                  'x-component': 'Select',
                  'x-component-props': {
                    style: {
                      width: '160px',
                    },
                  },
                  'x-reactions': ['{{useAsyncDataSource(loadData)}}'],
                },
                condition: {
                  type: 'string',
                  title: '条件',
                  required: true,
                  //   default: 'contain',
                  enum: conditionArr,
                  'x-decorator': 'FormItem',
                  'x-component': 'Select',
                  'x-component-props': {
                    style: {
                      width: '130px',
                    },
                  },
                },
                text: {
                  type: 'string',
                  required: true,
                  'x-decorator': 'FormItem',
                  'x-component': 'Input',
                  'x-reactions': [
                    {
                      dependencies: ['.condition'],
                      fulfill: {
                        state: {
                          visible: "{{$deps[0] === 'contain'}}",
                        },
                      },
                    },
                  ],
                  'x-component-props': {
                    style: {
                      width: '160px',
                    },
                    placeholder: '请选择',
                  },
                },
                range: {
                  type: 'object',
                  properties: {
                    space: {
                      type: 'void',
                      'x-component': 'Space',
                      properties: {
                        start: {
                          type: 'number',
                          required: true,
                          'x-reactions': `{{(field) => {
                            field.selfErrors =
                              field.query('.end').value() <= field.value ? '左边必须小于右边' : ''
                          }}}`,
                          //   default: 1,
                          'x-decorator': 'FormItem',
                          'x-component': 'InputNumber',
                          'x-component-props': {
                            style: {
                              width: '150px',
                            },
                            placeholder: '左临界数值',
                          },
                        },
                        end: {
                          type: 'number',
                          required: true,
                          //   default: 10,
                          'x-decorator': 'FormItem',
                          'x-component': 'InputNumber',
                          'x-reactions': {
                            dependencies: ['.start'],
                            fulfill: {
                              state: {
                                selfErrors: "{{$deps[0] >= $self.value ? '左边必须小于右边' : ''}}",
                              },
                            },
                          },
                          'x-component-props': {
                            style: {
                              width: '150px',
                            },
                            placeholder: '右临界数值',
                          },
                        },
                      },
                    },
                  },
                  'x-reactions': [
                    {
                      dependencies: ['.condition'],
                      fulfill: {
                        state: {
                          visible: "{{$deps[0] === 'range'}}",
                        },
                      },
                    },
                  ],
                },
                relationship: {
                  type: 'string',
                  title: '关系',
                  required: true,
                  //   default: '',
                  enum: relationArr,
                  'x-decorator': 'FormItem',
                  'x-component': 'Select',
                  'x-component-props': {
                    style: {
                      width: '160px',
                    },
                  },
                },
                bracket: {
                  type: 'string',
                  title: '括号',
                  required: true,
                  //   default: '',
                  enum: bracketArr,
                  'x-decorator': 'FormItem',
                  'x-component': 'Select',
                  'x-component-props': {
                    style: {
                      width: '130px',
                    },
                  },
                },
                copy: {
                  type: 'void',
                  'x-decorator': 'FormItem',
                  'x-component': 'ArrayItems.Copy',
                },
                remove: {
                  type: 'void',
                  'x-decorator': 'FormItem',
                  'x-component': 'ArrayItems.Remove',
                },
              },
            },
          },
        },
        properties: {
          add: {
            type: 'void',
            title: '添加条目',
            'x-component': 'ArrayItems.Addition',
          },
        },
      },
      // properties: {
      // },
      escape: {
        type: 'string',
        title: '检索式',
        'x-component': 'conditionResult',
        'x-decorator': 'FormItem',
      },
    },
  };
}
...

此案例的完整代码,我放在我的github了,有需要自取。

延伸

完成这个案例之后,后续还有一个类似的表单需求,我本来是准备用这个来做的,但是这个需求是要放到IE上运行,所以我留了一个心眼。先写了一个小案例,测试了一下Vue2+elementui+Formily打包后在IE浏览器能否运行,最后发现是不可以。

后续再查资料中发现确实是不兼容IE的,大家在使用的时候要考虑这个场景。

总结

以上就是在Vue3中引入Formily解决需求的过程,经历了调研、了解、熟悉、运用的过程,Formily是一个比较好的表单处理工具,解决了表单联动、逻辑处理和回显的痛点,如果大家遇到此类需求,可以考虑一下使用这个工具,但是此工具不兼容IE的情况也要考虑进去。「吐槽一句就是,Formily文档写得其实不是很明朗」

ok,就这样!下一篇文章再见

相关推荐
咖啡の猫32 分钟前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲3 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5813 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路4 小时前
GeoTools 读取影像元数据
前端
ssshooter4 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry5 小时前
Jetpack Compose 中的状态
前端
dae bal6 小时前
关于RSA和AES加密
前端·vue.js
柳杉6 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog6 小时前
低端设备加载webp ANR
前端·算法
LKAI.6 小时前
传统方式部署(RuoYi-Cloud)微服务
java·linux·前端·后端·微服务·node.js·ruoyi