一、reason & background
算上实习时间,笔者在前端这个领域大致做了两年,从我负责过的需求和项目来看:绝大部分的时间,我都在与各种组件库的table组件和input组件打交道,也就是完成一个个用户操作友好的 CRUD 界面;我时常在想,有没有什么方法可以减少代码中一个个的 table-item 或者 Column 组件?
对于每个前端,大概都会经历这个阶段:从励志使用自身技术开发出划时代的交互到一遍遍的轮回在各种表格页和详情页的开发中,或许是时候做出改变了,程序员就是要尽力用机器去替换重复的工作的。

1. 我的一些实践
笔者曾经使用过antd的pro-table,其中的通过配置生成search和table的能力让我受到了启发,或许我们可以将代码抽象为一个个json数据,从而做到改变这些数据的同时,生成不同的页面,这样就可以减少许多工作,所以在后续的vue项目中,我也尝试和相应的后端同学交流,共同完成了一套适用于项目中所有表格和搜索项的一套配置,当然,这套配置只能服务于前端的页面,也只能服务于当前的业务。

2. 基于领域模型的设计
上述的实践部分,只能做到对于当前项目前端的一个描述,并不能扩展到其它业务中,也并不能形成一个规范,去约束产品的设计,所以一个真正好的设计,应该能够服务于多种业务,整合大部分的重复性工作,并在做好自由拓展性的能力。由此可以引出一个新的概念 DSL(领域特定语言,Domain-Specific-Language)
3. DSL的设计
DSL(Domain-Specific Language,领域特定语言)是一种针对特定业务领域设计的编程语言或标记语言,它的语法和语义紧密贴合该领域的术语、规则和场景,专注于解决该领域内的问题,而非通用编程任务。
3.1一个好的DSL应该具备以下特性
- 专注性:只解决特定领域的问题(如 SQL 只处理数据库交互,不负责图形界面开发)。
- 简洁性 :语法贴近领域术语(如 SQL 的
SELECT * FROM table
直接对应 "从表中查询数据" 的业务逻辑),避免通用语言的冗余代码。 - 易用性:降低领域内的使用门槛,即使是非开发人员(如业务分析师、运营)也能通过 DSL 完成任务。
- 抽象性:屏蔽底层技术细节(如 Markdown 用户无需关心 HTML 渲染原理,只需专注内容)。
二、实践
1. 页面形式
下图是页面的目标渲染方式,header-container 和 sider-container对应头部菜单和侧边栏菜单,页面中间是对应三种渲染方式,schema-view 是dsl的主要实现部分,其中具有搜索项和表格项,iframe 和 custom是可以作为扩展部分填充到项目中

2. dsl具体格式
下图是dsl的规则设计图,此dsl可以渲染出dashboard和菜单项

