这是 Elpis 框架系列的最后一篇。前四篇把框架从服务端内核、Webpack 构建、DSL 配置、表单组件一路讲到了完整的 CRUD 闭环。但到这一步为止,框架代码和业务代码还混在同一个仓库里。这一篇要做的事情是:把 Elpis 变成一个 npm 包,业务项目通过
require("@nickmjiang/elpis")引入,框架和业务彻底分离。
一、为什么要分离
之前的项目结构是这样的:框架代码(elpis-core、webpack 配置、通用组件)和业务代码(controller、router、model 配置、自定义页面)全部放在一个仓库里。
这带来几个问题:
- 框架升级要改业务仓库,业务开发也可能误改框架代码
- 多个业务项目想用同一套框架,只能复制粘贴
- 框架的版本没法管理,出了问题不知道该回退到哪个版本
分离之后,框架是一个独立的 npm 包,业务项目只需要 npm install @nickmjiang/elpis,框架升级就是改个版本号的事。
二、分离的思路
核心问题是:哪些东西属于框架,哪些东西属于业务?
graph TD
Z["Elpis 分离"] --> A["框架 npm 包"]
Z --> K["业务项目"]
A --> A1["elpis-core<br/>Koa 服务端内核"]
A --> A2["webpack 配置<br/>构建体系"]
A --> A3["通用页面<br/>dashboard / schema-view"]
A --> A4["通用组件<br/>schema-table / schema-form"]
A --> A5["基类<br/>BaseController / BaseService"]
K --> K1["model 配置<br/>DSL 定义"]
K --> K2["controller / service<br/>业务 API"]
K --> K3["router / router-schema<br/>路由和校验"]
K --> K4["自定义页面<br/>custom 组件"]
K --> K5["扩展控件<br/>自定义表单 / 搜索控件"]
style Z fill:#f5f5f5,stroke:#9e9e9e
style A fill:#e3f2fd,stroke:#1565c0
style K fill:#fff3e0,stroke:#f57c00
style A1 fill:#e3f2fd,stroke:#1565c0
style A2 fill:#e3f2fd,stroke:#1565c0
style A3 fill:#e3f2fd,stroke:#1565c0
style A4 fill:#e3f2fd,stroke:#1565c0
style A5 fill:#e3f2fd,stroke:#1565c0
style K1 fill:#fff3e0,stroke:#f57c00
style K2 fill:#fff3e0,stroke:#f57c00
style K3 fill:#fff3e0,stroke:#f57c00
style K4 fill:#fff3e0,stroke:#f57c00
style K5 fill:#fff3e0,stroke:#f57c00
简单说:不变的归框架,变化的归业务。
三、框架导出了什么
分离后,Elpis 的 index.js 变成了一个 SDK 入口,对外暴露三个能力:
// index.js — npm 包入口
module.exports = {
// 服务端基类,业务项目继承它写 Controller 和 Service
Controller: {
Base: require("./app/controller/base.js"),
},
Service: {
Base: require("./app/service/base.js"),
},
// 前端构建,根据环境变量选择 dev 还是 prod
frontendBuild(env) {
if (env === "local") FEBuildDev();
if (env === "production") FEBuildProd();
},
// 启动 Koa 服务
serverStart(options = {}) {
return ElpisCore.start(options);
},
};
业务项目的使用方式:
// 业务项目 — 启动服务
const { serverStart } = require("@nickmjiang/elpis");
serverStart({ name: "我的电商后台", homePage: "/view/project-list" });
// 业务项目 — 构建前端
const { frontendBuild } = require("@nickmjiang/elpis");
frontendBuild(process.env._ENV);
// 业务项目 — 写 Controller
const { Controller } = require("@nickmjiang/elpis");
module.exports = (app) => {
const BaseController = Controller.Base(app);
return class ProductController extends BaseController {
async getList(ctx) {
/* 业务逻辑 */
}
};
};
框架提供骨架和基类,业务项目填充具体逻辑。
四、前端扩展点:业务怎么注入自定义内容
框架把 dashboard、schema-view、schema-form 这些通用页面和组件都打包进了 npm 包。但业务项目需要扩展——加自定义路由、加自定义表单控件、加自定义动态组件。
问题是:npm 包里的代码是固定的,业务项目怎么往里面"注入"自己的东西?
答案是 Webpack alias + 空模块降级。
4.1 扩展点设计
框架定义了四个扩展点,每个扩展点对应业务项目中的一个约定文件:
| 扩展点 | 业务项目约定路径 | 作用 |
|---|---|---|
| 路由扩展 | app/pages/dashboard/router.js | 注入自定义路由(custom 页面) |
| 动态组件扩展 | app/pages/dashboard/.../component-config.js | 注入自定义动态组件 |
| 表单控件扩展 | app/pages/weights/schema-form/form-item-config.js | 注入自定义表单控件 |
| 搜索控件扩展 | app/pages/weights/schema-search-bar/search-item-config.js | 注入自定义搜索控件 |
4.2 实现原理
Webpack 构建时,框架在 resolve.alias 中为每个扩展点定义一个别名。如果业务项目中存在对应的文件,alias 指向业务文件;如果不存在,alias 指向一个空模块。
// webpack.base.js — alias 动态生成
const blankModulePath = path.resolve(__dirname, "../libs/blank.js");
// 检查业务项目是否有路由扩展文件
const businessDashboardRouterConfig = path.resolve(
process.cwd(),
"./app/pages/dashboard/router.js",
);
aliasMap["$businessDashboardRouterConfig"] = fs.existsSync(
businessDashboardRouterConfig,
)
? businessDashboardRouterConfig // 存在 → 指向业务文件
: blankModulePath; // 不存在 → 指向空模块
空模块就一行代码:
// libs/blank.js
module.exports = {};
框架内部的代码通过 alias 引入业务扩展,然后用展开运算符合并:
// component-config.js(框架内部)
import BusinessComponentConfig from "$businessComponentConfig";
const ComponentConfig = {
createForm: { component: createForm },
editForm: { component: editForm },
detailPanel: { component: DetailPanel },
};
export default {
...ComponentConfig, // 框架内置的组件
...BusinessComponentConfig, // 业务扩展的组件(没有就是空对象)
};
搜索控件和表单控件的扩展方式完全一样:
// form-item-config.js(框架内部)
import BusinessFormItemConfig from "$businessFormItemConfig";
export default { ...FormItemConfig, ...BusinessFormItemConfig };
// search-item-config.js(框架内部)
import BusinessSearchItemConfig from "$businessSearchItemConfig";
export default { ...SearchItemConfig, ...BusinessSearchItemConfig };
4.3 路由扩展
路由扩展稍微不同。业务项目导出一个函数,框架调用它并传入 routes 和 siderRouters 数组,业务代码往里面 push 自定义路由:
// entry.dashboard.js(框架内部)
import businessDashboardRouterConfig from "$businessDashboardRouterConfig";
// 业务扩展路由
if (typeof businessDashboardRouterConfig === "function") {
businessDashboardRouterConfig({ routes, siderRouters });
}
业务项目的路由扩展文件:
// 业务项目 app/pages/dashboard/router.js
export default ({ routes, siderRouters }) => {
routes.push({
path: "/view/dashboard/my-custom-page",
component: () => import("./my-custom-page/my-custom-page.vue"),
});
siderRouters.push({
path: "my-sider-page",
component: () => import("./my-sider-page/my-sider-page.vue"),
});
};
这样框架的路由是固定的(schema、iframe、sider),业务的路由是动态注入的,互不干扰。
五、package.json 的变化
发布为 npm 包后,package.json 有几个关键变化:
1. 包名改为 scoped 包
{ "name": "@nickmjiang/elpis" }
2. 构建相关的依赖从 devDependencies 移到 dependencies
之前 webpack、babel-loader、vue-loader 这些都在 devDependencies 里,因为它们只在开发时用。但现在 Elpis 是一个 npm 包,业务项目 npm install @nickmjiang/elpis 时不会安装 devDependencies。而业务项目需要用 Elpis 提供的 frontendBuild() 来构建前端,所以这些构建工具必须放到 dependencies 里,确保业务项目安装后能正常使用。
{
"dependencies": {
"webpack": "^5.88.1",
"webpack-merge": "^4.2.1",
"vue-loader": "^17.2.2",
"babel-loader": "^8.0.4",
"css-loader": "^0.23.1",
"less-loader": "^11.1.3",
"mini-css-extract-plugin": "^2.7.6",
"terser-webpack-plugin": "^5.4.0",
"thread-loader": "^4.0.4"
// ... 所有构建相关的包
},
"devDependencies": {
// 只剩下 eslint、mocha 等纯开发工具
"eslint": "^7.32.0",
"mocha": "^6.1.4",
"supertest": "^4.0.2"
}
}
3. 移除业务相关的 scripts
{
"scripts": {
"lint": "eslint --quiet --ext js,vue .",
"test": "_ENV='local' mocha 'test/**/*.js'"
// dev、beta、prod、build:dev、build:prod 都移除了
// 这些命令由业务项目自己定义
}
}
六、业务代码的剥离
框架仓库中删除了所有业务代码:
app/controller/business.js→ 删除(业务项目自己写 Controller)app/router/business.js→ 删除(业务项目自己定义路由)app/router-schema/business.js→ 删除(业务项目自己写校验规则)app/pages/dashboard/todo/todo.vue→ 删除(业务项目自己写自定义页面)docs/dashboard.model.js→ 删除(文档移到 README)
框架仓库只保留通用的、不随业务变化的代码。
七、业务项目的目录结构
分离后,业务项目的结构变成这样:
my-business-project/
├── index.js # 启动入口
├── build.js # 构建入口
├── package.json
│
├── model/ # DSL 配置
│ ├── business/
│ │ ├── model.js # 基础模型
│ │ └── project/
│ │ ├── taobao.js # 淘宝项目配置
│ │ └── pdd.js # 拼多多项目配置
│ └── index.js
│
├── config/ # 环境配置
│ ├── config.default.js
│ └── config.local.js
│
├── app/
│ ├── controller/ # 业务 Controller
│ ├── service/ # 业务 Service
│ ├── router/ # 业务路由
│ ├── router-schema/ # 参数校验
│ ├── middleware/ # 自定义中间件
│ ├── extend/ # 扩展
│ └── pages/ # 前端页面(可选扩展)
│ ├── dashboard/
│ │ └── router.js # 路由扩展(可选)
│ └── weights/
│ └── schema-form/
│ └── form-item-config.js # 表单控件扩展(可选)
启动入口只需要两行:
// index.js
const { serverStart } = require("@nickmjiang/elpis");
serverStart({ name: "我的电商后台", homePage: "/view/project-list" });
构建入口也只需要两行:
// build.js
const { frontendBuild } = require("@nickmjiang/elpis");
frontendBuild(process.env._ENV);
elpis-core 的 Loader 机制会自动扫描业务项目的 app/ 目录,加载 controller、service、router 等。Webpack 的 alias 机制会自动检测扩展文件是否存在。业务项目不需要做任何"注册"操作,放对目录就行。
八、扩展点总结
graph LR
E["业务 router.js"] -->|alias + merge| A["内置路由<br/>schema / iframe / sider"]
F["业务 component-config.js"] -->|alias + merge| B["内置动态组件<br/>createForm / editForm / detailPanel"]
G["业务 form-item-config.js"] -->|alias + merge| C["内置表单控件<br/>input / inputNumber / select"]
H["业务 search-item-config.js"] -->|alias + merge| D["内置搜索控件<br/>input / select / dynamicSelect / dateRange"]
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#e3f2fd,stroke:#1565c0
style C fill:#e3f2fd,stroke:#1565c0
style D fill:#e3f2fd,stroke:#1565c0
style E fill:#fff3e0,stroke:#f57c00
style F fill:#fff3e0,stroke:#f57c00
style G fill:#fff3e0,stroke:#f57c00
style H fill:#fff3e0,stroke:#f57c00
四个扩展点都遵循同一个模式:
- 框架通过 Webpack alias 引入业务文件
- 如果业务文件不存在,alias 降级到空模块
{} - 框架用
{ ...内置配置, ...业务配置 }合并 - 业务配置可以新增,也可以覆盖同名的内置配置
这个模式让框架开箱即用(不写任何扩展文件也能正常运行),同时保留了完整的扩展能力。
九、从项目到框架
回顾整个系列,Elpis 经历了这样一个演进过程:
graph LR
A["① elpis-core<br/>服务端内核"] --> B["② Webpack<br/>构建体系"]
B --> C["③ DSL 配置<br/>菜单/路由/渲染"]
C --> D["④ Schema 表单<br/>CRUD 闭环"]
D --> E["⑤ NPM 发布<br/>框架与业务分离"]
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#fff3e0,stroke:#f57c00
style C fill:#e8f5e9,stroke:#2e7d32
style D fill:#f3e5f5,stroke:#6a1b9a
style E fill:#fce4ec,stroke:#c62828
从一个具体的业务项目,逐步抽象出通用的框架能力,最后发布为独立的 npm 包。这个过程本身就是框架设计的典型路径:先在业务中验证,再抽象,最后分离。