为什么要基于领域模型搭建系统?
- CRUD体力活工作占比很高,且多套系统交付间产生大量重复功能,所以想要开发一套解决方案,支持多系统建设无需重新开发相同的功能
要做的事情总得来说就是:
第一个就是要通过一套统一的DSL(领域特定语言)描述出整个系统的具体结构,也就是要有一个语言或者数据结构来描述出这个系统具体长什么样子。
这样就可以把大量的重复性工作通过这个领域模型的一个数据结构来描述出来。然后再通过具体的解析器把这个描述出来的语言解析生成具体的系统。
但这样好像又不太够,因为还要满足定制化的需求。于是要结合面向对象的设计思路去丰富定制化内容,如何做呢?
可以基于封装好的领域模型的基类来根据不同的项目去继承出不同的项目配置 ,所以这个领域模型的DSL是要有继承的能力的。 能够基于一套标准的结构来派生出各种各样的项目配置结构。在这个过程中,可以通过新增、重载领域模型配置再通过解析器解析来实例化各种各样的系统。
第二个就是要实现出一个模板框架(响应这个DSL的配置)
- 首先要有一个工具库来提供各种封装的公共方法比如网络请求,同时可以随时根据需求来添加更多的工具到这个库中
- 还需要一个组件库,这个组件库的组件是根据DSL中的配置(描述组件长什么样子,要发什么请求,接收哪些参数,如何渲染等等)来生成的,同时也支持根据需求动态扩展出各种各样的组件出来
- 还需要设计一个能响应DSL的模板页,进入的这个模板页是一个SPA的应用,通过这个DSL解析以及vuerouter把页面分发到各种各样的schemaview,iframe view或customview、siderview
-
- schemaview可以根据schema(dsl)动态渲染
- siderview支持跳转到一个含侧边菜单的页面
- iframeview可以支持第三方页面引入
- customview可以支持用户完全自定义
- 使用iframeview与customview来保证了框架的灵活性与可扩展能力
- 同时schemaview中的组件也会留出足够的拓展空间(插槽)来供用户自定义扩展
也就是说通过这样的设计,可沉淀出80%的内容,只需要将时间投入到定制化内容上就可以实现提效。
举个🌰
假设现有两个业务,一个是电商行业、一个是教育行业
- 电商行业下有A电商系统与B电商系统,其中商品管理、订单管理、客户管理是相同的,黄色部分为两个系统特有的部分
- 同样教育行业下有A课程系统与B课程系统,其中视频管理与用户管理是公有部分,其余黄色部分为各自特有的部分

对与这两个业务相关的系统有一个共同特点,那就是都包含了头部菜单,菜单可能是一个单独的菜单项也可能是一个菜单组,点击菜单展示出对应页面,这个页面可能是schema view(就是后台管理系统中常见的search panel + table panel)、可能是一个sider view(包含侧边菜单)、可能是一个通过iframe嵌入的第三方页面、也可能是一个根据需求需要定制化的自定义页面
好了,有了如上特点,能否将这些业务对应的系统都抽象成一个模板页呢?如下图:

