Elpis-动态组件设计

210 阅读6分钟

前面完成了基本的框架建设,能够通过 DSL 渲染出一个常用的列表页。但是主要还是偏展示性,其中的查询、表格翻页点击事件基本算是表格必备功能,具体的实现代码也都分别跟搜索、表格组件写在一起。

实际的需求肯定不止这些,我们还需要一些其他的交互,例如:

  1. 在表格头部,添加新增按钮,点击新增按钮可以弹窗显示一个表单组件,允许用户填写表格并保存,保存后关闭弹窗并且刷新表格。
  2. 表格最后一列是各种操作按钮,如查看详情、编辑、删除等,其中编辑的交互更新增交互类似。

因此需要增加自定义的 动态组件,根据不同的操作展示不同组件,并完成一系列功能。因为最终的目的是通过一套数据生成整个项目,因此这里还是从 DSL 设计开始说起。

这里先回顾一下DSL的基本结构

项目是以菜单(menu)为顶级维度,即页面是某个菜单页面。

module.exports = {
  model: "dashboard",
  name: "电商系统",
  menu: [
      {
          key: "product",
          name: "商品管理",
          menuType: "module",
          moduleType: "schema",
          schemaConfig: {
              api: "/api/proj/product",
              schema: {},
              tableConfig: {},
              componentConfig: {},
          }
     }
  ]
}

schema:一个标准的 JSON-schema 格式的数据,描述了这个页面用到的原子数据以及在不同模版组件下的配置,如 tableOption、xxxOption、yyyOption。

tableConfig:表格整体配置,如表格头部按钮,表格最后一列的操作等。

componentConfig:使用到的动态组件以及动态组件的具体配置,如 createForm 会和schema的createFormOption 对应上。

schema

以动态组件createForm为例。

  type: "object",
  properties: {
    product_id: {
      type: "string",
      label: "商品ID",
    },
    product_name: {
      type: "string",
      label: "商品名称",
      createFormOption: {
        comType: "input",
      },
    },
    price: {
      type: "number",
      label: "价格",
      createFormOption: {
        comType: "inputNumber",
      },
    },
    inventory: {
      type: "number",
      label: "库存",
      createFormOption: {
        comType: "select",
        enumList: [
          {
            label: "100",
            value: 100,
          },
          {
            label: "500",
            value: 500,
          },
          {
            label: "1000",
            value: 1000,
          },
        ],
      },
    },
  },

componentConfig的配置是createForm的整体配置,而不是具体的每个字段。

componentConfig: {
  createForm: {
    title: "新增商品",
    saveBtnText: "确定",
  },
}

先说说为什么要这个设计数据结构。有些人可能不是在schema里面使用 createOption标记 ,而是会把 createForm 动态组件要渲染的字段配置都写在 componentConfig.createForm 里面,这貌似比较符合正常的思维,维护的时候一眼就可以看到有哪些字段。

当组件只有一两个的时候,确实没有太大问题,但是如果组件很多(实际上确实会有很多,而且可以不断扩充),那么将会出现很多重复的配置。

{
    createForm: {
        title: "新增商品",
        saveBtnText: "确定",    
        product_name: {
            comType: "input",
            defaultValue: "",
            // ...
        }
        price: {
            comType: "inputNumber",
            defaultValue: null
        },
    },
    editForm: {
        title: "新增商品",
        saveBtnText: "确定",
        //  createForm 多了商品id,保存的时候要回传
        product_id: {
            disabled: true,
            comType: "input",
        },
        product_name: {
            comType: "input",
            defaultValue: "",
            // ...
        }
        price: {
            comType: "inputNumber",
            defaultValue: null,
            // ...
        },
    },
}
// 其他动态组件

如果按照以上写法,越往后会越臃肿,而且相同内容重复多次,维护的时候不好分辨,容易出错。有个软件开发原则叫 DRY(Don’t Repeat Yourself),不要重复你自己,即减少信息的重复。所以最终选择在schema.properties的字段里插入createFormOption,表示这个字段将会出现先 createForm表单里面。其他组件同理,例如编辑表单的配置是editFormOptioncomponentConfig.editForm,一个是具体字段,一个是表单整体的配置。 schema.properties这个字段可以称得上核心的核心,表格、新建表单、编辑表单、详情等组件都是围绕着这个份数据进行处理。


既然createOption分散在各个字段,那组件渲染的时候肯定要把他们提取出来后,处理一番,再进行渲染。

最先拿到数据的是 app/pages/dashboard/complex-view/schema-view/hook.js,部分处理逻辑如下:

