Elpis NPM 发布:把框架从业务中剥离出来

0 阅读6分钟

这是 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 路由扩展

路由扩展稍微不同。业务项目导出一个函数,框架调用它并传入 routessiderRouters 数组,业务代码往里面 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

四个扩展点都遵循同一个模式:

  1. 框架通过 Webpack alias 引入业务文件
  2. 如果业务文件不存在,alias 降级到空模块 {}
  3. 框架用 { ...内置配置, ...业务配置 } 合并
  4. 业务配置可以新增,也可以覆盖同名的内置配置

这个模式让框架开箱即用(不写任何扩展文件也能正常运行),同时保留了完整的扩展能力。


九、从项目到框架

回顾整个系列,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 包。这个过程本身就是框架设计的典型路径:先在业务中验证,再抽象,最后分离。