那么如何知道这个dashboard模板页都要渲染什么内容呢?
- 顶部菜单区域都要渲染哪些菜单,这个菜单是一个菜单项还是一个菜单组?
- 每个菜单对应的页面是哪个?
- 这个页面是什么类型的?sider view、schema view还是iframe view还是custom view?
- 这个页面中包含什么内容?
接下来就是需要考虑如何设计这个dashboard模板页对应的DSL了,有了这个DSL后就能根据DSL对应的配置来将页面渲染出来进而避免80%的重复工作,只需要去完成那20%需要定制化的内容即可。🤩
下面来看下如何进行DSL设计。
一、DSL设计
以dashboard模板页的DSL设计为例:
1、大致的配置项有哪些呢?
经过上面的分析,不同页面不管是什么类型的,sider view、schema view、iframe view还是custom view,都是经过点击某个菜单项跳转的,那么就需要在dashboard配置项中有个菜单list去描述这个系统包含了哪些菜单,每个菜单对应的页面是什么,让菜单配置与页面配置对应起来。
现在就来思考下,菜单配置都需要包含什么呢?
头部菜单 menu
里面应该包含:
key
菜单唯一标识name
菜单名称menuType
菜单类型,有的是单个菜单module
有的是一个下拉菜单group
,当是一个菜单组的时候,需要包含subMenu
(subMenu中是一个个的可递归的menuItem,因为每个菜单项又可能存在子菜单)- 考虑到要通用,点击菜单的时候可能跳转的不只是schema view(包含searchbar与table的页面,后台管理系统中最常见的页面啦),可能会通过iframe展示第三方页面以及完全的定制化的页面,也可能是点击的时候先展示一个sider,再通过点击sider上的内容去跳转到其他页面。所以菜单项中要有一个
moduleType
(schema/iframe/custom/sider)来标识跳转的页面是什么类型的,不同的页面类型对应的配置项不同,所以会包括:
-
schemaConfig
iframeConfig
customConfig
siderConfig
大致结构如下:
js
export default {
// 模板类型,不同模板类型对应不一样的模板数据结构,未来可能还会扩展其他模板
mode: 'dashboard',
menu: [
// 头部菜单
{
key: '',
name: '',
menuType: '', // 枚举值,group / module
// 当menuType为group时,subMenu可填
subMenu: [],// 可递归的menuItem
// 当menuType为module时,moduleType可填
moduleType: '', // 枚举值:sider / iframe / custom / schema
// 当moduleType为sider时
siderConfig: {
menu: [], // 可递归 menuItem(除了siderConfig)
},
// 当moduleType为iframe时
iframeConfig: {
path: '', // iframe的path
},
// 当moduleType为custom时
customConfig: {
path: '', // 自定义页面的路由路径
},
// 当moduleType为schema时
schemaConfig: {},
},
],
}
对于不同的页面配置需要对应不同的实现方案:
- 页面是shcema view,那么就要根据读取出来的schemaConfig去渲染出对应的查询区域与列表区域、以及根据操作来渲染出配置的动态组件(比如 form) 等
- 页面是iframe view,那么就根据对应的path配置项去展示对应的第三方页面
- 页面是custom view,那么就根据对应的path去跳转到对应的自定义页面
- 页面是sider view,那么就要展示出配置的侧边菜单,然后点击侧边菜单的某一个菜单项进行对应页面的处理(这个页面有可能是schema view有可能是iframe view 有可能是custom view)...
下面就来看下不同页面类型对应的配置要如何设计🌟
2、以schema view为例
schema view都有什么?
schema view是后台管理系统中最常见的页面,主要包含查询区域与列表展示区域:
- 查询区域包含了列表字段中某些字段对应的查询项,可能是input、select、date-range以及查询、重置按钮等等
- 列表区域包含了功能按钮比如新增,以及查询到的数据列表,列表中会展示各字段信息以及每一行数据对应着一些操作按钮,比如编辑、删除等等
- 点击操作按钮会弹出一些其他交互组件,比如弹窗+form
- ...

