抖音-哲玄前端(全站) 《大前端实践课》学习有感
WHY - 领域模型
身为前端工程师,加班是家常便饭。仔细想想,多数时候我们都在重复劳动。做了一个 XXX 后台系统,紧接着又是 YYY 后台系统,大量时间消耗在重复性 CRUD 工作上。不仅技术难以提升,就连在项目中提炼重难点与亮点,都变成了一件难事。
有没有一种类似 “模板” 的东西,能囊括相似项目的共性?这样一来,我们就能把精力放在每个项目的独特之处,节省时间、提高效率。基于这样的需求,“领域模型” 的概念便诞生了。
WHAT - 领域模型
领域模型(Domain Model)是软件开发过程中,对特定业务领域,如电商、物流、金融等,其核心逻辑、规则和概念的抽象表达。作为领域驱动设计(DDD,Domain - Driven Design)的关键工具,它通过界定业务里的关键实体、行为、关系以及约束,助力开发团队准确理解并实现业务需求。
说句人话,领域模型就是对功能相似系统的抽象。以电商平台为例,京东、淘宝、拼多多,它们都具备商品管理、订单管理、用户管理等主要功能。这些平台可被视作电商领域模型的不同实例。我们抽象出的电商领域模型,涵盖的是这些平台的共性功能,像商品、订单、用户管理等。而各个平台的特色功能,不在领域模型的范畴内,需在具体实例中进行个性化定制。
HOW - 领域模型
了解了引入领域模型的原因,以及它究竟是什么之后,接下来就要思考如何搭建这个能显著节省出摸鱼时间(bushi)的工具了。
第一步,我们得创建一份用于描述领域模型的 “说明书”。这份说明书,将梳理模型的通用特性。当面对不同实例时,如何在其基础上增添个性化内容呢?这里可以借助面向对象设计中的 “继承” 思想。模型下的所有实例,都会继承模型的基础内容,并基于自身需求进行定制,如此一来,便能解决共性与个性的问题 。
不过随之而来新问题:怎样撰写这份计算机能够理解的 “说明书” 呢?
DSL
DSL 即领域特定语言(Domain - Specific Language),是一种专门为特定领域或特定任务而设计的计算机语言。
DSL 针对特定的领域或业务需求进行设计,如金融领域的风险评估、游戏开发中的场景描述、数据处理中的 ETL 流程等。它专注于解决特定领域的问题,提供了一套专门针对该领域概念和操作的词汇与语法,使得领域专家能够更自然、更高效地表达和解决问题。
DSL 可以根据不同的领域需求进行定制化设计。开发人员可以根据具体的业务场景和用户需求,灵活地定义语言的语法、语义和规则,以满足特定领域的特殊要求。
在模型驱动的软件开发中,DSL 用于描述系统的模型,如 UML(统一建模语言)就是一种用于软件建模的 DSL。通过 DSL 描述的模型,可以自动生成代码、文档等相关 artifacts。
简单了解DSL这大哥后,这不巧了嘛,完全是我们撰写说明书的不二之选。
接下来我们就来简单介绍怎么来完成这份DSL。
如何撰写我们的DSL
既然我们要使用领域模型来抽象某一类应用的基类,第一步就是要找到这些应用可能存在的共同点。
我们来想一想,这些后台,都包括哪些部分...
# 项目名
# 项目描述
# 菜单栏
# 内容展示区
接下来我们就要一步一步对这些共同点进行更加细致、深化的描述,来帮助我们尽量更加完整的以此为依据来生成站点
菜单
对于菜单,我们能够完善、细化的,无非是菜单包含的标签页、以及他们的跳转地址,不要忘了某些菜单可能会有多级子菜单存在:
# 菜单栏
# 标签页1
# 标签页1名称
# 跳转路径1
# ...
# 标签页2
# 标签页2名称
# ...
# 子菜单
# 子标签页1
# 子标签页1名称
# 子标签页1跳转路径
# ...
# 子标签页2
# 子标签页2名称
# 子标签页2跳转路径
# ...
# 标签页3
# 标签页3名称
# 跳转路径3
# ...
# ...
到这里,我们就基本完成了对菜单的描述
内容展示区
这部分我们需要思考可能会存在哪些不同情况,数据展示类、内嵌网页类、emm...(想个damn,用户自己想去),于是我们有了下面的简单分类
# schema 标准化配置类
# iframe 内嵌类
# custom 自定义类
到此我们就基本完成了内容展示区的划分
菜单关联
完成菜单和内容的基本描述之后,我们就需要将二者对应起来,不然怎么知道菜单要跳到哪里去,内容要展示哪些内容(碗里吗,damn)
于是,我们对菜单描述进一步细化
# 菜单栏
# 标签页1
# 标签页1名称
# 菜单类型 (module 模块)
# 页面类型 (schema 标准化)
# schema 配置
# ...
# 标签页2
# 标签页2名称
# 菜单类型 (group 菜单组)
# ...
# 子菜单
# 子标签页1
# 子标签页1名称
# 菜单类型 (module 模块)
# 页面类型 (iframe 内嵌)
# iframe 配置 (子标签页1跳转路径 path: iframe地址)
# ...
# 子标签页2
# 子标签页2名称
# 菜单类型 (module 模块)
# 页面类型 (custom 自定义)
# custom 配置 (子标签页2跳转路径 path)
# ...
# 标签页3
# 标签页3名称
# 菜单类型 (module 模块)
# 页面类型 (schema 标准化)
# schema 配置
# ...
# ...
到这里,我们基本完成了二者的对应关系建立
增加可用性
接下来我们可以来丰富一下我们的标准化配置类页面,使其可以尽量覆盖更多的情况,沉淀更多可复用功能。
这类后台系统最常见的页面是什么呢,针对不同领域,实际情况可能不同,可能是图表展示?或是表格展示?又或是其他。我们这里用表格展示来举例。
表格展示页面呢,通常包含搜索区、表格区、操作区,那这些操作必然对应了不同接口,那么在我们这份精简的说明书中,大量充斥统一模块的 api 路径,似乎不太合理,我们可以遵循 RESTFUL Api的规则,将同模块的数据看作一份资源,不同的接口请求类型就对应了对这份数据的不同操作
于是,经过分析,我们有了更细化的标准化页面配置
# 菜单栏
# 标签页1
# 标签页1名称
# 菜单类型 (module 模块)
# 页面类型 (schema 标准化)
# schema 配置
# api 数据源 (页面由数据驱动) (遵循 RESTFUL 规范)
# 板块数据结构
# 表格列字段 1
# 字段类型
# 字段类型
# 表格列配置
# 搜索区配置
# 操作表单配置
# ...
# 表格列字段 2
# ...
# 表格列字段 3
# ...
# 表格统一配置
# 搜索区配置
# 模块组件配置
# ...
到此我们的DSL就基本完成了
转化
接下来我们需要把这份我们能看懂的说明书,让计算机也能看懂
module.exports = {
model: 'dashboard',
name: '电商系统',
menu: [{
key: 'product',
name: '商品管理',
menuType: 'module',
moduleType: 'schema',
schemaConfig: {
api: '/api/proj/product',
schema: {
title: 'Product',
descriprtion: "product's properties and they styles in table or other section",
type: 'object',
properties: {
product_id: {
type: 'string',
label: '商品ID',
tableOptions: {
width: 150,
'show-overflow-tooltip': true
}
},
product_name: {
type: 'string',
label: '商品名称',
tableOptions: {
width: 200
},
searchOptions: {
comType: 'dynamicSelect',
api: '/api/proj/product_enum/list'
}
},
price: {
type: 'number',
label: '价格',
tableOptions: {
width: 200
},
searchOptions: {
comType: 'select',
enumList: [{
label: '全部',
value: -1
}, {
label: '¥11.11',
value: 11.11
}, {
label: '¥22.22',
value: 22.22
}, {
label: '¥33.33',
value: 33.33
}]
}
},
inventory: {
type: 'number',
label: '库存',
tableOptions: {
width: 200
},
searchOptions: {
comType: 'input',
}
},
create_time: {
type: 'string',
label: '创建时间',
tableOptions: {
'show-overflow-tooltip': true
},
searchOptions: {
comType: 'dateRange'
}
}
}
},
tableConfig: {
headerButtons: [{
label: '新增商品',
type: 'primary',
plain: true,
eventKey: 'showComponent'
}],
rowButtons: [{
label: '修改',
type: 'warning',
eventKey: 'showComponent'
}, {
label: '删除',
type: 'danger',
eventKey: 'remove',
eventOptions: {
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'
},
}]
}
这里我们就完成了一份简单的DSL编写。
解析并继承基类配置
要注意,我们只是完成了模型的"说明书",也就是基类,各个特定的系统,也就是其子类,结构与之类似,但需要继承该份基类配置
下面是解析并继承基类配置的方法
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(-) => 新增 (拓展)
// project(-) model(+) => 保留 (继承) (不用特殊处理)
// 处理修改
for (let i = 0; i < modelValue.length; i++) {
let modelValItem = modelValue[i]
const projValItem = projValue.find(projValItem => projValItem.key === modelValItem.key)
// project(+) model(+) 则递归调用 projectExtendModel 方法覆盖修改
result.push(projValItem ? projectExtendModel(modelValItem, projValItem) : modelValItem)
}
// 处理新增
for (let i = 0; i < projValue.length; i++) {
const projValItem = projValue[i]
const modelValItem = modelValue.find(modelValItem => modelValItem.key === projValItem.key)
if (!modelValItem) {
result.push(projValItem)
}
}
return result
}
})
}
/**
* 解析 model 配置 并 返回组织且继承后的数据结构
* @param {object} app
*
[{
model: ${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 => {
const filePath = path.resolve(file)
if (filePath.indexOf('index.js') > -1) return
// 区分配置类型 (model / project)
const type = filePath.indexOf(`${sep}project${sep}`) > -1 ? 'project' : 'model'
if (type === 'model') {
const modelKey = filePath.match(/[\\/]model[\\/](.*?)[\\/]model\.js/)?.[1]
let modelItem = modelList.find(item => item.model?.key === modelKey)
if (!modelItem) {
modelItem = {}
modelList.push(modelItem)
modelItem.model = require(filePath)
modelItem.model.key = modelKey // 注入 modelKey
}
}
if (type === 'project') {
const modelKey = filePath.match(/[\\/]model[\\/](.*?)[\\/]project[\\/]/)?.[1]
const projKey = filePath.match(/[\\/]project[\\/](.*?)\.js/)?.[1]
let modelItem = modelList.find(item => item.model?.key === modelKey)
if (!modelItem) {
modelItem = {}
modelList.push(modelItem)
}
if (!modelItem.project) { // 初始化 project 数据结构
modelItem.project = {}
}
modelItem.project[projKey] = require(filePath)
modelItem.project[projKey].key = projKey // 注入 projectKey
modelItem.project[projKey].modelKey = modelKey // 注入 modelKey
}
})
// 数据进一步处理 project => 继承 model
modelList.forEach(item => {
const { model, project } = item
for (const key in project) {
project[key] = projectExtendModel(model, project[key])
}
})
return modelList
}
到这里,我们可以告一段落,我们以及实现了抽象基类、子类继承、以及解析配置的步骤,下面就是在项目中基于这份配置动态渲染的过程,这里不再赘述
思考
在复盘重复性工作时,我们发现系统开发存在大量重复劳动。于是,我们尝试把重复性系统开发,抽象为模型与实例的配置文件。通过解析这份配置文件,就能生成相应的站点。
目前,配置文件由开发人员手动填写。对此,我们可以进一步思考:能否实现配置文件的自动生成?若要自动生成,应该依据什么生成?生成的配置文件,又该如何精准描述目标系统?
基于这些思考,组件拖拽式系统搭建方案应运而生。我们只需将所需组件拖拽至画布,灵活调整组件的嵌套层级与关联内容。系统就能根据最终布局,自动生成配置文件。后续再对配置文件进行解析,便能完成站点搭建。按照这个思路,或许有望开发出一个低代码平台。当然,这只是我个人的一些想法