Elpis-领域模型DSL设计与实践

0 阅读5分钟

概述

作为一名前端开发,我们每天都会和页面打交道。为了产品页面交互和风格的一致性,产品经理会在各个业务功能、页面设计(原型图)之间保持统一风格(理想状态下),但往往不同的业务方案会给我们不同的页面输出,尽管产品经理已经进了最大努力去磨平差异,但页面间还是会有一小部分的不同,这是业务导致的。那么这个问题应该怎么解决呢?大部分同学通常会把相似的页面复制一份,根据当前的业务改一改就OK,随着业务不断壮大,我们的代码也会越来越臃肿,那么恭喜你,屎山已经堆砌好了。那有没有比较好的系统解决方案呢?这里不得不提到Elpis,Elpis为此而生!

什么是Elpis

Elpis是前后端分离的领域模型框架,通过DSL动态生成不同的页面,来满足我们日常差异化开发,这里主要来介绍DSL。 image.png

image.png

DSL标准配置

module.exports = {
    mode: 'dashboard', // 模版类型,不同模版类型对应不一样的模版数据结构
    name: '', // 名称
    desc: '', // 描述
    icon: '', // 图标
    homePage: '', // 项目首页( )
    // 头部菜单
    menu: [{
        key: '', // 菜单唯一描述
        name: '', // 菜单名称
        menuType: '', // 枚举值,group / module

        // 当 menuType === group 时,可填
        sunMenu: [{
            // 可递归 menuItem
        }],

        // 当 menuType ===  module 时,可填
        moduleType: '', // 枚举值:sider/iframe/custom/schema

        // 当 moduleType == sider 时
        siderConfig: {
            menu: [{
                // 可递归 menuItem(除 moduleType == sider)
            }]
        },

        // 当 moduleType == iframe 时
        iframeConfig: {
            path: '', // iframe 路径
        },


        // 当 moduleType == custom 时
        customConfig: {
            path: '' // 自定义路由路径
        },


        // 当 moduleType == schema 时
        schemaConfig: {
            api: '', // 数据源 API (遵循 RESTFUL 规范)
            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-search-bar 配置
                            comType: '', // 配置组件类型 input/select/...
                            default: '', // 默认值

                            // comType === 'select'
                            enumList: [], // 下拉框选项列表

                            // comType === 'dynamicSelect'
                            api: '', // 动态下拉框数据源 API
                        },
                    }
                }
            },
            tableConfig: {
                headerButtons: [{
                    label: '', // 按钮中文名
                    eventKey: '', // 按钮事件名
                    option: {}, // 按钮具体配置
                    ...elButtonConfig // 标准 el-button 配置
                }],
                rowButtons: [{
                    label: '', // 按钮中文名
                    eventKey: '', // 按钮事件名
                    eventOption: {
                        // 当 eventKey === 'remove'
                        params: {
                            // paramKey  = 参数的键值
                            // rowValueKey = 参数值,格式为 schema::tableKey,到 table 中找相应的字段
                            paramKey: rowValueKey
                        }
                    }, // 按钮具体配置
                    ...elButtonConfig // 标准 el-button 配置
                }],
            }, // table 相关配置
            searchConfig: {}, // search-bar 相关配置
            component: {}, // 模块组件
        }
    }]
}

一份DSL配置就是一个系统描述文件,这里我们以dashboard为例

homePage 设置我们系统首页
menu 头部菜单集合
    menuType 指定菜单类型(group/module),当菜单为下拉多个菜单时,设置为group
    sunMenu  当menuType为group时,配置子菜单,配置项和menu一致(共用一份配置)
    moduleType  当menuType为module时配置,共有四个配置项(sider/iframe/custom/schema)
    siderConfig 有左侧菜单栏时配置,可配置menu,注意左侧菜单栏配置项中除去 moduleType == sider
    iframeConfig 引入第三方页面资源配置
    customConfigConfig 自定义页面,当有个别特殊页面无法配置时,这时候就需要自己去实现页面。
    schemaConfig  标准配置页面配置项
       api 这里设置获取业务数据API,需要遵循 RESTFUL 规范
       schema  定义板块数据结构,是标准 schema 配置,里面可配置 tableOption(表格),searchOption(表单搜索)
    tableConfig   配置headerButtons(表格上方按钮),rowButtons(表格行按钮)
    searchConfig  search-bar 相关配置
    component  模块组件

image.png

在项目中文件结构如上图所示 model.js 里面为我们项目的标准页面配置(可理解为面相对象的基类) project 中的每一个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++) {
                const projItem = projValue[i]
                const modelItem = modelValue.find(modelItem => modelItem.key === projItem.key)
                if (!modelItem) {
                    result.push(projItem)
                }
            }
            return result
        }
    })
}

/**
 * 解析 model 配置,并返回组织且继承后的数据结构
 * 【{
 *     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 => {
        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) { // 初始化 project 数据结构
                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
}

最为核心的 mergeWith 方法

有了这一份DSL配置,页面模版就可以通过DSL来生成具体的页面

想要更系统的学习Elpis,那么 抖音搜索 <哲玄前端>