如何配置呢?
【常见误区】:针对查询区域与列表区域分别写一套配置,再去分别对这两套配置进行解析渲染。但这样就是把写表单的时间转移到了写配置上。并没有形成通用化的配置,且最终受限于产品端,假如各板块的产品设计交互不同那就要针对不同的交互进行不同的配置。
【思考】
不管是查询区域 还是 列表区域 都是关于请求数据列表项中一个个字段的。
以用户管理列表页为例,查询区域包含用户的姓名、以及查询按钮、重置按钮;列表区域包括新增用户按钮,以及用户table:用户ID、用户姓名、用户创建时间,并且每一行数据对应编辑、删除按钮。
- 那么对于一个字段来说,该字段可能会在查询区域作为表单中的某一项、也可能在列表中作为某一column进行展示。 进而可知一个字段所对应的配置需要有:类型(比如string)、label(比如用户姓名)、所对应的tableOption(描述该字段在table中对应的配置,支持所有element table column配置以及自定义配置)、searchOption(描述该字段在查询表单中对应的配置,支持element form item配置及自定义配置)
- 对于整个页面来说,查询区域有搜索按钮、重置按钮;列表区域有新增功能,列表中每一行对应的操作按钮:编辑、删除
- 同时还要有一个配置项来配置这些数据来源于哪里(不管是查询区域还是列表区域的数据项,大多数情况下会来源于同一份数据源)
在api满足RESful的前提下,以用户管理这个schema view为例:
- 在展示用户列表的时候需要
GET /api/user/list
- 在新增用户的时候需要
POST /api/user
- 在编辑用户信息的时候需要
PATCH /api/user
- 在删除用户的时候需要
DELETE /api/user
那么可否将关于这个schema view页面的配置中的数据源抽象成 /api/user
呢?在查询列表的时候统一拼接上/list
,对于其他的操作就修改对应的method
于是就有了如下的配置:
js
// 当moduleType为schema时
schemaConfig: {
api: '/api/user', // 数据源API,遵循RESTful规范
schema: {
type: 'object',
properties: {
name: {
type: 'string', // 字段类型
label: '用户姓名', // 字段中文名
searchOption: { // 该字段对应的查询区域的配置信息
comType: 'input', // 该字段对应在查询区域表单所表现的组件形式
}
tableOption: { // 该字段对应的列表区域的配置信息
width: 300
}
},
},
},
tableConfig: {
headerButtons: [
{
label: '新增',
eventKey: 'showComponent', // 事件类型,告知外部组件所对应的处理事件是哪个
type: 'primary', // element button对应配置项
plain: true, // element button对应配置项
},
],
rowButtons: [
{
label: '修改',
eventKey: 'showComponent',
type: 'warning', // element button对应配置项
},
{
label: '删除',
eventKey: 'remove',
type: 'danger', // element button对应配置项
eventOption: { // 该事件所携带的参数信息
params: {
user_id: 'schema::user_id',
},
},
},
],
}, // search区域公共配置
searchConfig: {
}, // search-bar 相关配置
components: {}, // 模块组件,配置该页面交互所对应的动态组件
},
3、基于面向对象抽取基础配置
有了dashboard的配置之后,就要看如何在不同系统中去应用了。
在多个系统之间可能有很多内容是一样的,那么就需要将这些重复内容沉淀出来。
- 重复内容通过基类来抽取出来
- 不同的部分内容通过继承来扩展
对于教育行业和电商行业来说,都可以使用到dashboard这个领域模型,而对于教育行业还说教育系统A和教育系统B有公共的配置部分,又有各个系统下特有的配置,那么就将公有配置抽取出来,各系统特用配置继承公有配置然后扩展特有配置即可。同样,电商行业系统也是如此。那么就有了如下结构:

