一聚教程网:一个值得你收藏的教程网站

热门教程

四、elpis 基于 vue3 完成动态组件库建设

时间:2026-08-18 11:50:51 编辑:袖梨 来源:一聚教程网

四、elpis 基于 vue3 完成动态组件库建设并不只看表面做法,关键还要理解相关条件、限制和后续影响。

开发一些组件来建设组件库以及扩展 DSL

四、elpis 基于 vue3 完成动态组件库建设

组件 DSL 设计

schema-table

表格内容

  1. 表格组件由列字段控制渲染,表格数据可通过搜索条件筛选
  2. 常见的表格头部以及每行末尾可能会出现按钮,这些按钮主要用来交互

对于交互,需要知道事件回调以及动态参数值。事件回调函数可通过事件名确定,对于动态参数,可能来源于表格,也可能来源于外部,比如获取当前时间,所以可以添加一些专有标识,在组件内部分辨获取

DSL 设计:我们在 properties.key 中通过 tableOption 设置当前列字段在表格中的配置,然后在外部 tableConfig 中对表格整体设置,比如 headerButtons 以及每行的操作按钮 rowButtons

// schema-table dslconst schemaConfig = {  api: string;  schema: {    type: object,    properties: {      [key]: {        // ...标准 json-schema 配置tableOption: {          // ...ElTableColumnConfig,visible: boolean, // 是否展示        },      },    },  },  tableConfig: {    headerButtons: [        {            // ...elButtonConfig,eventKey: string; // 触发的事件名,如remove showComponenteventOption: {                // 当 eventKey = showComponentcomName: string; // "组件名"// 当 eventKey = remove// 比如说删除某条数据时,向后端传递参数 time,该字段值来源于表格中 createTime 字段params: {                    /**                     * paramKey: {string} 参数名称                     * rowValueKey: {string} 如果 rowValueKey 是 `schema::${rowValueKey}` 这样的格式,表示从表格中获取值                     */                    [paramKey: string]: rowValueKey,                }            }        }    ],    // 同 headerButtonsrowButtons: [],  },};

schema-search-bar

搜索组件内容

  1. 每个搜索表单项会渲染不同的组件,也可以赋予默认值
  2. 只用来筛选,最终影响的结果是表格,所以请求不发生在该组件内

DSL 设计:我们在 properties.key 中通过 searchOption 设置表单项的组件类型以及默认值,然后在外部 searchConfig 中对搜索组件整体设置,比如 searchButtons

