大前端全栈实践:章节四(动态组件设计)

0 阅读8分钟

在上篇文章中,我们已经搭好了项目的初步架构和框架,因此这一章节我们聚焦在于应用层面,即:如何利用我们已经搭好的框架来实现一个我们日常工作中所用到的系统。

总体来说可拆分为下列三个步骤:

  1. 撰写DSL文档,即我们的配置文档;
  2. (如果是新增配置)撰写配置解析器,保证我们的前端代码能够解析我们配置文档过来的配置项
  3. 撰写前端组件,保证我们所解析的配置能够映射到一个具体组件中

下面,我们将以日常开发中后台管理系统的CRUD为例子,进一步讲解每个章节中所完成的事情

1.撰写DSL文档

假设我们的CRUD组件分别命名为create-form、edit-form、detail-panel。(删除一般是在表格中完成,因此不专门封装为组件)

首先来到领域模型我们在schemaConfig中配置componentConfig,这一步是为了给我们的schema-view增加自定义配置(至于为什么配置项在componentConfig,是因为我们在上一个篇章里约定好了,每增加一项新的配置,就以...Config做为命名结尾)。这里我们的createForm,editForm,detailPanel都是隶属于schemaView下面的组件,因此这个新配置项叫做componentConfig。

schemaConfig:{
    api,
    schema,
    tableConfig,
    searchConfig,
    ...
    componentConfig:{
    
    
    }

}

紧接着,我们在compoentConfig中配置对应的组件

componentConfig: {
                    //create-form 表单相关配置
                    createForm: {
                        title: '', //表单标题
                        saveBtnText: '', //保存按钮文案
                    },
                    //editForm配置
                    editForm: {
                        mainKey: '',//表单主键
                        title: '', //表单标题
                        saveBtnText: '', //保存按钮文案
                    },
                    //detailPanel相关配置
                    detailPanel: {
                        mainKey: '', //表单主键
                        title: '', //表单标题
                    }
                    //...支持用户动态扩展
}

到这一步为止,我们相当于已经为我们的框架定义好了组件的名称以及该组件的配置。也就是完成了让框架认识组件这一个步骤

紧接着,我们需要配置这些个组件内部所显示的字段。这里我们需要聚焦于我们之前的schema上,配置对应字段(如果不知道schema是什么意义的,参考第三章文章)。


schemaConfig:{
    schema:{
         product_id: {
            type: 'string',
            label: '商品ID',
            tableOption: {
                width: 300,
                'show-overflow-tooltip': true,
            },
            editFormOption: {
                comType: 'input',
                disabled: true,
            },
            detailPanelOption: {

            }
        },
        product_name: {
            type: 'string',
            label: '商品名称',
            maxLength: 10,
            minLength: 3,
            tableOption: {
                width: 200
            },
            searchOption: {
                comType: 'dynamicSelect',
                api: '/api/proj/product_enum/list',
            },
            createFormOption: {
                comType: 'input',
                required: true,
            },
            editFormOption: {
                comType: 'input'
            },
            detailPanelOption: {

            }
        },
        price: {
            type: 'number',
            label: '价格',
            minimum: 10,
            maximum: 100,
            tableOption: {
                width: 200
            },
            searchOption: {
                comType: 'select',
                enumList: [
                    {
                        label: '全部',
                        value: -1,
                    },
                    {
                        label:39.9',
                        value: 39.9,
                    },
                    {
                        label:199',
                        value: 199,
                    },
                    {
                        label:699',
                        value: 699,
                    }
                ]
            },
            createFormOption: {
                comType: 'inputNumber'
            },
            editFormOption: {
                comType: 'inputNumber'
            },
            detailPanelOption: {

            }
        },
        inventory: {
            type: 'number',
            label: '库存',
            tableOption: {
                width: 200
            },
            searchOption: {
                comType: 'input'
            },
            createFormOption: {
                comType: 'select',
                enumList: [{
                    label: '100',
                    value: 100
                }, {
                    label: '1000',
                    value: 1000,
                }, {
                    label: '10000',
                    value: 10000,
                }]
            },
            editFormOption: {
                comType: 'inputNumber',
            },
            detailPanelOption: {

            }
        },
        create_time: {
            type: 'string',
            label: '创建时间',
            tableOption: {

            },
            searchOption: {
                comType: 'dateRange'
            },
            detailPanelOption: {

            }
        },
        required: ['product_name']
    }
    }
}