dsl的配置规则,根据当前项目的dashboard产生的规则文档
js
const docs = {
mode: 'dashboard', // 模板类型,不同模板类型对应不一样的模板数据结构
name: '', // 模板名称
desc: '', // 模板描述
icon: '', // 模板图标
homePage: '', // 首页路由
// 头部菜单
menu: [
{
key: '', // 菜单唯一标识
name: '', // 菜单名称
menuType: '', // 枚举值,group:分组,module:菜单项
// 当menuuType为group时,可以配置以下subMenu
subMenu: [
{
// 可递归的 menuItem
},
],
// 当menuType为module时,可以配置以下moduleType
// 其中 iframe 与 custom 是可以自定义的,相当于扩展功能,schema 是根据配置生成的页面
moduleType: '', // 枚举值, sider: 侧边菜单选项, iframe: 嵌入的第三方页面, custom: 自定义页面, schema: 配置生成的页面
// 当moduleType为sider时
siderConfig: {
menu: [
{
// 可递归menuItem (包含除sider的所有类型)
},
],
},
// 当moduleType为iframe时
iframeConfig: {
path: '', // iframe的 路径
},
// 当moduleType为custom时
customConfig: {
path: '', // 自定义页面的路由
},
// 当moduleType为schema时
schemaConfig: {
api: '', // 数据源API (遵循 RESTFUL 规范) get: 获取数据, post: 提交数据, put: 更新数据, delete: 删除数据
schema: {
// 板块数据结构
type: 'object',
properties: {
key: {
...schema, // 继承标准 schema 配置
type: '', // 字段类型
label: '', // 字段的中文名
// 字段在 table 中的相关配置
tableOption: {
...elTableColumnConfig, // 标准的 el-table-column 配置
toFixed: 0, // 小数点后保留几位
visiable: true, // 默认为 true {仅在配置false时, 不在表单中显示}
},
// 字段在 search-bar 中的相关配置
searchOption: {
...eleComponentConfig, // 标准 el-component 配置
comType: '', // 配置组件类型 input/select/.....
defaultValue: '', // 默认值
// 当 comType 为 select 时,可以配置以下 enumList
enumList: [{
label: '', // 选项名称
value: '', // 选项值
}], // 下拉框可选项
// 当 comType 为 dynamicSelect 时,可以配置以下 api
api: '', // 动态获取下拉框可选项的 API
}
},
},
},
// table 相关配置
tableConfig: {
headerButtons: [{
label: '', // 按钮名称
eventKey: '', // 按钮事件名
eventOption: {}, // 按钮事件具体配置
...elButtonConfig, // 标准的 el-button 配置
}, '....'], // 表头按钮
rowButtons: [{
label: '', // 按钮名称
eventKey: '', // 按钮事件名
eventOption: {
// 当eventKey === 'remove'
params: {
// paramKey = 参数的键值
// rowValueKey = 参数值,当格式为 schema::tableKey ,到 table 中找相应的字段
paramKey: rowValueKey
},
}, // 按钮事件具体配置
...elButtonConfig, // 标准的 el-button 配置
}, '....'], // 行按钮
},
searchConfig: {}, // search-bar 相关配置
components: {}, // 模块组件
},
},
],
}
console.log(docs)
3. 配置以及示例
接下来用一些示例来展现这些数据怎么配置:
首先,我们需要考虑一个问题,因为这一套配置可以影响一个项目中的多个页面,难道我们又要针对多个页面都加上同样的配置吗,这好像有点违背初衷了吧,所以说,我们需要先设计一些基准配置,这个基准配置里面,描述了不同业务场景下的通用配置,而不通用的配置,我们再放到其单独的配置中去处理。
例如我们想要实现一个电商后台管理模块,可以有以下基准配置:

想要实现一个课程管理模块,可以有如下基准配置:

