这是 Elpis 框架系列的第三篇。前两篇分别拆解了服务端框架内核和 Webpack 构建体系,这一篇进入核心主题——如何用一套 DSL(领域特定语言)配置,同时驱动导航菜单、路由跳转、页面渲染、API 请求和数据校验。
一、要解决什么问题
后台管理系统有一个特点:80% 的页面结构是重复的。
商品管理、订单管理、客户管理——每个模块都是"左边菜单 + 右边表格 + 搜索栏 + 表单弹窗"。但传统开发方式下,每个模块都要单独写路由、写页面组件、写 API 对接、写表格列定义、写搜索控件。
更麻烦的是多租户场景。同一套电商系统,淘宝、拼多多、京东各有一套后台,80% 相同,20% 不同。复制三份代码维护成本很高,改一个公共逻辑要同步三份。
Elpis DSL 的思路是:把这些重复的结构抽象成配置,让配置驱动一切。
二、配置长什么样
先看一个完整的配置节点,这是"商品管理"模块的定义:
{
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: -1 },
{ label: "¥39.9", value: 39.9 },
],
},
},
create_time: {
type: "string",
label: "创建时间",
tableOption: {},
searchOption: { comType: "dateRange" },
},
},
},
},
}
这份配置只描述了"是什么":这个模块叫商品管理,它有哪些字段,每个字段是什么类型、在表格里多宽、在搜索栏里用什么控件。它没有说"怎么渲染"——用什么组件、怎么布局、怎么发请求,这些都是框架的事。
三、一次定义,多端投影
一个字段在系统中会出现在多个地方:表格里是一列,搜索栏里是一个控件,表单里是一个输入框。传统做法在每个地方单独定义,信息分散在五六处。
Elpis 的做法是:一个字段定义一次,通过 Option 后缀向不同方向投影。
graph TD
A["字段定义<br/>product_name: { type, label }"] --> B["tableOption<br/>→ 表格列"]
A --> C["searchOption<br/>→ 搜索栏控件"]
A --> D["createFormOption<br/>→ 新增表单控件"]
A --> E["editFormOption<br/>→ 编辑表单控件"]
A --> F["detailPanelOption<br/>→ 详情面板"]
style A fill:#fff3e0,stroke:#f57c00
style B fill:#e3f2fd,stroke:#1565c0
style C fill:#e8f5e9,stroke:#2e7d32
style D fill:#f3e5f5,stroke:#6a1b9a
style E fill:#f3e5f5,stroke:#6a1b9a
style F fill:#efebe9,stroke:#4e342e
以 product_name 为例:
product_name: {
type: "string",
label: "商品名称",
tableOption: { width: 200 }, // → 表格中宽 200px 的一列
searchOption: { comType: "dynamicSelect", api: "/api/..." }, // → 搜索栏中一个动态下拉框
createFormOption: { comType: "input" }, // → 新增表单中一个输入框
editFormOption: { comType: "input" }, // → 编辑表单中一个输入框
detailPanelOption: {}, // → 详情面板中展示
}
框架内部有一个 buildDtoSchema 方法,负责从完整配置中提取某个方向的投影:
// hook/schema.js
const buildDtoSchema = (_schema, comName) => {
const dtoSchema = { type: "object", properties: {} };
for (const key in _schema.properties) {
const props = _schema.properties[key];
// 只提取有对应 Option 的字段
if (props[`${comName}Option`]) {
let dtoProps = {};
// 提取非 Option 的公共属性(type、label 等)
for (const pKey in props) {
if (pKey.indexOf("Option") < 0) {
dtoProps[pKey] = props[pKey];
}
}
// 把对应的 Option 提取为 option 字段
dtoProps.option = props[`${comName}Option`];
dtoSchema.properties[key] = dtoProps;
}
}
return dtoSchema;
};
调用 buildDtoSchema(schema, "table") 就得到表格需要的 schema,调用 buildDtoSchema(schema, "search") 就得到搜索栏需要的 schema。同一份原始配置,按需提取,不同组件各取所需。
四、配置节点类型:一个节点同时承载三种职责
后台系统有层级(商品管理下面有列表、表单)、有路由(点击菜单跳转页面)、有渲染类型(有些是表格,有些是嵌入外部页面)。传统做法用菜单表达层级、用路由配置表达页面、用业务代码表达渲染类型——三套体系要手动对齐。
Elpis 用一个配置节点统一承载这三种职责。节点有两个关键属性:menuType 和 moduleType。
graph TD
A["配置节点"] --> B{"menuType"}
B -->|group| C["分组节点<br/>只组织层级,不对应功能<br/>包含 subMenu 子节点"]
B -->|module| D{"moduleType"}
D -->|schema| E["Schema 模块<br/>配置驱动的 CRUD 页面<br/>表格 + 搜索 + 表单"]
D -->|iframe| F["Iframe 模块<br/>嵌入外部页面<br/>零开发"]
D -->|sider| G["侧边栏模块<br/>左侧菜单 + 右侧内容<br/>内部可继续嵌套"]
D -->|custom| H["自定义模块<br/>代码逃生舱<br/>指向自定义 Vue 组件"]
style C fill:#e8f5e9,stroke:#2e7d32
style E fill:#e3f2fd,stroke:#1565c0
style F fill:#fff3e0,stroke:#f57c00
style G fill:#f3e5f5,stroke:#6a1b9a
style H fill:#efebe9,stroke:#4e342e
看一个实际的配置树:
// model/business/project/jd.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: "group", // group 可以嵌套 group
subMenu: [
{
key: "category-2",
name: "二级分类",
menuType: "module",
moduleType: "iframe", // 嵌入外部页面
iframeConfig: { path: "https://www.doubao.com" },
},
],
},
],
},
];
这棵配置树直接映射为三个东西:
- 导航菜单的层级结构(group 渲染为子菜单)
- 路由跳转的目标路径(moduleType 决定跳转到
/schema、/iframe、/sider还是自定义路径) - 页面的渲染方式(schema 渲染表格+搜索+表单,iframe 渲染嵌入页,sider 渲染侧边栏布局)
改结构只需要改配置,不需要动代码。
五、容器与内容分离
页面布局和业务内容是两个不同的维度。Elpis 把它们分开:布局是稳定的骨架,内容是流动的业务,插槽是连接方式。
graph TD
A["header-container<br/>顶部导航骨架"] --> B["#menu-content 插槽<br/>放菜单"]
A --> C["#setting-content 插槽<br/>放项目切换"]
A --> D["#main-content 插槽<br/>放主体内容"]
D --> E["router-view<br/>根据路由渲染不同内容"]
E -->|"/schema"| F["schema-view<br/>表格 + 搜索 + 表单"]
E -->|"/iframe"| G["iframe-view<br/>嵌入外部页面"]
E -->|"/sider"| H["sider-view<br/>侧边栏布局"]
H --> I["sider-container<br/>侧边栏骨架"]
I --> J["#menu-content 插槽<br/>放侧边菜单"]
I --> K["#main-content 插槽<br/>放 router-view"]
K --> L["继续嵌套<br/>schema / iframe / custom"]
style A fill:#fff3e0,stroke:#f57c00
style H fill:#f3e5f5,stroke:#6a1b9a
style I fill:#f3e5f5,stroke:#6a1b9a
style F fill:#e3f2fd,stroke:#1565c0
style G fill:#e8f5e9,stroke:#2e7d32
项目中有两个容器组件:
header-container:顶部导航 + 主体内容区,暴露三个插槽(菜单、设置、主内容)。
sider-container:左侧菜单 + 右侧内容区,暴露两个插槽(菜单、主内容)。
<!-- sider-container.vue -->
<el-container class="sider-container">
<el-aside width="200px">
<slot name="menu-content" />
</el-aside>
<el-main>
<slot name="main-content" />
</el-main>
</el-container>
容器不关心插槽里放什么。header-container 的 #main-content 里可以放 router-view,sider-view 的 #main-content 里也可以放 router-view。容器可以嵌套容器——顶部导航里嵌套侧边栏,侧边栏里再嵌套具体内容。
这样改布局不需要动业务代码,改业务内容也不需要动布局。
六、路由是怎么工作的
配置中的 moduleType 决定了点击菜单后跳转到哪个路由,路由决定了渲染哪个 Vue 组件。
graph LR
A["点击菜单项"] --> B{"moduleType"}
B -->|schema| C["跳转 /view/dashboard/schema"]
B -->|iframe| D["跳转 /view/dashboard/iframe"]
B -->|sider| E["跳转 /view/dashboard/sider"]
B -->|custom| F["跳转 customConfig.path"]
C --> G["schema-view.vue"]
D --> H["iframe-view.vue"]
E --> I["sider-view.vue"]
style C fill:#e3f2fd,stroke:#1565c0
style D fill:#fff3e0,stroke:#f57c00
style E fill:#f3e5f5,stroke:#6a1b9a
style F fill:#efebe9,stroke:#4e342e
菜单点击时的路由跳转逻辑:
// dashboard.vue
const onMenuSelect = (menuItem) => {
const { moduleType, key, customConfig } = menuItem;
const pathMap = {
sider: "/sider",
iframe: "/iframe",
schema: "/schema",
custom: customConfig?.path,
};
router.push({
path: `/view/dashboard${pathMap[moduleType]}`,
query: { key, proj_key: route.query.proj_key },
});
};
路由定义中,sider 类型支持嵌套子路由——侧边栏内部的内容区也是一个 router-view,可以继续渲染 schema、iframe 或 custom:
// entry.dashboard.js
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") },
],
});
URL 中通过 key 和 proj_key 两个 query 参数定位当前模块和当前项目。sider 类型还有一个 sider_key 参数,定位侧边栏中选中的子模块。
七、Schema View:配置如何变成页面
schema-view 是整个 DSL 的核心渲染器。它接收配置,输出一个完整的 CRUD 页面。
graph TD
A["schema-view 接收配置"] --> B["useSchema Hook<br/>从 menuStore 中找到当前模块配置"]
B --> C["buildDtoSchema(schema, 'table')<br/>提取表格 schema"]
B --> D["buildDtoSchema(schema, 'search')<br/>提取搜索 schema"]
B --> E["提取 tableConfig<br/>按钮配置"]
B --> F["提取 componentConfig<br/>动态组件配置"]
C --> G["table-panel<br/>渲染表格"]
D --> H["search-panel<br/>渲染搜索栏"]
E --> G
F --> I["动态组件<br/>表单 / 详情面板"]
style A fill:#fff3e0,stroke:#f57c00
style B fill:#f3e5f5,stroke:#6a1b9a
style G fill:#e3f2fd,stroke:#1565c0
style H fill:#e8f5e9,stroke:#2e7d32
style I fill:#fce4ec,stroke:#c62828
useSchema 是一个 Vue Composable Hook,它做的事情是:
- 监听路由变化(
route.query.key改变说明用户切换了模块) - 从
menuStore中根据key找到对应的配置节点 - 调用
buildDtoSchema分别提取 table、search、form 等方向的 schema - 通过
provide把处理后的数据注入给子组件
// schema-view.vue
const { api, tableConfig, tableSchema, searchSchema, components } = useSchema();
const apiParams = ref({});
provide("schemaViewData", {
api,
apiParams,
tableSchema,
tableConfig,
searchSchema,
components,
});
子组件通过 inject 获取数据,各自渲染自己的部分。搜索栏搜索后更新 apiParams,表格组件监听到 apiParams 变化后重新请求数据。
八、搜索栏:动态组件渲染
搜索栏的控件类型由配置中的 searchOption.comType 决定。框架维护了一个控件类型到 Vue 组件的映射表:
// search-item-config.js
const SearchItemConfig = {
input: { component: Input },
select: { component: Select },
dynamicSelect: { component: DynamicSelect },
dateRange: { component: DateRange },
};
搜索栏组件遍历 schema 的 properties,根据每个字段的 comType 动态渲染对应的控件:
<!-- schema-search-bar.vue -->
<el-form-item
v-for="(schemaItem, key) in schema.properties"
:key="key"
:label="schemaItem.label"
>
<component
:is="SearchItemConfig[schemaItem.option?.comType]?.component"
:schemaKey="key"
:schema="schemaItem"
/>
</el-form-item>
Vue 的 <component :is> 是动态组件的关键——它根据传入的组件对象决定渲染哪个组件。配置里写 comType: "dynamicSelect",框架就渲染 DynamicSelect 组件。
每个控件组件都暴露统一的接口:getValue() 获取当前值,reset() 重置。搜索栏在点击"搜索"时遍历所有控件调用 getValue(),合并成一个查询参数对象。
const getValue = () => {
let dtoObject = {};
searchComList.value.forEach((component) => {
dtoObject = { ...dtoObject, ...component?.getValue() };
});
return dtoObject;
};
dynamicSelect 控件比较特殊——它的选项列表不是写死在配置里的,而是通过 api 字段指定一个接口,组件挂载时自动请求接口获取选项:
// dynamic-select.vue
const fetchEnumList = async () => {
const res = await $curl({ method: "get", url: schema.option?.api });
if (res?.data?.length > 0) {
enumList.value.push(...res.data);
}
};
onMounted(async () => {
await fetchEnumList();
reset();
});
九、表格:配置驱动列渲染和操作按钮
schema-table 组件接收 table schema 和按钮配置,渲染表格。
表格列由 schema 的 properties 驱动:
<template v-for="(schemaItem, key) in schema.properties">
<el-table-column
v-if="schemaItem.option.visible !== false"
:prop="key"
:label="schemaItem.label"
v-bind="schemaItem.option"
/>
</template>
v-bind="schemaItem.option" 把 tableOption 中的所有配置(width、show-overflow-tooltip 等)直接透传给 el-table-column。配置里写什么属性,ElementPlus 的表格列就接收什么属性。visible: false 的字段不渲染。
操作按钮(新增、修改、删除)由 tableConfig 中的 headerButtons 和 rowButtons 驱动:
tableConfig: {
headerButtons: [
{ label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } },
],
rowButtons: [
{ label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
{ label: "删除", eventKey: "remove", eventOption: { params: { product_id: "schema::product_id" } } },
],
}
按钮不直接绑定处理函数,而是声明一个 eventKey。点击按钮时,schema-view 根据 eventKey 查找对应的处理器:
const EventHandleMap = {
showComponent, // 展示动态组件(表单、详情面板)
};
const onTableOperate = ({ buttonConfig, rowData }) => {
const { eventKey } = buttonConfig;
EventHandleMap[eventKey]?.({ buttonConfig, rowData });
};
配置只说"点击这个按钮要做什么事"(eventKey),不说"怎么做"。具体的处理逻辑在框架内置的事件处理器中。
十、Model → Project 继承
多租户场景下,不同项目之间大部分配置相同。Elpis 用 Model + Project 的继承体系解决这个问题。
graph TD
A["Model 基础模型<br/>model/business/model.js<br/>商品管理 + 订单管理 + 客户管理"] --> B["Project 淘宝<br/>project/taobao.js<br/>只写差异"]
A --> C["Project 拼多多<br/>project/pdd.js<br/>只写差异"]
A --> D["Project 京东<br/>project/jd.js<br/>只写差异"]
B --> E["合并结果<br/>Model 配置 + 淘宝差异"]
C --> F["合并结果<br/>Model 配置 + 拼多多差异"]
D --> G["合并结果<br/>Model 配置 + 京东差异"]
style A fill:#fff3e0,stroke:#f57c00
style B fill:#e3f2fd,stroke:#1565c0
style C fill:#e8f5e9,stroke:#2e7d32
style D fill:#fce4ec,stroke:#c62828
Model 定义基础配置(电商系统通用的商品管理、订单管理、客户管理),Project 只写和 Model 不同的部分。
合并规则在 model/index.js 的 projectExtendModel 方法中实现:
const projectExtendModel = (model, proj) => {
return _.mergeWith({}, model, proj, (modelValue, projValue) => {
if (Array.isArray(modelValue) && Array.isArray(projValue)) {
let result = [];
// 同 key → 递归合并(修改/重载)
for (const modelItem of modelValue) {
const projItem = projValue.find((p) => p.key === modelItem.key);
result.push(
projItem ? projectExtendModel(modelItem, projItem) : modelItem,
);
}
// Project 独有 → 追加(扩展)
for (const projItem of projValue) {
if (!modelValue.find((m) => m.key === projItem.key)) {
result.push(projItem);
}
}
return result;
}
// 非数组字段:lodash mergeWith 默认深度合并
});
};
三条规则:
- Model 有、Project 也有(同 key)→ 递归合并,Project 覆盖 Model
- Model 有、Project 没有 → 保留 Model 的(继承)
- Model 没有、Project 有 → 追加 Project 的(扩展)
举个例子。Model 定义了商品管理、订单管理、客户管理。拼多多的 Project 配置:
// model/business/project/pdd.js
menu: [
{
key: "product", // 同 key → 和 Model 的商品管理合并
name: "拼多多商品管理", // 覆盖名称
},
{
key: "data-analysis", // 新 key → 追加
name: "数据分析",
moduleType: "sider",
siderConfig: { menu: [...] },
},
]
合并后拼多多拥有:拼多多商品管理(改了名字)+ 订单管理(继承)+ 客户管理(继承)+ 数据分析(新增)。
Project 只存储"和 Model 的差异",类似 Git 存储 diff 而不是快照。
十一、服务端如何配合
配置不只驱动前端,也驱动服务端。
graph TD
A["浏览器访问<br/>/view/dashboard?proj_key=pdd"] --> B["Koa Router<br/>匹配 /view/:page"]
B --> C["ViewController<br/>渲染 entry.dashboard.tpl<br/>注入 projKey"]
C --> D["前端 Vue 应用启动"]
D --> E["请求 /api/project?proj_key=pdd<br/>获取项目配置(含 menu 树)"]
E --> F["menuStore 缓存配置"]
F --> G["header-view 渲染菜单"]
G --> H["用户点击菜单"]
H --> I["路由跳转 + schema-view 渲染"]
I --> J["请求 /api/proj/product/list<br/>获取表格数据"]
style A fill:#e8f5e9,stroke:#2e7d32
style E fill:#e3f2fd,stroke:#1565c0
style J fill:#e3f2fd,stroke:#1565c0
服务端做了几件事:
1. 配置加载与合并
model/index.js 在服务启动时扫描 model/ 目录,加载所有 Model 和 Project 配置,执行继承合并,生成最终的配置数据。
2. 项目配置 API
// app/controller/project.js
async get(ctx) {
const { proj_key: projKey } = ctx.request.query;
const projectConfig = await ProjectService.get({ projKey });
this.success(ctx, projectConfig); // 返回完整的项目配置(含 menu 树)
}
前端通过 /api/project?proj_key=pdd 获取拼多多的完整配置,包括合并后的 menu 树。
3. 业务 API 的项目隔离
业务 API(/api/proj/ 前缀)通过 project-handler 中间件注入 projKey:
// app/middleware/project-handler.js
module.exports = (app) => {
return async (ctx, next) => {
if (ctx.path.indexOf("/api/proj/") < 0) return next();
const { proj_key: projKey } = ctx.request.headers;
if (!projKey) {
ctx.body = { success: false, message: "proj_key not found", code: 446 };
return;
}
ctx.projKey = projKey; // 注入到 ctx,Controller 中可以直接使用
return next();
};
};
Controller 中通过 ctx.projKey 区分不同项目的数据:
// app/controller/business.js
getList(ctx) {
let productList = [
{ product_id: 1, product_name: `${ctx.projKey} -- xx`, price: 39.9 },
// ...
];
this.success(ctx, productList);
}
前端的 curl.js 在请求业务 API 时自动把 proj_key 放到 headers 中,整个链路自动完成项目隔离。
十二、Pinia Store:前端状态管理
前端用两个 Pinia Store 管理全局状态:
menuStore:缓存当前项目的菜单配置,提供菜单查找方法。
// store/menu.js
const findMenuItem = function ({ key, value }, mList = menuList.value) {
for (const menuItem of mList) {
if (menuItem[key] === value) return menuItem;
// 递归搜索 group 的 subMenu
if (menuItem.menuType === "group" && menuItem.subMenu) {
const found = findMenuItem({ key, value }, menuItem.subMenu);
if (found) return found;
}
// 递归搜索 sider 的 menu
if (menuItem.moduleType === "sider" && menuItem.siderConfig?.menu) {
const found = findMenuItem({ key, value }, menuItem.siderConfig.menu);
if (found) return found;
}
}
};
这个递归查找方法支持在任意深度的嵌套菜单中定位节点。schema-view、sider-view、iframe-view 都通过它根据 URL 中的 key 参数找到对应的配置节点。
projectStore:缓存同模型下的项目列表,用于顶部的项目切换下拉框。
十三、完整数据流
从用户进入系统到看到一个 CRUD 页面,完整链路:
sequenceDiagram
participant 用户
participant Koa as Koa 服务端
participant Vue as Vue 前端
用户->>Koa: 访问 /view/dashboard?proj_key=pdd
Koa->>Koa: 渲染 entry.dashboard.tpl,注入 projKey
Koa-->>用户: 返回 HTML
用户->>Vue: 浏览器加载 JS,Vue 应用启动
Vue->>Koa: GET /api/project?proj_key=pdd
Koa->>Koa: 从 modelList 中查找 pdd 的合并配置
Koa-->>Vue: 返回 { name, menu: [...] }
Vue->>Vue: menuStore 缓存 menu 配置
Vue->>Vue: header-view 根据 menu 渲染顶部菜单
用户->>Vue: 点击"商品管理"
Vue->>Vue: 路由跳转 /schema?key=product&proj_key=pdd
Vue->>Vue: useSchema 从 menuStore 找到 product 配置
Vue->>Vue: buildDtoSchema 提取 tableSchema、searchSchema
Vue->>Vue: 渲染搜索栏 + 表格
Vue->>Koa: GET /api/proj/product/list (headers: proj_key=pdd)
Koa->>Koa: projectHandler 中间件注入 ctx.projKey
Koa->>Koa: BusinessController 返回数据
Koa-->>Vue: 返回 { success, data: [...] }
Vue->>Vue: 表格填充数据
十四、边界在哪里
配置驱动不是万能的。Elpis 明确了什么该配置、什么不该配置:
| 该配置的 | 不该配置的 | 原因 |
|---|---|---|
| 字段类型、名称 | 交互流程 | 交互是事件处理器的事,配置只声明 eventKey |
| 表格列宽 | 颜色、间距 | 列宽是业务决策,颜色间距是主题系统的事 |
| 搜索控件类型 | 控件内部逻辑 | 控件类型可枚举,内部逻辑由组件实现 |
| 模块层级关系 | 复杂业务流程 | 层级是树形结构,复杂流程需要代码 |
对于配置覆盖不了的场景,moduleType: "custom" 是逃生舱——指向一个自定义 Vue 组件,用代码实现任何逻辑。覆盖 80% 的通用场景,为 20% 保留代码出口。