里程碑三:基于 Vue3 领域模型架构建设

0 阅读7分钟

🐮🐴前景提要

在前端开发的日常工作中,可能会出现不同模块,功能却大致相同的需求。在没有针对这种场景做相应的对策,会导致大部分时间去重复 CRUD (增加(Create)、读取(Read)、更新(Update)、删除(Delete))操作。

尽管这些功能高度统一,但却分布在不同的业务模块下面。比如:电商管理系统中:店铺的数据表操作商品的数据表操作....等等诸多模块,开发这些模块虽然不难,但重复做这些动作,会浪费掉很多不必要的时间。

因此正对于这种场景,应该需要作出什么样的对策呢?🤔

答案是:“我们可以设计一个可配置模型,基于这个模型来渲染生成我们需要的各种各样的页面,这些页面里携带有一些需要的功能,并且支持自定义扩展”。🤩

这个模型我们取了个名字,成它为领域模型 “DSL”。

🌺领域模型 DSL 设计

json-schema

领域模型DSL的具体设计可以这样思考:

我们可以通过一份配置文件,这个配置文件描述了当前需要的一个页面内容,里面有哪些模块。假设当前的需求是这样的:“需要建一个培训人员管理模块,这个模块的功能是,入职人员录入,培训附件的录入,新增人员、修改、删除、是否完成培训这些按钮”。

那么我们可以通过一份配置描述当前页面有哪些模块。

当然我们不仅希望能通过配置能生成一个页面,也能够生成一个站点项目(包括菜单,系统登录、退出)等之诸多此类。

那么如何定义描述这份配置呢,这份配置有要遵循什么规范呢?

我们可以考虑到:json-schema。借助 json-schema 能帮助我们实现标准化和定义 JSON 数据的期望。

比如:

schema: {
  type: 'object',
  properties: {
    user_id: {
      type: '', // 字段类型
      label: '',
      tableOption: {}, // 这个 user_id 字段在表格中的配置描述
      searchOption: {}, // user_id 在 搜索项中的配置描述
      components: {}, // user_id 在组件中的配置描述
      ... // 其他 Option 配置
    }
  }
}

有了一份这种详细的配置,我们就可以通过某种方式将其转换解析成页面,比如字段这样对应:

properties 中定义的 tableOption、searchOption、components 都是为了方便以及清晰的表达,当前某个字段在页面中 某个组件 的配置。tableOption 配置了 user_id 在 table 组件中的呈现、searchOption 则表示在 搜索栏中的呈现。

当然 Option 中则描述了这个组件具体配置信息,比如 tableOption :

tableOption: {
    ...elTableColumnConfig, // 标准的 el-table-column 配置
    toFixed: 0, // 保留几位小数
    visible: true, // 默认为 truefalse,表示不在表单中显示 )
},

这段配置,不仅能描述 el-ui 组件中的配置,也能描述自定义配置 比如处理小数的 toFixed。

上面 json-schema 不仅能配置描述一个页面内容,也能配置描述一个项目站点。

详细配置:

{
    name: '', // 项目名称
    desc: '', // 项目描述
    homePage: '', // 项目主页
    menu: [{
        key: '', // 菜单的 key
        moduleType: '', // 模块类型
        iframeConfig: {},
        siderConfig: {},
        ... // 其他 Config 
    }]
}

通过上面的配置,我们就可以描述一个项目包含哪些东西,菜单 以及 其对应的子页面。子模块有能描述这个子模块的详细信息:

schemaConfig: {
    api: '', // 数据接口 Api
    schema: { // schema-view 的描述
        type: '', // 类型
        properties: { // 必要的参数
            key: { // 某业务 key
                type: '', // 类型
                label: '', // 中文名
                tableOption: {}, // 表格配置
                searchOption: {} // 搜索项配置
            }
        }
    },
    // 表格 中 按钮的描述
    tableConfig: {
        headerButtons: [],
        rowButtons: []
    }
}

上面那个配置描述了 schema-view 的的详细配置。

有了这份  json-schema 配置之外,我们还需要额外处理配置中的一种情况:假设我建立了两个项目站点:“A办公平台”、“B办公平台”。然后里面都有 “培训人员管理” 子模块。

mode 模块继承和扩展

如果在这种情况下,我们写两份 json-schema 数据配置,显然是不合理的。我们做这件事的核心是 “整合 80%” 重复性的工作。那么我们是否可以这样考虑,假设提供一个公共的 json-schema 数据,在这份配置的基础上扩展出其他的配置,比如 提供一个 “培训人员管理” 基础配置,然后 让它继承到 “A办公平台” 和 ”B办公平台“ 上,这样就会更合理一些。

提供一个基础类配置,让它继承到 “A扩展类”、“B扩展类”。然后在创建对应的系统。这样相同模块就能重复利用。

当然我们还需要细致入微的再深入考虑一下继承的问题,比如说:“B办公平台存在采购模块,办公平台基础类也存在采购模块呢?或者 B办公平台 采购模块中的内容比办公平台基础类多一个配置呢?”。