其次,针对模块下的不同租户,我们需要处理其特定的配置,需要有一套规则:
- 同配置后者替代前者
- 不同配置直接采用后者
- 最终输出合并后的结果 这套思想有点类似css的层叠样式的规则,就拿电商系统举例,假设我们添加一个taobao的配置如下:
{
name: '淘宝',
desc: '淘宝电商系统',
homePage: '/schema?proj_key=taobao&key=product',
menu: [
{
key: 'order',
name: '订单管理(淘宝)',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'http://www.baidu.com',
},
},
{
key: 'operating',
name: '运营活动',
menuType: 'moudle',
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',
},
},
],
},
},
],
}
和基准的模型结合之后,应该变成如下的配置
js
{
"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": [
{
"label": "全部",
"value": "all"
},
{
"label": "¥100",
"value": 100
},
{
"label": "¥200",
"value": 200
},
{
"label": "¥300",
"value": 300
}
]
}
},
"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": "iframe",
"customConfig": {
"path": "/todo"
},
"iframeConfig": {
"path": "http://www.baidu.com"
}
},
{
"key": "client",
"name": "客户管理",
"menuType": "module",
"moduleType": "custom",
"customConfig": {
"path": "/todo"
}
},
{
"key": "operating",
"name": "运营活动",
"menuType": "moudle",
"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"
}
}
]
}
}
],
"key": "taobao",
"desc": "淘宝电商系统",
"homePage": "/schema?proj_key=taobao&key=product",
"modelKey": "business"
}
实现合并基准配置与特殊配置的代码如下:
js
const _ = require('lodash')
const glob = require('glob')
const path = require('path')
// const { sep } = path
// project配置 继承 model配置的 方法
const projectExtendModel = (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 ? projectExtendModel(modelItem, projItem) : modelItem
)
}
// 处理新增
for (let i = 0; i < projValue.length; i++) {
let projItem = projValue[i]
const modelItem = modelValue.find(
(modelItem) => modelItem.key === projItem.key
)
// project有的键值,model没有,则直接新增
if (!modelItem) {
result.push(projItem)
}
}
return result
}
})
}
/**
* 解析 model 配置,并返回组织且继承后的数据结构
* 根据模板和项目的配置,返回解析后的结构
* [{
model: ${model}
project: {
proj1: ${proj1},
proj2: ${proj2}
}
}, ...]
*/
module.exports = (app) => {
const modelList = []
// 遍历当前文件夹,构造模型数据结构,挂载到 modelList 上
const modelPath = path.resolve(app.baseDir, './model')
const fileList = glob.sync(path.resolve(modelPath, `./**/**.js`))
fileList.forEach((file) => {
if (file.indexOf('index.js') > -1) {
return
}
// 区分配置类型 (model / project)
const type = file.indexOf('/project/') > -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)
if (!modelItem) {
// 初始化 model 数据结构
modelItem = {}
modelList.push(modelItem)
}
if (!modelItem.project) {
// 初始化 proj 数据结构
modelItem.project = {}
}
modelItem.project[projKey] = require(path.resolve(file))
modelItem.project[projKey].key = projKey // 注入 projectKey
modelItem.project[projKey].modelKey = modelKey // 注入 modelKey
}
if (type === 'model') {
const modelKey = file.match(/\/model\/(.*?)\/model\.js/)[1]
let modelItem = modelList.find((item) => item.model?.key === modelKey)
if (!modelItem) {
// 初始化 model 数据结构
modelItem = {}
modelList.push(modelItem)
}
modelItem.model = require(path.resolve(file))
modelItem.model.key = modelKey // 注入 modelKey
}
})
// 数据进一步整理:project => 继承 model
modelList.forEach((item) => {
const { model, project } = item
for (const key in project) {
project[key] = projectExtendModel(model, project[key])
}
})
return modelList
}
4. 页面实现
上文 页面形式 中提到,我们需要实现 header-container、sider-container、dashboard 这三个区域,其中header-container以及sider-container都是目录项,dashboard是主要内容区域。这些区域都要借助于我们的配置来实现
目录区域
目录有两种形式,module 和 group,group代表目录会存在子项。其中module形式的目录还会有moduleType属性,代表这页面形式,共有四种:sider、custom、iframe、schema。 下面展示一下示例:
- group形式目录示例:
js
menu: [
{
key: 'shop-setting',
name: '店铺设置(京东)',
menuType: 'group',
subMenu: [
{
key: 'info-setting',
name: '店铺信息设置(京东)',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'quality-setting',
name: '店铺资质设置(京东)',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'http://www.baidu.com',
},
},{
key: 'categories',
name: '分类数据',
menuType: 'group',
subMenu: [
{
key: 'categories-1',
name: '一级分类',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
{
key: 'categories-2',
name: '二级分类',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'http://www.baidu.com',
},
},
{
key: 'tags',
name: '标签',
menuType: 'module',
moduleType: 'custom',
customConfig: {
path: '/todo',
},
},
]
}
],
},
],
- module形式的示例
js
{
key: 'search',
name: '信息查询',
menuType: 'module',
moduleType: 'iframe',
iframeConfig: {
path: 'http://www.baidu.com',
},
},