const schemaConfig = {  schema: {    type: object,    properties: {      [key]: {        // ...标准 json-schema 配置searchOption: {          // ...xxComponentConfig, // 包括 elementui、自定义组件、其他组件visible: boolean, // 是否展示comType: "", // 组件类型default: "", // 默认值        },      },    },  },  searchConfig: {    searchButtons: [        {            // ...elButtonConfig,label: '搜索',            eventKey: string; // 触发的事件名 searchvisible: true,        },        {            // ...elButtonConfig,label: '清空',            eventKey: string; // 触发的事件名 clearvisible: false,        },    ],  },};

schema-form

表单内容

  1. 每个表单项会渲染不同的组件,也可以赋予默认值,设置 disable 等
  2. 一个页面可能会有多个表单,需要避免操作冲突
  3. 同一页面存在多个一样的组件的需求还有很多,需要考虑扩展维护问题

DSL 设计:我们在 properties.key 中通过 xxxFormOption 表示某个表单项的配置,然后在外部 componentConfig 下设置多个表单(xxxForm)的整体配置,类似的,对于同类型组件,通过 xxx${compType} 设置。

const schemaConfig = {  schema: {    type: object,    properties: {      [key]: {        // ...标准 json-schema 配置xxxFormOption: {          // ...elFormItemConfig,visible: boolean, // 是否展示comType: "", // 组件类型default: "", // 默认值disabled: false,          // 根据组件类型扩展传递该组件必要的内容,如当 comType = 'select' 时,需要枚举列表enumList: [],          api: "", // 动态select        },      },    },  },  componentConfig: {    xxxForm: {        title: string; // 表单标题saveBtnText: string; // 保存按钮mainKey: string; // 表单标识    }  }};

schema 标准化处理

对于组件来说应该只传递必要的内容,而 key 不一定在所有组件中都渲染

在将 schema 配置传递给组件前,先筛选出当前渲染组件的 schema 和 config,将 xxxOption 改为 option,清除噪音,代码更加直观

// app/pages/dashboard/complex-view/schema-view/hooks/schema.jsimport { ref, watch, onMounted, nextTick } from"vue";import { useRoute } from"vue-router";import { useMenuStore } from"$store/menu.js";exportconst useSchema = function () {  const route = useRoute();  const menuStore = useMenuStore();  const api = ref("");  const tableSchema = ref({});  const tableConfig = ref({});  const searchSchema = ref({});  const searchConfig = ref({});  const components = ref({});  // 构造 schemaConfig 相关配置,输送给 schemaViewconstbuildData = () => {    const { sider_key: sideKey, key } = route.query;    const mItem = menuStore.findMenuItem({      key: "key",      value: sideKey ?? key,    });    if (mItem && mItem.schemaConfig) {      const { schemaConfig: sConfig } = mItem;      // 对象的深拷贝实现const configSchema = JSON.parse(JSON.stringify(sConfig.schema));      api.value = sConfig.api ?? "";      tableSchema.value = {};      tableConfig.value = undefined;      searchSchema.value = {};      searchConfig.value = undefined;      components.value = {};      nextTick(() => {        // 构造 tableSchema 和 tableConfig        tableSchema.value = buildDtoSchema(configSchema, "table");        tableConfig.value = sConfig?.tableConfig || {};        // 构造 searchSchema 和 searchConfigconst dtoSearchSchema = buildDtoSchema(configSchema, "search");        for (const key in dtoSearchSchema.properties) {          if (route.query[key] !== undefined) {            // 设置默认值            dtoSearchSchema.properties[key].option.default = route.query[key];          }        }        searchSchema.value = dtoSearchSchema;        searchConfig.value = sConfig?.searchConfig || {};        // 构造 components = { comKey: { schema: {}, config: {} }}const { componentConfig } = sConfig;        if (componentConfig && Object.keys(componentConfig).length >0) {          const dtoComponents = {};          for (const comName in componentConfig) {            dtoComponents[comName] = {              schema: buildDtoSchema(configSchema, comName),              config: componentConfig[comName],            };          }          components.value = dtoComponents;        }      });    }  };  // 通用构建 schema 方法(清除噪音 将 xxOption 统一用 option 访问)constbuildDtoSchema = (_schema, compName) => {    if (!_schema?.properties) return {};    const dtoSchema = {      type: "object",      properties: {},    };    for (const key in _schema.properties) {      const props = _schema.properties[key];      if (props[`${compName}Option`]) {        let dtoProps = {};        // 提取 props 中非 option 的部分,存放到 dtoProps 中for (const pKey in props) {          if (pKey.indexOf("Option") < 0) {            dtoProps[pKey] = props[pKey];          }        }        // 处理 compName Option        dtoProps = Object.assign({}, dtoProps, {          option: props[`${compName}Option`],        });        // 处理 required 字段const { required } = _schema;        if (required && required.find((pk) => pk == key)) {          dtoProps.option.required = true;        }        dtoSchema.properties[key] = dtoProps;      }    }    return dtoSchema;  };  watch(    [      () => route.query.key,      () => route.query.sider_key,      () => menuStore.menuList,    ],    () => {      buildData();    },    {      deep: true,    },  );  onMounted(() => {    buildData();  });  return {    api,    tableSchema,    tableConfig,    searchSchema,    searchConfig,    components,  };};

组件实现

schema-table

// app/pages/widgets/schema-table/schema-table.vue<template>  <div class="schema-table">    <el-table      v-if="schema && schema.properties"      v-loading="loading"      class="table"      :data="tableData"    >      <template v-for="(schemaItem, key) in schema.properties">        <el-table-column          v-if="schemaItem.option.visible !== false"          :key="key"          :prop="key"          :label="schemaItem.label"          v-bind="schemaItem.option"        />      </template>      <el-table-column        v-if="buttons?.length > 0"        label="操作"        fixed="right"        :width="operationWidth"        :prop="key"        v-bind="schema.option"      >        <template #default="scope">          <el-button            v-for="btnItem in buttons"            :key="btnItem.label"            v-bind="btnItem"            link            @click="              operationHandler({ btnConfig: btnItem, rowData: scope.row })            "          >            {{ btnItem.label }}          </el-button>        </template>      </el-table-column>    </el-table>    <el-row justify="end" class="pagination">      <el-pagination        :current-page="currentPage"        :page-size="pageSize"        :page-sizes="[10, 20, 50, 100, 200]"        layout="prev, pager, next, jumper, ->, total"        :total="total"        @size-change="onPageSizeChange"        @current-change="onCurrentPageChange"      />    </el-row>  </div></template><script setup>import { ref, toRefs, computed, watch, nextTick, onMounted } from "vue";import $curl from "$common/curl";const props = defineProps({  /**   * schema 配置,结构如下:   * {   *  type: "object",   *   properties: {   *     key: {   *       ...schema, // 标准 schema 配置   *       type: "", // 字段类型   *       label: "", // 字段的中文名   *       // 字段在 xxxComp 组件中的相关配置   *       option: {   *         ...elxxxCompColumnConfig, // 标准 el-xxx 配置   *         visible: true, // 自定义字段 默认为 true(false 或 不配置时,表示不在表单中显示)   *       },   *     },   *   },   * }   */  schema: Object,  /**   * 表格数据源 api   */  api: String,  /**   * buttons 操作按钮相关配置,结构如下:   * [   *  {   *    label: '',   *    eventKey: '', // 按钮事件名   *    eventOption: {}, // 按钮具体配置   *    ...elButtonConfig,   *  }   * ]   *   */  apiParams: Object,  buttons: Array,});const emit = defineEmits(["operate"]);const { schema, api, apiParams, buttons } = toRefs(props);onMounted(() => {  initData();});watch(  [schema, api, apiParams],  () => {    initData();  },  { deep: true },);// 估算按钮长度const operationWidth = computed(() => {  return buttons?.value?.length > 0    ? buttons.value.reduce((prev, curr) => {        return prev + curr.label.length * 18;      }, 50)    : 50;});const loading = ref(false);const tableData = ref([]);const currentPage = ref(1);const pageSize = ref(50);const total = ref(0);// 通过节流避免重复请求let timeId = null;const loadTableData = async () => {  clearTimeout(timeId);  timeId = setTimeout(async () => {    await fetchTableData();    timeId = null; // 解除引用  }, 100);};const initData = () => {  currentPage.value = 1;  pageSize.value = 50;  nextTick(async () => {    await loadTableData();  });};const fetchTableData = async () => {  if (!api.value) return;  setLoadingStatus(true);  const res = await $curl({    method: "get",    url: `${api.value}/list`,    query: {      page: currentPage.value,      size: pageSize.value,      ...apiParams.value,    },  });  setLoadingStatus(false);  if (!res || !res.success || !Array.isArray(res.data)) {    tableData.value = [];    total.value = 0;    return;  }  tableData.value = buildTableData(res.data);  total.value = res.metadata?.total;};const buildTableData = (listData = []) => {  if (!schema.value?.properties) return listData;  return listData.map((rowData) => {    for (const dKey in rowData) {      const schemaItem = schema.value.properties[dKey];      if (schemaItem?.option?.toFixed) {        rowData[dKey] =          rowData[dKey].toFixed &&          rowData[dKey].toFixed(schemaItem.option.toFixed);      }    }    return rowData;  });};const setLoadingStatus = (status) => {  loading.value = status;};const operationHandler = ({ btnConfig, rowData }) => {  emit("operate", { btnConfig, rowData });};const onPageSizeChange = async (value) => {  pageSize.value = value;  await loadTableData();};const onCurrentPageChange = async (value) => {  currentPage.value = value;  await loadTableData();};defineExpose({  setLoadingStatus,  initData,  loadTableData,});</script><style lang="less" scoped>.schema-table {  flex: 1;  display: flex;  flex-direction: column;  overflow: auto;  .table {    flex: 1;  }  .pagination {    margin: 10px 0;    text-align: right;  }}</style>

table-panel

app/pages/dashboard/complex-view/schema-view/complex-view/table-panel/table-panel.vue<template><el-cardclass="table-panel"><!-- operation-panel --><el-rowv-if="tableConfig?.headerButtons?.length > 0"justify="end"class="operation-panel"    ><el-buttonv-for="item in tableConfig.headerButtons"v-bind="item"        @click="operationHandler({ btnConfig: item })"      >        {{ item.label }}      </el-button></el-row><!-- schema-table (组件 widget) --><schema-tableref="schemaTableRef":schema="tableSchema":api="api":api-params="apiParams":buttons="tableConfig?.rowButtons ?? []"      @operate="operationHandler"    /></el-card></template><scriptsetup>import { ref, inject } from"vue";import { ElMessageBox, ElNotification } from"element-plus";import $curl from"$common/curl";importSchemaTablefrom"$widgets/schema-table/schema-table.vue";const emit = defineEmits(["operate"]);const { api, apiParams, tableSchema, tableConfig } = inject("schemaViewData");const schemaTableRef = ref(null);constremoveData = async ({ btnConfig, rowData }) => {  const { eventOption } = btnConfig;  if (!eventOption?.params) return;  const { params } = eventOption;  const removeKey = Object.keys(params)[0];  let removeValue;  const removeValueList = params[removeKey].split("::");  if (removeValueList[0] == "schema" && removeValueList[1]) {    removeValue = rowData[removeValueList[1]];  }  ElMessageBox.confirm(    `确认删除 ${removeKey} 为: ${removeValue} 数据?`,    "Warning",    {      type: "warning",      confirmButtonText: "确认",      cancelButtonText: "取消",    },  ).then(async () => {    schemaTableRef.value.setLoadingStatus(true);    const res = await $curl({      method: "delete",      url: api.value,      data: {        [removeKey]: removeValue,      },      errorMessage: "删除失败",    });    schemaTableRef.value.setLoadingStatus(false);    if (!res || !res.success || !res.data) {      return;    }    ElNotification({      title: "删除成功",      message: "删除成功",      type: "success",    });    // 如果删除后当前页没有数据,就加载第一页// await initTableData();// 重新加载 我感觉这个更合理一点awaitloadTableData();  });};constEventKeyMap = {  remove: removeData,};constoperationHandler = async ({ btnConfig, rowData }) => {  const { eventKey } = btnConfig;  if (EventKeyMap[eventKey]) {    awaitEventKeyMap[eventKey]({ btnConfig, rowData });  } else {    emit("operate", { btnConfig, rowData });  }};constinitTableData = async () => {  await schemaTableRef.value.initData();};constloadTableData = async () => {  await schemaTableRef.value.loadTableData();};defineExpose({  loadTableData,});</script><stylelang="less"scoped>.table-panel {  flex: 1;  margin: 10px;  .operation-panel {    margin-bottom: 10px;  }}:deep(.el-card__body) {  height: 98%;  display: flex;  flex-direction: column;}</style>

schema-view

// pages/dashboard/complex-view/schema-view/schema-view.vue<template>  <el-row class="schema-view">    <search-panel      v-if="        searchSchema?.properties &&        Object.keys(searchSchema.properties).length > 0      "      @search="onSearch"    />    <table-panel ref="tablePanelRef" @operate="onTableOperate" />    <component      v-for="(item, key) in components"      :key="key"      :is="ComponentConfig[key]?.component"      ref="comListRef"      @command="onComponentCommand"    ></component>  </el-row></template><script setup>import { ref, provide } from "vue";import SearchPanel from "./complex-view/search-panel/search-panel.vue";import TablePanel from "./complex-view/table-panel/table-panel.vue";import { useSchema } from "./hooks/schema";import ComponentConfig from "./components/component-config.js";const {  api,  tableSchema,  tableConfig,  searchSchema,  searchConfig,  components,} = useSchema();const apiParams = ref();// 深层访问provide("schemaViewData", {  api,  apiParams,  tableSchema,  tableConfig,  searchSchema,  searchConfig,  components,});const tablePanelRef = ref(null);const comListRef = ref([]);const EventhandlerMap = {  showComponent: showComponent,};// 响应组件事件const onComponentCommand = (data) => {  const { event } = data;  if (event == "loadTableData") {    tablePanelRef.value.loadTableData();  }};const onSearch = (searchValObj) => {  apiParams.value = searchValObj;};const onTableOperate = ({ btnConfig, rowData }) => {  const { eventKey } = btnConfig;  if (EventhandlerMap[eventKey]) {    EventhandlerMap[eventKey]({ btnConfig, rowData });  }};function showComponent({ btnConfig, rowData }) {  const { comName } = btnConfig.eventOption;  if (!comName) {    console.error(`找不到组件:${comName}`);    return;  }  const comRef = comListRef.value.find((item) => item.name == comName);  if (!comRef || typeof comRef.show !== "function") {    console.error(`找不到组件配置:${comName}`);    return;  }  comRef.show(rowData);}</script><style lang="less" scoped>.schema-view {  display: flex;  flex-direction: column;  height: 100%;  width: 100%;}</style>

热门栏目