我们将每个字段中,需要显示的组件配置对应的Option,例如商品ID这个字段,它需要显示在editForm组件和detailPanel组件中,我们就配置对应的editFormOption和detailPanelOption,不配置createFormOption(为什么不在createForm上显示?因为商品ID是我们后端在创建时默认赋予的,用户无法自行定义创建)。另外注意,我们需要具体显示的UI组件名称使用的是comType 这个字段来定义的。(比方说我们想要这个字段在创建时使用element的input框提供给用户做文字输入comType则定义为input,其他同理)。另外我们想要一些字段做必填校验,和一些规范性验证,这里使用required字段标识。

2.撰写解析器

解析器我们在上一个章节中给封装成了一个hooks,存放在schema-view目录下方;此时解析器只能够解析tableConfig和searchConfig俩配置,因此我们需要在这一节中继续完善它以保证它能够解析我们新增的componentConfig。

  1. 首先我们在hooks开头声明变量components,用于存放所有的component的shema和组件配置项
export const useSchema(){
    const tableSchema = ref(null);
    const tableConfig = ref(null);
    const searchSchema = ref(null);
    const searchConfig = ref(null);
    ...
    const components = ref(null); //components配置项和schema项目
}
  1. 紧接着我们在buildData中调用buildDtoSchema,将每一个单独的component配置项传入,使其过滤噪音,提取对应的config和option配置项
const buildData = ()=>{
    ...构造其他config的逻辑
    //构造components = { componentKey: {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;
}

