Elpis-领域模型DSL设计实践

抖音"哲玄前端"《大前端全栈实践》学习记录

项目:Elpis

文章只是描述了大概流程和思路,离最终实现还有一段距离,也无法展示太多代码细节。会通过Elpis配置一个名为 dashboard 的精简版页面作为示例。

概述

业务解析语言是一种面向管理业务的领域特定语言(DSL),其核心目标是通过标准化的业务描述定义,简化企业管理应用(如ERP、CRM等)的开发流程。该语言通过抽象化业务规则与实现细节,提供高度可配置的解决方案。

该语言的设计理念源自企业管理软件开发的实践需求,通过分离业务描述与技术实现,使非技术人员能够通过配置而非编码构建系统。其标化定义推动了"零代码"管理平台的实现,为业务人员直接参与信息化建设提供了工具基础。(来源:百度百科

核心在于分离业务描述与技术实现。起因是我这个星期的工作是写几个列表页和表单页,然后我下个星期和下下星期的工作内容都差不多。于是为了提高效率对一些通用的UI进行了组件封装,然后在长期的工作逐渐统一了一些规范,于是进行一些优化,通过配置化来生成模块(类似表单生成器,把一个JSON数据传给一个组件,然后渲染出一个表单)。

然后下个月又有一个新项目,新项目跟之前的项目也长差不多样子,虽然100个产品就有80个不同的要求和样式,但来来回回都是那些东西。如何让项目能够 配置化(整个项目配置化,而不是某个页面或者某个模块配置化),并且可以快速移植和复用,甚至一套代码就可以配置多个项目。DSL 就是实现这个目的的一种方式。使用者不需要太关注我是用react还是vue开发,只需要按照要求就可以生成整个项目,满足大部分日常需求,同时也暴露了口子可以进行一些定制化页面的开发。

纯前端显然不太可能实现,因为涉及到文件的操作,接口开发等,因此这是一个全栈的项目。使用了 Node.js 作为后端语言,主要使用的框架和工具包括:

  1. Koa
  2. webpack
  3. Vue3 + element-plus
  4. ...

架构示意图

精简流程图


1. 基本流程

模板配置 model 文件夹目录如下:

markdown 复制代码
└── model
    ├── business
    │   ├── model.js
    │   └── project
    │       ├── pdd.js
    │       └── jd.js
    └── index.js

核心数据:每个文件夹就是同一类配置,model/**/model.js 是每一类配置的基类。

css 复制代码
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: {}, // 没有tableOption,不展示
              searchOption: {
                comType: "dynamicSelect",
                api: "/api/proj/product_enum/list",
              },
            },
            inventory: {
              type: "number",
              label: "库存",
              tableOption: {},
              searchOption: {
                comType: "input",
              },
            },
          },
        },
        tableConfig: {
          headerButtons: [
            {
              label: "新增",
              eventKey: "showComponent",
              type: "primary",
              plain: true,
            },
          ],
          rowButtons: [
            {
              label: "编辑",
              eventKey: "showComponent",
              type: "warning",
              plain: true,
            },
          ],
        },
      },
    },
  ],
};

当调用model/index.js时,会把 model/**/model.js(基类)和 model/**/project 下的每个文件进行合并,返回配置列表。

yaml 复制代码
// modelList
[{
    model: {
      model: 'dashboard',
      name: '电商系统',
      menu: [Array],
      key: 'business'
    },
    project: { jd: [Object], pdd: [Object] }
}]

返回的 modelList 会被 app/service/project.js 调用

app/service/project.js则会被 app/controller/project.js 层调用。

接下来,controller 就会被 router 调用,生成几个接口。

另一方面 router 也可以渲染页面。

所以我们访问 /view/dashboard/schema?proj_key=jd&key=product ,就会通过 nunjucks 动态解析我们打包出来的模版文件,这个模板就是一个完整的SPA单页应用的结构。这个模板就跟我们平时用vue开发一个项目有点类似。我们设计了路由,页面,同时我们也知道接口地址(api/projapi/proj/list等),并且接口是遵循RESTFul的。

阶段性回顾下,按照上面的分析可以通过文件的调用链路,了解整个过程。

2. 项目模版设计

定义一个boot函数,作为启动方法

调用这个boot方法,并传入pageComponent组件和路由,就能初始化项目。pageComponent相当于我们平时vue或者react项目的 App.vue 和 App.jsx 入口文件。那为什么 entry.dashboard.js 引入 boot进行调用就能起效果呢,那是因为 webpack 配置的时候找到了 entry.xxx.js 格式的文件作为入口文件进行解析打包。

ini 复制代码
const glob = require("glob");
const path = require("path");
const webpack = require("webpack");
const { VueLoaderPlugin } = require("vue-loader");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const pageEntries = {};
const htmlWebpackPluginList = [];

// 获取 app/pages 目录下所有入口文件 entry.[pageName].js
const entryList = path.resolve(process.cwd(), "./app/pages/**/entry.**.js");
const entryFiles = glob.sync(entryList);
entryFiles.forEach((file) => {
  const entryName = path.basename(file, ".js");
  // 生成webpack的入口entry配置
  pageEntries[entryName] = file;
  //
  htmlWebpackPluginList.push(
    new HtmlWebpackPlugin({
      // 产物(最终模板)输出路径
      filename: path.resolve(process.cwd(), "./app/public/dist/", `${entryName}.tpl`),
      // 指定要使用的模板文件
      template: path.resolve(process.cwd(), "./app/view/entry.tpl"),
      // 要注入的代码块
      chunks: [entryName],
    })
  );
});