if (componentConfig && Object.keys(componentConfig).length > 0) {
  const dtoComponents = {};
  for (const comName in componentConfig) {
    dtoComponents[comName] = {
      // 例如:product_name 里面的 comAOption 进行转换
      schema: buildDtoSchema(configSchema, comName),
      // 这是动态组件的配置
      config: componentConfig[comName],
    };
  }
  components.value = dtoComponents;
}

最终得出 components,格式为 { compName: { schema, config }, ... } ,通过 provide ,在 schema-view.vue 这个顶层组件注入,后续要使用的时候直接 inject 就可以了。


schema-view.vue 文件,和表格容器平级的地方,遍历 components,把动态组件组件都添加上。

image.png

注意要绑定 ref="comListRef",需要通过ref拿到组件实例,调用内部的方法。

components 数据格式大致如下:

{
  createForm: {
      schema: {
        type: "object",
        properties: {
          product_name: {
            type: "string",
            label: "商品名称",
            maxLength: 8,
            minLength: 4,
            option: {
              comType: "input",
              required: true,
            },
          },
          price: {
            type: "number",
            label: "价格",
            minimum: 1,
            maximum: 1000,
            option: {
              comType: "inputNumber",
            },
          },
          inventory: {
            type: "number",
            label: "库存",
            option: {
              comType: "select",
              enumList: [
                {
                  label: "100",
                  value: 100,
                },
                {
                  label: "500",
                  value: 500,
                },
                {
                  label: "1000",
                  value: 1000,
                },
              ],
            },
          },
        },
      },
      config: {
        title: "新增商品",
        saveBtnText: "确定",
      },
    };
}

接下来就是动态组件的开发,结构大致如下:

image.png

FormItemConfig 是表单要用到的组件二次封装,统一加了getValue、validate方法。

import input from "./complex-view/input/input.vue";
import inputNumber from './complex-view/input-number/input-number.vue'
import select from './complex-view/select/select.vue'

const FormItemConfig = {
  input: {
    component: input,
  },
  inputNumber: {
    component: inputNumber,
  },
  select: {
    component: select,
  },
};

export default FormItemConfig;

schema-form.vue

<template>
  <el-row v-if="schema && schema.properties" class="schema-form">
    <template v-for="(itemSchema, key) in schema.properties">
      <component
        :is="FormItemConfig[itemSchema.option?.comType]?.component"
        v-show="itemSchema.option.visible !== false"
        ref="formComList"
        :schema-key="key"
        :schema="itemSchema"
        :model="model ? model[key] : undefined"
      />
    </template>
  </el-row>
</template>

<script setup>
import { ref, toRefs, provide } from "vue";
import FormItemConfig from "./form-item-config";

const Ajv = require("ajv");
const ajv = new Ajv();

provide("ajv", ajv);

const formComList = ref([]);

const props = defineProps({
  schema: Object,
  // editForm 表单数据回填
  model: Object,
});
const { schema, model } = toRefs(props);
const validate = () => {
  return formComList.value.every((item) => {
    const res = item.validate();
    return res;
  });
};
// 获取表单数据
const getValue = () => {
  return formComList.value.reduce(
    (dtoObj, item) => ({ ...dtoObj, ...item.getValue() }),
    {}
  );
};

defineExpose({
  validate,
  getValue,
});
</script>

schema-form 的 getValue 和 validate ,会遍历每个表单子组件的字段值和校验,因此子组件内部也必须实现 getValue和validate方法。


接下来是点击新建按钮,触发弹窗显示,填写表单后,点保存提交数据并关闭弹窗,刷新表格,完成整个流程的串联。

DSL 的 tableConfig 配置

tableConfig: {
  headerButtons: [
    {
      label: "新增",
      // showComponent 是自己定义的,这里怎么定义,组件内部也要用相同的名字
      eventKey: "showComponent",
      type: "primary",
      plain: true,
      eventOption: {
        comName: "createForm",
        // 其他配置,如 params 等
      },
    },
  ],
}

点击按钮的时候,把一行的数据(编辑才有,新建没有)和按钮的配置传给处理函数,因此可能拿到eventKey和eventOption.comName。如果是 showComponent,就会调起组件内写好的 showComponent 方法。因为所有的动态组件都已经挂载了,eventOption.comName,找到对应的组件(这里是CreateForm),调用组件内部写好的 show 方法显示弹窗和表单。点保存按钮时, 调用 validate和 getValue 方法,通用校验后,提交数据,成功则使用 notification 进行提示,并且通知父组件更新表格 emit("command", { event: "loadTableData" })

至此,通过一份配置,生成一个项目的列表页基本完成。

elis-demo.gif

出处:《哲玄课堂-大前端全栈实践》