这种特殊情况也是需要考虑的,具体的做法可以通过对比两者之间是否存在差异,有差异则修改,没有则新增的判断方式处理。

const _ = require('lodash');
const glob = require("glob");
const path = require("path");
const { sep } = path;

/**
 * 解析 model 配置,并返回组织且继承后的数据结构
 * [{
 *  modelKey: ${model},
 *  project: {
 *     proj1key: ${proj1},
 *     proj2key: ${proj2}
 *  }
 * }]
 */
module.exports = (app) => { 
    const modelList = [];

    // 遍历当前文件夹,构建模型数据结构,挂载到 modelList 上
    const modelPath = path.resolve(app.baseDir, `.${sep}model`);
    const fileList = glob.sync(path.resolve(modelPath, `.${sep}**${sep}**.js`));
    fileList.forEach(file => {
        if (file.indexOf('index.js') > -1) return;

        // 区分配置类型(model 或 project)
        const type = file.indexOf(`${sep}project${sep}`) > -1 ? 'project' : 'model';

        if (type === 'project') {
            const modelKey = file.match(//model/(.*?)/project/)?.[1];
            const projKey = file.match(//project/(.*?).js/)?.[1];
            let modelItem = modelList.find(item => item.model?.key === modelKey);
            
            // 初始化 model 数据结构
            if (!modelItem) {
                modelItem = {};
                modelList.push(modelItem);
            }
            // 初始化 project 数据结构
            if (!modelItem.project) {
                modelItem.project = {};
            }

            modelItem.project[projKey] = require(path.resolve(file));
            modelItem.project[projKey].key = projKey;
            modelItem.project[projKey].modelKey = modelKey;
        };

        if (type === 'model') {
            const modelKey = file.match(//model/(.*?)/model.js/)?.[1];
            let modelItem = modelList.find(item => item.model?.key === modelKey);
            if (!modelItem) {
                modelItem = {};
                modelList.push(modelItem);
            }
            modelItem.model = require(path.resolve(file));
            modelItem.model.key = modelKey;
        };
    });

    // 数据进一步整理:project => 继承 model
    modelList.forEach(item => {
        const { model, project } = item;
        for(const key in project) {
            project[key] = projectExtenModel(model, project[key]);
        }
    })

    return modelList;
}
/**
 * 项目继承 model 配置
 * @param {*} model 模型配置
 * @param {*} project 项目配置
 * @returns 合并后的配置
 */
const projectExtenModel = (model, project) => {
    return _.mergeWith({}, model, project, (modelValue, projValue) => {
        // 处理数组何必的特殊性情况
        if (Array.isArray(modelValue) && Array.isArray(projValue)) {
            let result = [];

            // 因为 project 继承 model,所以需要处理修改和新增内容的情况
            // project 有的键值,model 也有 => 修改
            // project 有的键值,model 没有 => 新增
            // model 有的键值,project 没有 => 保持不变

            // 处理修改
            for (let i = 0; i < modelValue.length; i++) {
                let modelItem = modelValue[i];
                const projItem = projValue.find(projItem => projItem.key === modelItem.key);
                // project 有的键值,model 也有,则递归调用 projectExtendModel 方法覆盖修改
                result.push(projItem ? projectExtenModel(modelItem, projItem) : modelItem);
            }

            // 处理新增
            for (let i = 0; i < projValue.length; i++) {
                const projItem = projValue[i];
                const modelItem = modelValue.find(modelItem => modelItem.key === projItem.key);
                if (!modelItem) {
                    result.push(projItem);
                }
            }

            return result;
        }
    });
}

模版组件设计

有了数据之后,但还需要一个能够更具配置来渲染页面的模版,那么这个模版如何设计呢?

dahboard

我们建立一个模版入口页面比如叫它 “dahboard”,但也可以是其他:“dahboard2”、“dahboard3”等等诸多此类。

关于组件的设计,我们可以遵循 “高内聚低耦合” 的宗旨来设计组件。

dahboard 页面,我们可以放一个 header-view 组件,在 header-view 我们放公共部分,像 “头部菜单” 组件,“右侧菜单” 组件,“content 内容” 插槽。

然后 将子模块内容渲染到 “content 内容” 插槽 中,比如:“schema-view”、“iframe-view” 插入到 “content 内容” 里面显示。

业务契合度不是很高的页面封装成公共组件、将业务深度关联的页面封装成业务组件。这样做的好处是能够降低维护成本、以及随着业务不断叠加,减缓熵增(孤立系统中无序程度不断增加的趋势)的情况。

schema-view & iframe-view

在 业务组件里面也遵命 “高内聚低耦合” 的宗旨来设计:

schema-view 中 放 search-panle 和 table-panle 。在这两个 panle 中实现 search 组件和 table 组件。

总结:

DSL的设计核心:可以根据 json-schema 配置,通过解析引擎,来渲染出对应的站点和页面。这份 json-schema配置也可以扩展 和 继承。根据配置的 json-schema 扩展自定义需求、自定义按钮无限扩展其他的页面,从而实现沉淀 80% 的重复性功能重新开发的根本痛点,提高开发效率。