这样便可以利用模板去建各种各样的领域模型(比如business的model.js),每个模型又可以派生出不同的项目配置(比如aProj.js和bProj.js)
- model.js中去配置a系统与b系统公有的配置项,比如都包含商品管理、订单管理、客户管理
- aProj与bProj中再去针对各自系统进行不同的配置
有了对应的领域模型与各项目各自的配置后,现在就需要考虑如何解析这些配置并渲染到dashboard模板页中了。
二、DSL解析
1、描述模型与项目
比如business下的model与project描述如下:
- 将aProj与bProj公有的配置放在了model.js中
- aProj与bProj中的配置在公有配置上进行修改或新增实现扩展
js
// model/business/model.js
module.exports = {
model: 'dashboard',
name: '电商系统',
menu: [
{
key: 'product',
name: '商品管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/proj/product',
schema: {
type: 'object',
properties: {
product_id: {
type: 'string',
label: '商品ID',
tableOption: {
width: 300,
'show-overflow-tooltip': true,
},
},
product_name: {
type: 'string',
label: '商品名称',
tableOption: {
width: 200,
},
searchOption: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list',
},
},
price: {
type: 'number',
label: '价格',
tableOption: {
width: 200,
},
searchOption: {
comType: 'select',
enumList: [
{
value: -1,
label: '全部',
},
{
value: 39.9,
label: '¥39.9',
},
{
value: 699,
label: '¥699',
},
{
value: 199,
label: '¥199',
},
],
},
},
inventory: {
type: 'number',
label: '库存',
tableOption: {
width: 200,
},
searchOption: {
comType: 'input',
},
},
create_time: {
type: 'string',
label: '创建时间',
tableOption: {},
searchOption: {
comType: 'dateRange',
},
},
},
},
tableConfig: {
headerButtons: [
{
label: '新增商品',
eventKey: 'showComponent',
type: 'primary',
plain: true,
},
],
rowButtons: [
{
label: '修改',
eventKey: 'showComponent',
type: 'warning',
},
{
label: '删除',
eventKey: 'remove',
type: 'danger',
eventOption: {
params: {
product_id: 'schema::product_id',
},
},
},
],
},
},
},
{
key: 'order',
name: '订单管理',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'client',
name: '客户管理',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
],
}
// model/business/project/aProj.js
module.exports = {
name: 'A电商',
desc: 'A电商系统',
homePage: '',
menu: [
{
key: 'product',
name: '商品管理(A)',
},
{
key: 'client',
name: '客户管理(A)',
},
{
key: 'data',
name: '数据分析',
menuType: 'module',
moduleType: 'sider',
siderConfig: {
menu: [
{
key: 'analysis',
name: '电商罗盘',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'sider-search',
name: '信息查询',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'https://www.pinduoduo.com',
},
},
],
},
},
{
key: 'search',
name: '信息查询',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'https://www.pinduoduo.com',
},
},
],
}
// model/business/project/bProj.js
module.exports = {
name: 'B电商',
desc: 'B电商系统',
homePage: '',
menu: [
{
key: 'order',
moduleType: 'iframe',
iframeConfig: {
path: 'https://xxx.xxxcom',
},
},
{
key: 'operating',
name: '运营活动',
menuType: 'module',
moduleType: 'sider',
siderConfig: {
menu: [
{
key: 'coupon',
name: '优惠券',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'limited',
name: '限量购',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'festival',
name: '节日活动',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
],
},
},
],
}
2、DSL解析与整合
为了方便处理,就需要将各业务对应的模型读取出来,然后整合成如下结构:
- 各项目在继承领域模型的同时将项目特有的配置覆盖上去,最终形成一个完整的项目配置,里面包含了model中公有的配置以及项目本身的配置
js
[
{
"model": {
"model": "dashboard",
"name": "电商系统",
"menu": [...],
"key": "business"
},
"project": {
"aProj": {
"model": "dashboard",
"name": "A电商",
"key": "a",
"desc": "A电商系统",
"homePage": "/schema?proj_key=a&key=product",
"modelKey": "business",
"menu": [...],
},
"bProj": {
"model": "dashboard",
"name": "B电商",
"menu": [...],
"key": "b",
"desc": "B电商电商系统",
"homePage": "/schema?proj_key=b&key=product",
"modelKey": "business"
},
}
},
{
"model": {
"model": "dashboard",
"name": "课程系统",
"menu": [...]
"key": "course"
},
"project": {
"xxx" : {},
"xxx": {}
}
}
]
解析与整合的目的是在让模板页能够根据整合好的各项目配置,在对应区域渲染对应的内容
三、dashboard模板页渲染
有了各项目配置后就需要拿着配置去渲染了~
1、dashboard模板页整体结构
主要包含了头部区域与内容区域:
- 头部区域包含了比如logo和项目名称、菜单区域、以及其他内容
- 内容区域来填充各view

那么就可以将整体结构抽象成一个公共组件:
- 这个公共组件用来充当layout的作用,用于页面布局,不进行业务处理
- 为了公共组件更加通用,应该避免在组件中进行业务逻辑处理
js
<template>
<el-container class="header-container">
<el-header class="header">
<!-- 左上方title -->
<el-row type="flex" align="middle" class="header-row">
<!-- ... -->
</el-row>
<!-- 插槽:菜单区域 -->
<slot name="menu-content"></slot>
<!-- 右上方区域 -->
<el-row type="flex" align="middle" justify="end" class="setting-panel">
<slot name="setting-content"></slot>
<!-- ...除了插槽外的公有部分内容 -->
</el-row>
</el-row>
</el-header>
<el-main class="main-container">
<!-- 插槽:核心内容扩展区域 -->
<slot name="main-content"></slot>
</el-main>
</el-container>
</template>
有了这个公共组件后,那么就使用该公共组件,对dashbord的整体结构进行再一次抽取
-
与公共组件不同的是,公共组件未来可能会参与其他模板页
-
而这次抽取的组件是与dashboard模板页绑定的,里面会有关于dashboard模板页的业务逻辑部分:
- 将项目配置中的menulist,渲染到menu-content插槽中
- 处理菜单点击,并向父组件传递点击的菜单信息
- 并留好main-content的渲染区域
vue
<template>
<header-container>
<!-- 根据menuStore.menuList渲染 -->
<template #menu-content>
<el-menu
>
<!-- ... -->
</el-menu>
</template>
<template #main-content>
<slot name="main-content"></slot>
</template>
</header-container>
</template>
于是在dashboard.vue中便可以使用该组件来插入向main-content中插入对应view
vue
<template>
<header-view>
<template #main-content>
<!-- 通过router-view将路由页面插入到header-view的具名插槽main-content中 -->
<router-view />
</template>
</header-view>
</template>
2、渲染项目中对应页面到dashboard模板中
dashboard通过router-view留好了路由页面渲染入口,接下来就可以对在vuerouter中配置的各页面组件进行渲染了。
3、项目对应页面如何结合配置项进行渲染
以最常见的schema view为例,假设A电商系统中的商品管理菜单对应的页面是一个schema view,shcma view包括search panel区域与table-panel区域:
vue
<template>
<el-row class="schema-view">
<search-panel></search-panel>
<table-panel></table-panel>
</el-row>
</template>
查询区域组件的渲染
search-panel组件获取schema配置项中含有searchOption
的字段信息,根据searchOption进行对应组件的渲染,假设包含searchOption的字段信息如下
js
properties: {
product_type: {
type: 'string',
label: '商品类型',
tableOption: {
width: 200,
},
searchOption: {
comType: 'dynamicSelect', // 该字段对应的为动态select组件
api: '/api/product_enum/list', // select中的数据需要通过该接口获取
}
}
}
这个字段对应的是一个动态的下拉列表,该下拉列表的数据是通过接口获取的,首先基于el-select封装该动态下拉列表:
同理,有的字段需要展示成input组件,有的需要展示成date-range组件,那么我们就可以根据业务需求去扩展这些组件进行根据配置项二次封装,需要注意的是,为了满足element组件上的属性信息,通过v-bind去将配置项绑定上即可
ini
<template>
<el-select class="dynamic-select" v-model="dtoValue" v-bind="xxx">
<el-option
v-for="item in enumList"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</el-select>
</template>
<script setup>
// ..根据配置的api来动态获取enumList
</script>
有了二次封装的业务组件后,便可以根据配置的comType
结合component
来动态渲染了,为了方便获取配置的动态组件,可以专门抽取出一个文件来进行管理:
javascript
import input from '...'
import select from '...'
import dynamicSelect from '...'
import dateRange from '...'
const SearchItemConfig = {
input: {
component: input,
},
select: {
component: select,
},
dynamicSelect: {
component: dynamicSelect,
},
dateRange: {
component: dateRange,
},
}
export default SearchItemConfig
接下来在查询区域的公共组件中进行渲染即可
这样便实现了可灵活扩展组件并灵活配置的能力,一旦有需要扩展的渲染组件,那么就可以对该组件进行二次封装后放到
SearchItemConfig
中进行管理,后续如果某个字段对应的表单项要渲染成这个组件,那么通过在字段对应的searchOption中的comType
配置上即可
vue
<template>
<el-form v-if="schema?.properties" inline class="schema-search-bar">
<!-- 动态组件 -->
<el-form-item
v-for="(schemaItem, key) in schema.properties"
:key="key"
:label="schemaItem.label"
>
<!-- 展示子组件 -->
<component
:ref="handleSearchComList"
:is="SearchItemConfig[schemaItem.option?.comType]?.component"
:schemaKey="key"
:schema="schemaItem"
></component>
</el-form-item>
<!-- 操作区域 -->
<el-form-item>
<el-button type="primary" plain class="search-btn" @click="search"
>查询</el-button
>
<el-button type="primary" plain class="reset-btn" @click="reset"
>重置</el-button
>
</el-form-item>
</el-form>
</template>
- 点击查询与重置后,通过emit传递出去,方便外侧进行业务处理
列表区域组件的渲染
首先把schema配置中api(数据源)、以及关于tableOption的字段提取出来,并且获取到外侧的tableConfig配置项:
css
properties: {
product_id: {
type: 'string',
label: '商品ID',
tableOption: {
width: 300,
'show-overflow-tooltip': true,
},
},
product_name: {
type: 'string',
label: '商品名称',
tableOption: {
width: 200,
},
},
price: {
type: 'number',
label: '价格',
tableOption: {
width: 200,
}
}
}
tableConfig: {
headerButtons: [
{
label: '新增商品',
eventKey: 'showComponent',
type: 'primary',
plain: true,
},
],
rowButtons: [
{
label: '修改',
eventKey: 'showComponent',
type: 'warning',
},
{
label: '删除',
eventKey: 'remove',
type: 'danger',
eventOption: {
params: {
product_id: 'schema::product_id',
}
}
}
]
}
- 根据headerButtons来渲染出来顶部的操作按钮,通过
eventKey
可以在点击的时候通过emit给父组件来进行相应的逻辑处理 - 接着是table区域的渲染,根据字段中的配置信息以及外侧的rowButtons(行操作按钮)进行渲染即可
对于列表来说需要实现的功能包括以下几点:
- 【列表展示数据从哪来】表格展示数据的通过调取配置的数据源
api
去获取即可 - 【如何保证灵活渲染能力】如果遇到需要额外处理的字段怎么办?比如小数部分保留两位等等,也可以通过配置去处理,在获取到数据后去根据配置项中是否配置了需要保留,比如{toFixed: 2},去对该字段进行处理即可。如果遇到复杂的处理过程也可以去扩展一些公共方法,总之方法很多啦
- 【外侧如何拿到需要处理的数据】每条数据所对应的操作通过emit传递出去,并携带着数据信息与按钮配置信息,在父组件进行统一的业务处理
vue
<template>
<div class="schema-table">
<el-table
class="table"
v-if="schema?.properties"
v-loading="loading"
:data=".."
>
<template v-for="(schemaItem, key) in schema.properties" :key="key">
<el-table-column ...>
</el-table-column>
</template>
<el-table-column
v-if="buttons?.length"
label="操作"
fixed="right"
:width=".."
>
<template #default="scope">
<el-button
v-for="rowButtons"
:key="item.label"
v-bind="item"
link
@click="operationHandler({ btnConfig: item, rowData: scope.row })"
>
{{ item.label }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-row class="pagination" justify="end">
<el-pagination
...
></el-pagination>
</el-row>
</div>
</template>
至此便完成了schema view的渲染
对于其他类型页面:
- iframe view 只需使用iframe将配置的path挂上去即可
- custom view是用户完全自定义页面,根据配置的path进行跳转即可
- sider view里又包含了menu配置项去渲染出一个侧边菜单,然后点击跳转的子页面也是iframe view custom view schema view这些
将来可能有不同的模板,那么就可以继续扩展,针对这个模板再派生出各个项目配置去完成重复性工作的沉淀。
更多知识 抖音🔍《哲玄课堂-大前端全栈实践》