🐮🐴前景提要
在前端开发的日常工作中,可能会出现不同模块,功能却大致相同的需求。在没有针对这种场景做相应的对策,会导致大部分时间去重复 CRUD (增加(Create)、读取(Read)、更新(Update)、删除(Delete))操作。
尽管这些功能高度统一,但却分布在不同的业务模块下面。比如:电商管理系统中:店铺的数据表操作 、商品的数据表操作....等等诸多模块,开发这些模块虽然不难,但重复做这些动作,会浪费掉很多不必要的时间。


因此正对于这种场景,应该需要作出什么样的对策呢?🤔
答案是:"我们可以设计一个可配置模型,基于这个模型来渲染生成我们需要的各种各样的页面,这些页面里携带有一些需要的功能,并且支持自定义扩展"。🤩
这个模型我们取了个名字,成它为领域模型 "DSL"。
🌺领域模型 DSL 设计
json-schema
领域模型DSL的具体设计可以这样思考:
我们可以通过一份配置文件,这个配置文件描述了当前需要的一个页面内容,里面有哪些模块。假设当前的需求是这样的:"需要建一个培训人员管理模块,这个模块的功能是,入职人员录入,培训附件的录入,新增人员、修改、删除、是否完成培训这些按钮"。
那么我们可以通过一份配置描述当前页面有哪些模块。
当然我们不仅希望能通过配置能生成一个页面,也能够生成一个站点项目(包括菜单,系统登录、退出)等之诸多此类。
那么如何定义描述这份配置呢,这份配置有要遵循什么规范呢?
我们可以考虑到:json-schema。借助 json-schema 能帮助我们实现标准化和定义 JSON 数据的期望。
比如:
go
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 :
ruby
tableOption: {
...elTableColumnConfig, // 标准的 el-table-column 配置
toFixed: 0, // 保留几位小数
visible: true, // 默认为 true( false,表示不在表单中显示 )
},
这段配置,不仅能描述 el-ui 组件中的配置,也能描述自定义配置 比如处理小数的 toFixed。
上面 json-schema 不仅能配置描述一个页面内容,也能配置描述一个项目站点。

详细配置:
csharp
{
name: '', // 项目名称
desc: '', // 项目描述
homePage: '', // 项目主页
menu: [{
key: '', // 菜单的 key
moduleType: '', // 模块类型
iframeConfig: {},
siderConfig: {},
... // 其他 Config
}]
}
通过上面的配置,我们就可以描述一个项目包含哪些东西,菜单 以及 其对应的子页面。子模块有能描述这个子模块的详细信息:
go
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办公平台 采购模块中的内容比办公平台基础类多一个配置呢?"。
这种特殊情况也是需要考虑的,具体的做法可以通过对比两者之间是否存在差异,有差异则修改,没有则新增的判断方式处理。
ini
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;
}
javascript
/**
* 项目继承 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% 的重复性功能重新开发的根本痛点,提高开发效率。