module.exports = {
  // 入口
  entry: pageEntries,
  output: { /**/ },
  resolve: { /**/ },
  plugins:{ /**/ },
  optimization: { /**/ },
};

entry.dashboard.js 里面定义了页面的结构,例如/view/dashboard/schema是只有头部和主体内容的,/view/dashboard/sider 是主体部分有侧边栏,点击才显示内容。

javascript 复制代码
import boot from "$pages/boot.js";
import PageComponent from "./dashboard.vue";

const routes = [];

// 头部菜单路由
routes.push({
  path: "/view/dashboard/iframe",
  component: () => import("./complex-view/iframe-view/iframe-view.vue"),
});
routes.push({
  path: "/view/dashboard/schema",
  component: () => import("./complex-view/schema-view/schema-view.vue"),
});
// custom 自定义路由
routes.push({
  path: "/view/dashboard/todo",
  component: () => import("./todo/todo.vue"),
});

// 侧边栏菜单路由(头部菜单的子路由)
routes.push({
  path: "/view/dashboard/sider",
  component: () => import("./complex-view/sider-view/sider-view.vue"),
  children: [
    {
      path: "iframe",
      component: () => import("./complex-view/iframe-view/iframe-view.vue"),
    },
    {
      path: "schema",
      component: () => import("./complex-view/schema-view/schema-view.vue"),
    },
    {
      path: "todo",
      component: () => import("./todo/todo.vue"),
    },
  ],
});

// 侧边栏兜底策略
routes.push({
  path: "/view/dashboard/sider/:chapters+",
  component: () => import("./complex-view/sider-view/sider-view.vue"),
});

boot(PageComponent, { routes });

下面是对一些组件的简单说明:

  • dashboard.vue,全局组件,布局是上面header组件,下面是内容,预留了插槽,可以通过参数透传填充内容。后续所有的组件都会被包在 dashboard.vue 中,都会有header头,一些通用数据,可以在这个组件进行请求,然后存储到全局的状态管理store中,其他组件通过store进行读写。
  • schema-view.vue 通用列表页组件,上面是搜索栏,下面是表格。search-paneltable-panel 是从store中拿到上面提到的 DSL 模版数据,经过处理变成页面比较方便使用的数据,然后进行渲染。
schema—view.vue 复制代码
<template>
  <el-row class="schema-view">
    <search-panel
      v-if="searchSchema?.properties && Object.keys(searchSchema.properties).length > 0"
      @search="onSearch"
    ></search-panel>
    <table-panel @operate="onTableOperate"></table-panel>
  </el-row>
</template>

<script setup>
import { provide, ref } from "vue";
import SearchPanel from "./complex-view/search-panel/search-panel.vue";
import tablePanel from "./complex-view/table-panel/table-panel.vue";
import { useSchema } from "./hook/schema.js";

const { api, tableSchema, tableConfig, searchSchema, searchConfig } = useSchema();

const apiParams = ref({});

provide("schemaViewData", {
  api,
  apiParams,
  tableSchema,
  tableConfig,
  searchSchema,
  searchConfig,
});

const onSearch = (searchValObj) => {
  apiParams.value = searchValObj;
};

const onTableOperate = (operateObj) => {
  console.log(operateObj);
};
</script>

其实后面就是结合 model/**/model.js 数据和自己想要沉淀的组件结构,进行开发。以上文的核心数据为例。

menu.schemaConfig.schema 是一个标准的JSON-schema 格式数据,properties 下面的每个字段的tableOptionsearchOption表示在表格和搜索是否显示以及展示的具体配置,这是属性级别的。

menu.schemaConfig.tableConfig 则是配置整个表格的,例如这个表格头部有新建按钮,这个表格的每一行都有一个操作列,有编辑、删除等。

至于接口请求,使用的是RESTFul规范,menu.schemaConfig.api 配置了 /api/proj/product,除了请求表格数据时,调的接口是 /api/proj/product/list,其他操作通过method的不同进行区分,删除是 delete、新增 post、查询详情是get,以此类推。

篇幅有限,细节和具体代码无法一一呈现。通过这个项目学习可以从一个更加宏观的角度去思考问题,同时打通了数据结构、页面、接口等整个链路,沉淀出更多的通用能力。

相关推荐
赛博丁真Damon19 分钟前
【VSCode插件】【p2p网络】为了硬写一个和MCP交互的日程表插件(Cursor/Trae),我学习了去中心化的libp2p
前端·cursor·trae
江城开朗的豌豆29 分钟前
Vue的keep-alive魔法:让你的组件"假死"也能满血复活!
前端·javascript·vue.js
BillKu1 小时前
Vue3 + TypeScript 中 let data: any[] = [] 与 let data = [] 的区别
前端·javascript·typescript
GIS之路1 小时前
OpenLayers 调整标注样式
前端
爱吃肉的小鹿1 小时前
Vue 动态处理多个作用域插槽与透传机制深度解析
前端
GIS之路1 小时前
OpenLayers 要素标注
前端
前端付豪1 小时前
美团 Flink 实时路况计算平台全链路架构揭秘
前端·后端·架构
sincere_iu1 小时前
#前端重铸之路 Day7 🔥🔥🔥🔥🔥🔥🔥🔥
前端·面试
设计师也学前端1 小时前
SVG数据可视化组件基础教程7:自定义柱状图
前端·svg
我想说一句1 小时前
当JavaScript的new操作符开始内卷:手写实现背后的奇妙冒险
前端·javascript