另外,在buildDtoSchema中,我们需要专门解析和处理一下required字段。因为我们在schema层面可能配置了总体校验项目,是一个数组(例如我在schemaConfig中配置了required:['product_name', 'product_id'],标明我在后端需要校验这两个请求入参。但是前端的UI选项中可能忘记配置了这个选项,因此下面在解析器里为其补全这份逻辑。

const buildDtoSchema = ()=>{
    ...
     //处理 required 字段
    const { required } = _schema;
    if (required?.find?.(pk => pk === key)) {
        dtoProps.option.required = true;
    }
}

3.完成前端组件实现

为了方便校验和组件状态管理,我们规定组件必须对外暴露并实现show方法和name属性;show方法负责维护组件内部初始化的相关逻辑,name属性则是用来标识组件的key,这样方便后续出现错误或者需要做校验时回溯组件。

createForm.vue

<template>
    <el-drawer
    v-model="isShow"
    direction="rtl"
    :destroy-on-close="true"
    :size="550"
    >
    <template #header>
        <h2 class="title">
            {{ title }}
        </h2>
    </template>
    <template #default>
        <schema-form :schema="components[name]?.schema" ref="schemaFormRef">

        </schema-form>
    </template>
    <template #footer>
        <el-button type="primary" @click="save">
            {{ saveBtnText }}
        </el-button>
    </template>
    </el-drawer>
</template>

<script setup>
import { ref, inject } from 'vue';
import SchemaForm from '$widgets/schema-form/schema-form.vue';
import { ElNotification } from 'element-plus';
import curl from '$common/curl.js';
const {api,components} = inject('schemaViewData')
const emit = defineEmits(['command'])
const name = ref('createForm');
const isShow = ref(false);
const title = ref('');
const saveBtnText = ref('');
const schemaFormRef = ref(null);
const loading = ref(false);
const show = rowData => {
    const {config} = components.value[name.value];
    title.value = config.title;
    saveBtnText.value = config.saveBtnText;
    isShow.value = true;
}
const close = () => {
    isShow.value = false;
}

defineExpose({
    name,
    show,
})
const save = async () => {
    //校验
    if(!schemaFormRef?.value?.validate?.()) {
        return;
    }
    //请求
    loading.value = true;
    const {success} = await curl({
        url:api.value,
        method:'post',
        data:schemaFormRef?.value?.getValue?.(),
    }) ?? {}
    loading.value = false;
    if(!success) {
        ElNotification.error('新增错误');
        return;
    }
    ElNotification.success('新增成功');
    //关闭,抛出事件
    close();
    emit('command', {
        event:'loadTableData',
    })
}
</script>

<style lang="less" scoped>

</style>

editForm.vue

<template>
  <el-drawer
    v-model="isShow"
    direction="rtl"
    :destroy-on-close="true"
    :size="550"
  >
    <template #header>
      <h3 class="title">
        {{ title }}
      </h3>
    </template>
    <template #default>
      <schema-form
        ref="schemaFormRef"
        v-loading="loading"
        :model="dtoModel"
        :schema="components?.[name]?.schema"
      />
    </template>
    <template #footer>
      <el-button
        type="primary"
        @click="save"
      >
        {{ saveBtnText }}
      </el-button>
    </template>
  </el-drawer>
</template>

<script setup>
import { ref, defineExpose, inject, defineEmits } from 'vue';
import SchemaForm from '$widgets/schema-form/schema-form.vue';
import { ElNotification } from 'element-plus';
import $curl from '$common/curl.js'
const {api, components} = inject('schemaViewData');
const emit = defineEmits(['command']);
const name = ref('editForm');
const title = ref('');
const saveBtnText = ref('');
const mainKey = ref('');
const mainValue = ref('');
const dtoModel = ref({});
const isShow = ref(false);
const loading = ref(false);
const schemaFormRef = ref(null);
const show = (rowData) => {
    const {config} = components.value[name.value];
    title.value = config.title;
    saveBtnText.value = config.saveBtnText;
    mainKey.value = config.mainKey;//表单主键
    mainValue.value = rowData[config.mainKey];
    dtoModel.value = {};
    isShow.value = true;
    fetchFormData();
}

const close = () => {
    isShow.value = false;
}

const fetchFormData = async () => {
    if(loading.value) {
        return;
    }
    loading.value = true;
    const { success,data } = await $curl({
        url:api.value,
        method:'get',
        query:{
            [mainKey.value]:mainValue.value,
        }
    }) ?? {}
    dtoModel.value = data;
    loading.value = false;
    if(!success || !data) {
        ElNotification.error('获取表单数据失败')
        return;
    }
}

const save = async () => {
    if(loading.value) {
        return;
    }
    if(!schemaFormRef?.value?.validate?.()) {
        return;
    }
    loading.value = true;
    const {success} = await $curl({
        url:api.value,
        method:'put',
        data:{
            [mainKey.value]:[mainValue.value],
            ...schemaFormRef?.value?.getValue?.(),
        }
    })
    loading.value = false;
    if(!success) {
        ElNotification.error('保存失败');
        return
    }
    ElNotification.success('修改成功');
    close();
    emit('command', {
        event:'loadTableData',
    })
}

defineExpose({
    name,
    show,
})
</script>

<style lang="less" scoped>

</style>

detailPanel.vue

<template>
  <el-drawer
    v-model="isShow"
    direction="rtl"
    :destroy-on-close="true"
    :size="550"
  >
    <template #header>
      <h3 class="title">
        {{ title }}
      </h3>
    </template>
    <template #default>
      <el-card
        v-loading="loading"
        shadow="always"
        class="detail-panel"
      >
        <el-row
          v-for="(item, key) in components[name]?.schema?.properties"
          :key="key"
          type="flex"
          align="middle"
          class="row-item"
        >
          <el-row class="item-label">
            {{ item.label }}:
          </el-row>
          <el-row class="item-value">
            {{ dtoModel[key] }}
          </el-row>
        </el-row>
      </el-card>
    </template>
    <template #footer />
  </el-drawer>
</template>

<script setup>
import { ref,defineExpose, inject } from 'vue';
import $curl from '$common/curl.js';
const { api, components } = inject('schemaViewData');
const mainKey = ref('');
const mainValue = ref('');
const isShow = ref(false);
const name = ref('detailPanel')
const title = ref('');
const dtoModel = ref({});
const loading = ref(false);
const show = (rowData) => {
    const { config } = components.value[name.value];
    mainKey.value = config.mainKey;
    mainValue.value = rowData[config.mainKey];
    title.value  = config.title;
    dtoModel.value = {};
    isShow.value = true;
    fetchFormData();
}
const close = () => {
    isShow.value = false;
}
const fetchFormData = async ()=> {
    if(loading.value) {
        return;
    }
    loading.value = true;
    const {success, data} = await $curl({
        method:'get',
        url:api.value,
        query:{
            [mainKey.value]:mainValue.value,
        }
    }) ?? {}
    loading.value = false;
    if(!success || !data) {
        return;
    }
    dtoModel.value = data;
}
defineExpose({
    name,
    show,
})
</script>

<style lang="less" scoped>
.detail-panel {
    border:1px sold #a6a6a6;
    padding:30px;
    .row-item {
        height:40px;
        line-height:40px;
        font-size:20px;

        .item-label {
            margin-right:20px;
            width:120px;
            color:#ffffff;
        }
        .item-value {
            color:#d2dae4;
        }
    }
}
</style>

同时为这些组件提供一个统一入口: compoentConfig.js

import CreateForm from "./create-form/create-form.vue";
import DetailPanel from "./detail-panel/detail-panel.vue";
import EditForm from "./edit-form/edit-form.vue";
export default {
    createForm: {
        component: CreateForm,
    },
    editForm: {
        component: EditForm,
    },
    detailPanel: {
        component: DetailPanel
    }
}

在schema-view中

<template>
  <el-row class="schema-view">
    <search-panel 
      v-if="searchSchema?.properties && Object.keys(searchSchema.properties).length > 0"
      @search="onSearch"
    />
    <table-panel 
      @operate="onTableOperate"
      ref="tablePanelRef"
    />
    <component 
    v-for="(item,key) in components"
    :key="key"
    :is="ComponentConfig?.[key]?.component"
    ref="comListRef"
    @command="onComponentCommand"
    >

    </component>
  </el-row>
</template>

<script setup>
import { provide,ref } 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 './hook/schema.js';
import ComponentConfig from './components/component-config.js';
const apiParams = ref({})
const {api, tableConfig,tableSchema, searchSchema, searchConfig, components} = useSchema();
//层级节点比较深的情况下,通过provide/inject方法提供/注入数据会更方便些,相对比于props传参来说
provide('schemaViewData', {
    api,
    tableSchema,
    tableConfig,
    searchSchema,
    searchConfig,
    apiParams,
    components,
});

const tablePanelRef = ref(null);
const comListRef = ref(null);

const onSearch = (searchValueObj) => {
    apiParams.value = searchValueObj;
}

// table事件映射
const EventHandlerMap = {
  showComponent:({btnConfig,rowData}) => {
    const { comName } = btnConfig.eventOption;
    if(!comName) {
      return;
    }
    const comRef = comListRef?.value?.find?.(item => item.name === comName);
    if(!comRef || typeof comRef?.show !== 'function') {
      return;
    }
    comRef?.show(rowData);
  }
}

const onComponentCommand = data => {
  const {event} = data;
  if(event === 'loadTableData') {
    tablePanelRef?.value?.initTableData?.();
    return;
  }
  
}

const onTableOperate = ({btnConfig, rowData}) => {
  const {eventKey} = btnConfig;
  if(EventHandlerMap?.[eventKey]) {
    EventHandlerMap?.[eventKey]({btnConfig,rowData})
  }
}

</script>

<style lang="less" scoped>
.schema-view {
    display:flex;
    flex-direction:column;
    height:100%;
    width:100%;
}
</style>

备注:

  1. 字段校验通过Ajv完成,Ajv是一个遵循了JSON-Schema所实现的验证库。在项目中,我们在最顶层Schema-View中引入,并通过Provide/Inject的方式注入给了下属所有的组件使用。
  2. 从我们解析器解析过来的数据,也是在schema-view中获取,并通过Provide/Inject的方式注入给下属所有组件。
  3. 动态组件依赖于Vue->Component标签实现,并配合着我们上述compoentConfig.js入口,这样即可达到根据解析器返回过来的组件名称而动态渲染组件的效果。
  4. schema-view是顶层组件,它可以被理解为事件总线的总线管理处。所有子组件抛出的事件都会在这层收到并传达给对应组件来完成对应操作。例如我们的table-panel中会有“删除”按钮和"新增"按钮,本质上就是将‘showComponent’事件和'delete'事件上抛到了这一层,再由这一层去分发事件到对应的子组件中完成。例如点击新增按钮后,事件会被分发到create-form组件,并调用对应的'show'方法。

总体效果参考 (注意接口并未实现对应功能,只是简单响应成功)

录屏2026-04-27-1-10.41.55.gif

总结:

  1. 通过自研elpis这套框架,我们将原有的开发流程、需求配置化,通过这种以数据驱动视图的概念进而解决了我们日常工作重复性较高且耗时的问题。
  2. elpis框架的使用方法大致分三个步骤行进,1.配置文档,2.实现对应的解析器,3.实现对应动态组件代码。未来若新增需求都可以通过重复这三个步骤来完成。