普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月12日首页

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

作者 NickJiangDev
2026年4月12日 14:20

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

Elpis Schema 动态组件与表单:配置驱动的完整 CRUD 闭环

作者 NickJiangDev
2026年4月12日 14:13

这是 Elpis 框架系列的第四篇。第三篇讲了 DSL 配置如何驱动菜单、路由、搜索栏和表格。但一个完整的 CRUD 页面还缺三块:新增表单、编辑表单、详情面板。这一篇补上最后的拼图——如何用同一套 Schema 配置,驱动表单渲染、数据回填、字段校验和 API 提交。


一、要补什么

上一篇结束时,schema-view 已经能渲染搜索栏和表格了。但表格里的"新增"、"修改"、"查看详情"按钮点了之后什么都不会发生。

这一篇要做的事情:

  1. 实现 schema-form 通用表单组件,根据配置动态渲染表单控件
  2. 实现三个动态组件:create-form(新增)、edit-form(编辑)、detail-panel(详情)
  3. 用 AJV(JSON Schema 校验库)做表单字段校验
  4. 打通按钮点击 → 弹出表单 → 填写/校验 → 提交 API → 刷新表格的完整链路

二、动态组件是怎么挂上去的

上一篇讲过,表格按钮通过 eventKey: "showComponent" 声明"点击后要展示一个组件",eventOption.comName 指定展示哪个组件。

// model 配置
tableConfig: {
  headerButtons: [
    { label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } },
  ],
  rowButtons: [
    { label: "查看详情", eventKey: "showComponent", eventOption: { comName: "detailPanel" } },
    { label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
  ],
}

comName 对应的 Vue 组件在 component-config.js 中注册:

// component-config.js
import createForm from "./create-form/create-form.vue";
import editForm from "./edit-form/edit-form.vue";
import DetailPanel from "./detail-panel/detail-panel.vue";

const ComponentConfig = {
  createForm: { component: createForm },
  editForm: { component: editForm },
  detailPanel: { component: DetailPanel },
};

schema-view 根据配置中的 componentConfig 动态渲染这些组件:

<!-- schema-view.vue -->
<component
  v-for="(item, key) in components"
  :key="key"
  :is="ComponentConfig[key]?.component"
  ref="comListRef"
  @command="onComponentCommand"
/>

点击按钮时,schema-view 通过 ref 找到对应的组件实例,调用它的 show() 方法:

// schema-view.vue
function showComponent({ buttonConfig, rowData }) {
  const { comName } = buttonConfig.eventOption;
  const comRef = comListRef.value.find((item) => item.name === comName);
  comRef.show(rowData); // rowData 是当前行数据,新增时为 undefined
}

每个动态组件都通过 defineExpose 暴露 nameshow 两个属性。name 用于匹配,show 用于触发展示。

这套机制的关键是:配置只声明"要展示哪个组件",不关心组件内部怎么实现。新增一种动态组件只需要两步:写一个 Vue 组件,在 component-config.js 里注册。


三、componentConfig:动态组件的配置来源

每个动态组件需要知道自己的标题、按钮文案、主键字段等信息。这些信息在 model 配置的 componentConfig 中定义:

// model/business/model.js
componentConfig: {
  createForm: {
    title: "新增商品",
    saveBtnText: "新增商品",
  },
  editForm: {
    mainKey: "product_id",     // 主键字段,用于查询和提交
    title: "修改商品",
    saveBtnText: "修改商品",
  },
  detailPanel: {
    mainKey: "product_id",
    title: "商品详情",
  },
}

useSchema Hook 在解析配置时,会为每个 componentConfig 中的 key 构建对应的 schema:

// hook/schema.js
const { componentConfig } = mItem;
if (componentConfig && Object.keys(componentConfig).length > 0) {
  const dtoComponents = {};
  for (const comName in componentConfig) {
    dtoComponents[comName] = {
      schema: buildDtoSchema(configSchema, comName), // 提取 createFormOption / editFormOption 等
      config: componentConfig[comName], // 标题、按钮文案等
    };
  }
  components.value = dtoComponents;
}

buildDtoSchema(schema, "createForm") 会从字段定义中提取所有带 createFormOption 的字段,组装成表单需要的 schema。同一个 buildDtoSchema 方法,传不同的 comName,就能提取不同方向的投影。


四、schema-form:通用表单组件

schema-form 是表单的核心渲染器,和搜索栏的 schema-search-bar 思路一样:遍历 schema 的 properties,根据 comType 动态渲染对应的表单控件。

graph TD
    A["schema-form 接收 schema + model"] --> B["遍历 schema.properties"]
    B --> C{"comType"}
    C -->|input| D["Input 组件"]
    C -->|inputNumber| E["InputNumber 组件"]
    C -->|select| F["Select 组件"]

    D --> G["统一接口<br/>validate() + getValue()"]
    E --> G
    F --> G

    style A fill:#fff3e0,stroke:#f57c00
    style D fill:#e3f2fd,stroke:#1565c0
    style E fill:#e3f2fd,stroke:#1565c0
    style F fill:#e3f2fd,stroke:#1565c0
    style G fill:#e8f5e9,stroke:#2e7d32
<!-- schema-form.vue -->
<template v-for="(itemSchema, key) in schema.properties">
  <component
    ref="formComList"
    v-show="itemSchema.option.visible !== false"
    :is="FormItemConfig[itemSchema.option?.comType]?.component"
    :schemaKey="key"
    :schema="itemSchema"
    :model="model ? model[key] : undefined"
  />
</template>

控件类型映射表:

// form-item-config.js
const FormItemConfig = {
  input: { component: Input },
  inputNumber: { component: InputNumber },
  select: { component: Select },
};

和搜索栏的区别在于:

  1. 表单控件多了 model 属性——编辑表单需要回填已有数据
  2. 表单控件多了 validate() 方法——提交前需要校验
  3. 表单控件支持 visibledisabled 配置——有些字段只读(如编辑时的 ID 字段)

schema-form 对外暴露两个方法:

// schema-form.vue
const validate = () => {
  return formComList.value.every((component) => component.validate());
};

const getValue = () => {
  return formComList.value.reduce((dtoObj, component) => {
    return { ...dtoObj, ...component.getValue() };
  }, {});
};

validate() 遍历所有控件,全部通过才返回 truegetValue() 收集所有控件的值,合并成一个对象。


五、表单控件与 AJV 校验

每个表单控件内部都集成了 AJV 校验。AJV 是一个 JSON Schema 校验库,它能根据 JSON Schema 的规则(type、minLength、maxLength、minimum、maximum、pattern、enum 等)自动校验数据。

schema-form 在初始化时创建 AJV 实例,通过 provide 注入给所有子控件:

// schema-form.vue
const Ajv = require("ajv");
const ajv = new Ajv();
provide("ajv", ajv);

以 Input 控件为例,校验流程:

graph TD
    A["用户输入 / 失焦触发校验"] --> B{"required 且为空?"}
    B -->|是| C["提示:不能为空"]
    B -->|否| D["ajv.compile(schema)"]
    D --> E{"校验通过?"}
    E -->|是| F["清除错误提示"]
    E -->|否| G{"错误类型"}
    G -->|type| H["提示:类型必须为 string"]
    G -->|maxLength| I["提示:最大长度应为 N"]
    G -->|minLength| J["提示:最小长度应为 N"]
    G -->|pattern| K["提示:格式不正确"]

    style C fill:#fce4ec,stroke:#c62828
    style H fill:#fce4ec,stroke:#c62828
    style I fill:#fce4ec,stroke:#c62828
    style J fill:#fce4ec,stroke:#c62828
    style K fill:#fce4ec,stroke:#c62828
    style F fill:#e8f5e9,stroke:#2e7d32
// input.vue
const validate = () => {
  validTips.value = null;

  // 1. 必填校验
  if (schema.option?.required && !dtoValue.value) {
    validTips.value = "不能为空";
    return false;
  }

  // 2. AJV Schema 校验
  if (dtoValue.value) {
    const validate = ajv.compile(schema);
    const valid = validate(dtoValue.value);
    if (!valid && validate.errors?.[0]) {
      const { keyword, params } = validate.errors[0];
      if (keyword === "type") validTips.value = `类型必须为 ${schema.type}`;
      if (keyword === "maxLength")
        validTips.value = `最大长度应为 ${params.limit}`;
      if (keyword === "minLength")
        validTips.value = `最小长度应为 ${params.limit}`;
      if (keyword === "pattern") validTips.value = `格式不正确`;
      return false;
    }
  }
  return true;
};

AJV 的 compile 方法接收一个 JSON Schema 对象,返回一个校验函数。调用校验函数传入数据,返回 true/false,失败时 validate.errors 包含详细的错误信息。

这意味着校验规则直接写在字段的 Schema 定义中(typeminLengthmaxLengthpattern 等),不需要额外写校验逻辑。JSON Schema 本身就是校验规则的声明。

InputNumber 控件的校验类似,但处理的是 minimummaximum

// input-number.vue
if (keyword === "minimum") validTips.value = `最小值应为 ${params.limit}`;
if (keyword === "maximum") validTips.value = `最大值应为 ${params.limit}`;

Select 控件校验枚举范围:

// select.vue
let dtoEnum = schema.option?.enumList?.map((item) => item.value) ?? [];
const validate = ajv.compile({ ...schema, enum: dtoEnum });
// 如果选中的值不在枚举列表中 → "取值超出枚举范围"

每个控件都在失焦(blur)或值变化(change)时触发校验,实时反馈错误信息。提交时 schema-form 再做一次全量校验。


六、placeholder 自动生成

表单控件会根据 Schema 中的校验规则自动生成 placeholder 提示:

// input.vue
const { minLength, maxLength, pattern } = schema;
const ruleList = [];
if (schema.option?.placeholder) ruleList.push(schema.option.placeholder);
if (minLength) ruleList.push(`最小长度: ${minLength}`);
if (maxLength) ruleList.push(`最大长度: ${maxLength}`);
if (pattern) ruleList.push(`格式: ${pattern}`);
placeholder.value = ruleList.join("|");

如果一个字段定义了 minLength: 2, maxLength: 50,输入框的 placeholder 会自动显示 最小长度: 2|最大长度: 50。用户不需要看文档就知道输入要求。


七、三个动态组件的实现

7.1 create-form:新增表单

sequenceDiagram
    participant 用户
    participant CreateForm as create-form
    participant SchemaForm as schema-form
    participant API as Koa API

    用户->>CreateForm: 点击"新增商品"按钮
    CreateForm->>CreateForm: show() → 打开 Drawer
    CreateForm->>SchemaForm: 传入 createForm 的 schema
    SchemaForm->>SchemaForm: 动态渲染表单控件
    用户->>SchemaForm: 填写表单
    用户->>CreateForm: 点击"保存"
    CreateForm->>SchemaForm: validate() 校验
    SchemaForm-->>CreateForm: 校验通过
    CreateForm->>SchemaForm: getValue() 获取表单值
    CreateForm->>API: POST /api/proj/product
    API-->>CreateForm: { success: true }
    CreateForm->>CreateForm: 关闭 Drawer
    CreateForm->>CreateForm: emit("command", { event: "loadTableData" })

核心代码:

// create-form.vue
const { api, components } = inject("schemaViewData");

const show = () => {
  const { config } = components.value[name.value];
  title.value = config.title;
  saveBtnText.value = config.saveBtnText;
  isShow.value = true;
};

const save = async () => {
  if (!schemaFormRef.value.validate()) return; // 校验不通过就不提交

  const res = await $curl({
    method: "post",
    url: api.value,
    data: { ...schemaFormRef.value.getValue() },
  });

  if (res?.success) {
    ElNotification({ title: "创建成功", type: "success" });
    close();
    emit("command", { event: "loadTableData" }); // 通知表格刷新
  }
};

7.2 edit-form:编辑表单

编辑表单比新增多两个步骤:根据主键查询已有数据,回填到表单中。

sequenceDiagram
    participant 用户
    participant EditForm as edit-form
    participant SchemaForm as schema-form
    participant API as Koa API

    用户->>EditForm: 点击行"修改"按钮
    EditForm->>EditForm: show(rowData) → 提取主键值
    EditForm->>API: GET /api/proj/product?product_id=1
    API-->>EditForm: 返回商品详情
    EditForm->>SchemaForm: 传入 schema + model(已有数据)
    SchemaForm->>SchemaForm: 渲染控件并回填数据
    用户->>SchemaForm: 修改字段
    用户->>EditForm: 点击"保存"
    EditForm->>SchemaForm: validate() + getValue()
    EditForm->>API: PUT /api/proj/product
    API-->>EditForm: { success: true }
    EditForm->>EditForm: 关闭 + 通知表格刷新

和新增的区别:

// edit-form.vue
const show = (rowData) => {
  const { config } = components.value[name.value];
  mainKey.value = config.mainKey; // "product_id"
  mainValue.value = rowData[config.mainKey]; // 从行数据中取主键值
  isShow.value = true;
  fetchFormData(); // 根据主键查询详情
};

const fetchFormData = async () => {
  const res = await $curl({
    method: "get",
    url: api.value,
    query: { [mainKey.value]: mainValue.value }, // GET /api/proj/product?product_id=1
  });
  dtoModel.value = res.data; // 回填到 schema-form
};

const save = async () => {
  if (!schemaFormRef.value.validate()) return;
  const res = await $curl({
    method: "put", // 用 PUT 而不是 POST
    url: api.value,
    data: {
      [mainKey.value]: mainValue.value, // 提交时带上主键
      ...schemaFormRef.value.getValue(),
    },
  });
  // ...
};

schema-form 接收 model 属性后,每个控件会用 model[key] 作为初始值:

// input.vue
const initData = () => {
  dtoValue.value = model.value ?? schema.option?.default;
};

model.value 有值就用已有数据,没有就用配置中的默认值。

编辑表单中有些字段需要只读(比如商品 ID 不能改),通过 disabled: true 控制:

// model 配置
product_id: {
  editFormOption: {
    comType: "input",
    disabled: true,    // 编辑时不可修改
  },
}

v-bind="schema.option" 会把 disabled 透传给 ElementPlus 的 el-input,输入框自动变为禁用状态。

7.3 detail-panel:详情面板

详情面板不需要表单控件,直接遍历 schema 展示 label + value:

<!-- detail-panel.vue -->
<el-row
  v-for="(item, key) in components[name]?.schema?.properties"
  :key="key"
  class="row-item"
>
  <el-row class="item-label">{{ item.label }}:</el-row>
  <el-row class="item-value">{{ dtoModel[key] }}</el-row>
</el-row>

打开时根据主键查询详情数据,和 edit-form 的 fetchFormData 逻辑一样。区别是详情面板只展示不编辑,不需要 schema-form


八、完整 CRUD 事件流

把所有操作串起来,一个 schema 模块的完整 CRUD 事件流:

graph TD
    A["页面加载"] --> B["schema-view 解析配置"]
    B --> C["渲染搜索栏 + 表格 + 动态组件"]
    C --> D["请求 GET /list 填充表格"]

    D --> E{"用户操作"}
    E -->|搜索| F["收集搜索参数 → 重新请求 /list"]
    E -->|点击新增| G["打开 create-form"]
    E -->|点击修改| H["打开 edit-form"]
    E -->|点击详情| I["打开 detail-panel"]
    E -->|点击删除| J["请求 DELETE"]

    G --> K["填写 → 校验 → POST → 刷新表格"]
    H --> L["查询回填 → 修改 → 校验 → PUT → 刷新表格"]
    I --> M["查询 → 展示详情"]
    J --> N["确认 → DELETE → 刷新表格"]

    K --> D
    L --> D
    N --> D

    style A fill:#e8f5e9,stroke:#2e7d32
    style G fill:#e3f2fd,stroke:#1565c0
    style H fill:#fff3e0,stroke:#f57c00
    style I fill:#f3e5f5,stroke:#6a1b9a
    style J fill:#fce4ec,stroke:#c62828

动态组件通过 emit("command", { event: "loadTableData" }) 通知 schema-view 刷新表格:

// schema-view.vue
const onComponentCommand = (data) => {
  if (data.event === "loadTableData") {
    tablePanelRef.value.loadTableData();
  }
};

这是一个松耦合的通信方式——动态组件不直接操作表格,只发出一个事件,schema-view 作为协调者决定怎么响应。


九、服务端 CRUD API

配置中的 api: "/api/proj/product" 是一个基础路径,框架按照 RESTful 约定拼接完整的 API:

操作 HTTP 方法 URL 说明
列表 GET /api/proj/product/list 搜索栏参数作为 query
详情 GET /api/proj/product 主键作为 query
新增 POST /api/proj/product 表单数据作为 body
修改 PUT /api/proj/product 主键 + 表单数据作为 body
删除 DELETE /api/proj/product 主键作为 body

服务端对应的 Controller:

// app/controller/business.js
async create(ctx) {
  const { product_name, price, inventory } = ctx.request.body;
  this.success(ctx, { product_id: Date.now(), product_name, price, inventory });
}

async update(ctx) {
  const { product_id, product_name, price, inventory } = ctx.request.body;
  this.success(ctx, { product_id, product_name, price, inventory });
}

async get(ctx) {
  const { product_id } = ctx.request.query;
  const productItem = this.getProductList(ctx).find(
    (item) => item.product_id === product_id,
  );
  this.success(ctx, productItem);
}

每个 API 都有对应的 Router Schema 做参数校验:

// app/router-schema/business.js
"/api/proj/product": {
  post: {
    body: {
      type: "object",
      properties: {
        product_name: { type: "string" },
        price: { type: "number" },
        inventory: { type: "number" },
      },
      required: ["product_name"],
    },
  },
  put: {
    body: {
      type: "object",
      properties: {
        product_id: { type: "string" },
        product_name: { type: "string" },
        price: { type: "number" },
        inventory: { type: "number" },
      },
      required: ["product_name", "product_id"],
    },
  },
}

前端用 AJV + JSON Schema 校验,后端也用 AJV + JSON Schema 校验。同一套 Schema 标准,前后端双重保障。


十、从配置到完整 CRUD 页面

回到最开始的问题:一个完整的 CRUD 页面需要多少配置?

{
  key: "product",
  name: "商品管理",
  menuType: "module",
  moduleType: "schema",
  schemaConfig: {
    api: "/api/proj/product",
    schema: {
      type: "object",
      properties: {
        product_name: {
          type: "string", label: "商品名称",
          tableOption: { width: 200 },
          searchOption: { comType: "dynamicSelect", api: "/api/proj/product_enum/list" },
          createFormOption: { comType: "input" },
          editFormOption: { comType: "input" },
          detailPanelOption: {},
        },
        price: {
          type: "number", label: "价格",
          tableOption: { width: 200 },
          searchOption: { comType: "select", enumList: [...] },
          createFormOption: { comType: "inputNumber" },
          editFormOption: { comType: "inputNumber" },
          detailPanelOption: {},
        },
        // ... 其他字段
      },
      required: ["product_name"],
    },
  },
  tableConfig: {
    headerButtons: [{ label: "新增商品", eventKey: "showComponent", eventOption: { comName: "createForm" } }],
    rowButtons: [
      { label: "查看详情", eventKey: "showComponent", eventOption: { comName: "detailPanel" } },
      { label: "修改", eventKey: "showComponent", eventOption: { comName: "editForm" } },
      { label: "删除", eventKey: "remove", eventOption: { params: { product_id: "schema::product_id" } } },
    ],
  },
  componentConfig: {
    createForm:  { title: "新增商品", saveBtnText: "新增商品" },
    editForm:    { mainKey: "product_id", title: "修改商品", saveBtnText: "修改商品" },
    detailPanel: { mainKey: "product_id", title: "商品详情" },
  },
}

这大约 50 行配置,产出的是:一个带搜索栏(支持输入框、下拉框、动态下拉框、日期范围)、带分页表格、带新增/编辑表单(含字段校验)、带详情面板、带删除确认的完整 CRUD 页面。传统写法大概需要 300 行以上的 Vue 代码 + 路由配置 + API 对接代码。

而且这份配置是可继承的——写在 Model 里,所有 Project 自动拥有,Project 只需要写差异部分。

Elpis Webpack 工程化实战:Vue 多页应用的构建体系搭建

作者 NickJiangDev
2026年4月12日 13:53

这是 Elpis 框架系列的第二篇。上一篇拆解了 elpis-core 的服务端框架内核,这一篇聚焦前端——如何用 Webpack 为一个 Koa 全栈项目搭建 Vue 的完整构建体系。


一、整体架构

先看全貌,整个构建体系由三层组成:

graph TD
    subgraph 配置层
        A["webpack.base.js<br/>入口 / Loader / 插件 / 代码分割"]
        B["webpack.dev.js<br/>HMR / Source Map"]
        C["webpack.prod.js<br/>多线程 / 压缩 / CSS 抽离"]
    end

    subgraph 执行层
        D["dev.js<br/>Express DevServer"]
        E["prod.js<br/>构建脚本"]
    end

    subgraph 插件层
        F["MultiThreadPlugin<br/>多线程打包插件"]
    end

    A -->|merge| B
    A -->|merge| C
    B --> D
    C --> E
    C --> F

    style A fill:#fff3e0,stroke:#f57c00
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#fce4ec,stroke:#c62828
    style D fill:#e3f2fd,stroke:#1565c0
    style E fill:#fce4ec,stroke:#c62828
    style F fill:#f3e5f5,stroke:#6a1b9a

配置采用 Base + Dev + Prod 三层分离。webpack.base.js 放所有环境共用的配置,webpack.dev.jswebpack.prod.js 各自叠加环境专属的部分,通过 webpack-merge 合并。

这样做的好处是:通用配置只写一份,环境差异一目了然,改一个环境不会影响另一个。


二、入口(Entry):自动扫描,约定优于配置

Webpack 需要知道从哪些文件开始打包,这就是 Entry。传统做法是手动在配置里写死每个入口,每新增一个页面就要改配置。

这里用了另一种方式:用 glob 自动扫描目录。

graph LR
    A["glob 扫描<br/>app/pages/**/entry.*.js"] --> B["提取文件名<br/>entry.page1"]
    B --> C["生成 Entry 对象<br/>{ entry.page1: '文件路径' }"]
    B --> D["生成 HtmlWebpackPlugin<br/>每个入口 → 一个 .tpl 模板"]

    style A fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#e3f2fd,stroke:#1565c0
// webpack.base.js
const pageEntries = {};
const htmlWebpackPluginList = [];

glob
  .sync(path.resolve(process.cwd(), "./app/pages/**/entry.*.js"))
  .forEach((file) => {
    const entryName = path.basename(file, ".js");
    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],
      }),
    );
  });

约定规则:在 app/pages/ 下任意目录,只要文件名符合 entry.{pageName}.js 的格式,就会被自动识别为入口。

每个入口同时会生成一个 .tpl 模板文件。这个模板是给 Koa 服务端用的——用户访问 /view/page1 时,Koa 通过 Nunjucks 渲染 entry.page1.tpl,Webpack 已经把打包后的 JS/CSS 注入到了这个模板里。

// app/controller/view.js — Koa 服务端渲染页面
async renderPage(ctx) {
  await ctx.render(`dist/entry.${ctx.params.page}`, {
    name: app.options?.name,
    env: app.env.get(),
  });
}

所以整个链路是:新建 entry.xxx.js → Webpack 自动识别 → 生成 .tpl → Koa 自动渲染。不需要改任何配置。


三、Loader:告诉 Webpack 怎么处理不同类型的文件

Webpack 本身只认识 JS。要处理 .vue.css.less、图片等文件,需要配置对应的 Loader。

graph LR
    S["源文件"] --> A[".vue"]
    S --> C[".js"]
    S --> E[".css"]
    S --> G[".less"]
    S --> I["图片"]
    S --> K["字体"]

    A -->|vue-loader| B["解析 SFC 单文件组件<br/>template / script / style 拆分"]
    C -->|babel-loader| D["ES6+ 转译为 ES5"]
    E -->|css-loader + style-loader| F["解析 CSS → 注入 DOM"]
    G -->|less-loader + css-loader + style-loader| H["Less 编译 → 解析 → 注入"]
    I -->|url-loader| J["小于 300B 转 Base64<br/>否则输出文件"]
    K -->|file-loader| L["直接输出文件"]

    style S fill:#f5f5f5,stroke:#9e9e9e
    style A fill:#e8f5e9,stroke:#2e7d32
    style C fill:#fff3e0,stroke:#f57c00
    style E fill:#e3f2fd,stroke:#1565c0
    style G fill:#f3e5f5,stroke:#6a1b9a
    style I fill:#fff8e1,stroke:#f9a825
    style K fill:#efebe9,stroke:#4e342e

几个关键点:

vue-loader 的作用不只是处理 .vue 文件。它会把 .vue 中的 <script> 交给 babel-loader 处理,<style> 交给 css-loader 处理。它需要配合 VueLoaderPlugin 使用,这个插件的职责就是把你定义的其他 Loader 规则"复制"到 .vue 文件的各个块中。

babel-loader 通过 include 限定只处理 app/pages 目录:

{
  test: /\.js$/,
  include: [path.resolve(process.cwd(), "./app/pages")],
  use: { loader: "babel-loader" },
}

不加 include 的话,Webpack 会对 node_modules 里的 JS 也跑 Babel 转译,几千个包全过一遍,构建会非常慢。node_modules 里的包通常已经是编译好的,不需要再转。

url-loader 设置了 300 字节的阈值。小于这个值的图片会被转成 Base64 内联到 JS 中,减少一次 HTTP 请求;大于的则输出为独立文件。


四、Resolve:模块解析规则

当代码里写 import xxx from '$common/curl' 时,Webpack 需要知道 $common 指向哪个目录。这就是 resolve.alias 的作用。

resolve: {
  extensions: [".js", ".vue", ".less", ".css"],
  alias: {
    $pages:   path.resolve(process.cwd(), "./app/pages"),
    $common:  path.resolve(process.cwd(), "./app/pages/common"),
    $weights: path.resolve(process.cwd(), "./app/pages/weights"),
    $store:   path.resolve(process.cwd(), "./app/pages/store"),
  },
}

extensions 的作用是:import boot from '$pages/boot' 不需要写 .js 后缀,Webpack 会按照数组顺序依次尝试 .js.vue.less.css


五、代码分割(splitChunks):按变更频率拆包

如果不做代码分割,所有代码会打成一个巨大的 JS 文件。任何一行代码改动,用户都要重新下载整个文件,浏览器缓存完全失效。

代码分割的核心思路是:把变更频率不同的代码拆到不同的文件里

graph TD
    A["所有代码"] --> B{"splitChunks 分析"}
    B -->|来自 node_modules| C["vendor.js<br/>第三方库<br/>Vue / ElementPlus / Lodash<br/>版本不升级就不变"]
    B -->|被 ≥ 2 个入口引用| D["common.js<br/>公共业务模块<br/>偶尔变动"]
    B -->|页面独有| E["entry.page1.js<br/>页面业务代码<br/>频繁变动"]
    A --> F["runtime.js<br/>Webpack 模块加载运行时"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#f57c00
    style E fill:#e3f2fd,stroke:#1565c0
    style F fill:#f3e5f5,stroke:#6a1b9a
optimization: {
  splitChunks: {
    chunks: "all",
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name: "vendor",
        priority: 20,
        enforce: true,
        reuseExistingChunk: true,
      },
      common: {
        name: "common",
        minChunks: 2,
        minSize: 1,
        priority: 10,
        reuseExistingChunk: true,
      },
    },
  },
  runtimeChunk: true,
}

逐个解释:

  • chunks: "all":对同步和异步引入的模块都做分割
  • vendor:匹配 node_modules 下的所有包,打成一个文件。priority: 20 表示优先级最高,一个模块同时满足 vendor 和 common 条件时,归入 vendor
  • common:被 2 个以上入口引用的模块提取出来。minSize: 1 表示哪怕只有 1 字节也提取
  • reuseExistingChunk: true:如果一个模块已经被提取到某个 chunk 中,不会重复提取
  • runtimeChunk: true:把 Webpack 自身的模块加载代码(__webpack_require__ 等)单独打包。这段代码每次构建都可能变,独立出来避免污染业务 chunk 的 hash

这样用户第一次访问时加载所有文件,之后只要第三方库没升级,vendor.js 就一直走浏览器缓存。日常开发改的业务代码只影响 entry.xxx.js,用户只需重新下载这一个小文件。


六、插件(Plugins):在构建流程中注入额外能力

Loader 处理单个文件,Plugin 则作用于整个构建流程。

plugins: [
  // 1. 必须:让 vue-loader 工作
  new VueLoaderPlugin(),

  // 2. 全局注入:业务代码中不需要 import 就能用 Vue、axios、lodash
  new webpack.ProvidePlugin({
    Vue: "vue",
    axios: "axios",
    _: "lodash",
  }),

  // 3. 定义编译时常量:Vue 3 的特性标志
  new webpack.DefinePlugin({
    __VUE_OPTIONS_API__: JSON.stringify(true),
    __VUE_PROD_DEVTOOLS__: JSON.stringify(false),
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false),
  }),

  // 4. 每个入口生成对应的 HTML 模板
  ...htmlWebpackPluginList,
];

ProvidePlugin 的原理是:当 Webpack 在代码中遇到 axios 这个自由变量时,自动在文件顶部插入 import axios from 'axios'。所以前端代码里可以直接写 axios.request(...) 而不需要手动 import。

DefinePlugin 是编译时替换,不是运行时。__VUE_OPTIONS_API__: true 表示保留 Options API 支持;设为 false 的话 Vue 会在打包时 Tree Shake 掉 Options API 相关代码,减小体积。__VUE_PROD_DEVTOOLS__: false 关闭生产环境的 Vue DevTools 支持。


七、HMR 热模块替换:修改代码不刷新页面

HMR(Hot Module Replacement)解决的问题是:开发时每次改代码都要手动刷新页面,页面状态(表单输入、滚动位置、组件状态)全部丢失。HMR 让修改后的模块在不刷新页面的情况下直接替换,保留应用状态。

7.1 HMR 需要什么

HMR 需要三个东西配合:

  1. 一个能监控文件变更并重新编译的服务(webpack-dev-middleware
  2. 一个能把"有更新"这个消息推送给浏览器的通道(webpack-hot-middleware,基于 SSE)
  3. 一个运行在浏览器里的客户端,接收消息后拉取新模块并替换(HMR Client)
sequenceDiagram
    participant 编辑器
    participant DevMiddleware as webpack-dev-middleware<br/>编译 + 监控
    participant HotMiddleware as webpack-hot-middleware<br/>SSE 推送
    participant 浏览器 as 浏览器 HMR Client

    编辑器->>DevMiddleware: 保存文件,触发文件变更
    DevMiddleware->>DevMiddleware: 检测到变更,增量重编译
    DevMiddleware->>HotMiddleware: 编译完成,通知有更新
    HotMiddleware->>浏览器: 通过 SSE 推送更新通知
    浏览器->>DevMiddleware: 根据通知请求更新的模块(hot-update.js)
    DevMiddleware-->>浏览器: 返回新模块代码
    浏览器->>浏览器: 用新模块替换旧模块,页面不刷新

7.2 SSE 是什么

SSE(Server-Sent Events)是一种服务器向浏览器单向推送消息的技术。和 WebSocket 不同,SSE 是单向的(只能服务器推给浏览器),基于 HTTP,实现更简单。

webpack-hot-middleware/__webpack_hmr 路径上开了一个 SSE 端点。浏览器端的 HMR Client 连上这个端点后,服务器每次编译完成都会推送一条消息,告诉浏览器"有新的模块可以更新了"。

7.3 具体实现

第一步:入口注入 HMR Client

// webpack.dev.js
Object.keys(baseConfig.entry).forEach((v) => {
  if (v !== "vendor") {
    baseConfig.entry[v] = [
      baseConfig.entry[v],
      `webpack-hot-middleware/client?path=http://127.0.0.1:9002/__webpack_hmr&timeout=20000&reload=true`,
    ];
  }
});

把 HMR Client 脚本追加到每个业务入口中。这样打包后的 JS 里就包含了 HMR Client 代码,它会在浏览器中运行,负责和服务器建立 SSE 连接。

vendor 被排除了——第三方库不需要热更新,排除它减少 HMR 的处理范围。

参数说明:

  • path:SSE 端点地址
  • timeout=20000:20 秒没收到消息就重连
  • reload=true:如果 HMR 失败(某些模块不支持热替换),降级为整页刷新

第二步:启用 HotModuleReplacementPlugin

// webpack.dev.js
plugins: [
  new webpack.HotModuleReplacementPlugin({
    multiStep: false,
  }),
];

这个插件让 Webpack 在编译时生成 HMR 需要的额外代码(模块更新清单、更新后的模块代码)。multiStep: false 表示不分步编译,每次变更一次性编译完成。

第三步:启动 DevServer

// dev.js
const app = express();
const compiler = webpack(webpackConfig);

// 编译中间件:监控文件变更,增量编译,产物存在内存中
app.use(
  devMiddleware(compiler, {
    writeToDisk: (filePath) => filePath.endsWith(".tpl"),
    publicPath: webpackConfig.output.publicPath,
    headers: { "Access-Control-Allow-Origin": "*" },
  }),
);

// 热更新中间件:SSE 推送
app.use(
  hotMiddleware(compiler, {
    path: "/__webpack_hmr",
  }),
);

app.listen(9002);

这里单独用 Express 起了一个 DevServer(端口 9002),和 Koa 业务服务器(端口 8080)分开。

为什么要分开?因为职责不同:

  • Koa 负责页面路由和 API
  • Express DevServer 负责 Webpack 编译产物的分发和 HMR 推送

devMiddleware 把编译产物存在内存中,不写磁盘,读写速度更快。但 .tpl 模板文件例外——writeToDisk: (filePath) => filePath.endsWith(".tpl") 让模板文件落盘,因为 Koa 的 Nunjucks 引擎需要从文件系统读取模板。

headers 里设置了 CORS,因为页面从 Koa(:8080)加载,JS/CSS 资源从 DevServer(:9002)加载,属于跨域请求。

7.4 双服务器协作

graph LR
    A["浏览器"] -->|"页面 + API<br/>localhost:8080"| B["Koa 服务器 :8080"]
    A -->|"JS / CSS 资源<br/>127.0.0.1:9002"| C["Express DevServer :9002"]
    A <-->|"SSE 热更新<br/>/__webpack_hmr"| C

    B -->|读取| D[".tpl 模板文件"]
    C -->|落盘| D
    C -->|内存中| E["JS / CSS 产物"]

    style B fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e3f2fd,stroke:#1565c0

开发时的 publicPath 设置为 DevServer 的完整地址:

publicPath: `http://127.0.0.1:9002/public/dist/dev/`;

这样 .tpl 模板中注入的 <script> 标签的 src 会指向 DevServer,浏览器从 DevServer 拉取 JS/CSS。

7.5 CSS 的热更新

CSS 的热更新不需要额外配置。开发环境用的 style-loader 天然支持 HMR——它把 CSS 通过 <style> 标签注入到 DOM 中,更新时直接替换 <style> 标签的内容,不需要刷新页面。

这也是为什么开发环境用 style-loader,而不是 MiniCssExtractPlugin——后者把 CSS 抽成独立文件,无法做到热替换。


八、Source Map:开发时的调试支持

// webpack.dev.js
devtool: "eval-cheap-module-source-map",

Webpack 打包后的代码和源码差别很大,报错时看到的行号对不上。Source Map 建立了打包产物和源码之间的映射关系,让浏览器 DevTools 能显示原始源码。

eval-cheap-module-source-map 是一个折中选择:

  • eval:每个模块用 eval() 包裹,重编译速度快
  • cheap:只映射到行,不映射到列,生成速度更快
  • module:能映射到 Loader 处理前的源码(比如 .vue 文件的原始代码)

生产环境不配置 Source Map,避免暴露源码。


九、生产环境:多线程编译与压缩

9.1 MultiThreadPlugin:多线程打包

JS 的编译(Babel 转译)和 CSS 的处理是 CPU 密集型任务。默认情况下 Webpack 是单线程的,只用一个 CPU 核心。多线程方案把这些任务分发到多个 Worker 进程并行处理。

项目中把多线程方案封装成了一个 Webpack 插件,支持三种模式切换:

graph TD
    A["MultiThreadPlugin"] --> B{"mode 参数"}
    B -->|'thread-loader'| C["thread-loader<br/>Webpack 官方维护<br/>在 Loader 前插入"]
    B -->|'happypack'| D["HappyPack<br/>社区方案<br/>替换 Loader 为 happypack/loader"]
    B -->|'none'| E["不启用多线程<br/>用于排查问题"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#f57c00
    style E fill:#efebe9,stroke:#4e342e

使用方式:

// webpack.prod.js
new MultiThreadPlugin({ mode: "thread-loader" });

插件内部通过 Webpack 的 apply(compiler) 钩子,在编译开始前动态往 compiler.options.module.rules 里注入对应的 Loader 配置:

// thread-loader 模式下注入的规则
{
  test: /\.js$/,
  include: [path.resolve(process.cwd(), "./app/pages")],
  use: [
    {
      loader: "thread-loader",
      options: {
        workers: os.cpus().length - 1,  // 留一个核给主线程
        workerParallelJobs: 50,
        poolTimeout: 2000,  // 构建完成 2 秒后回收 Worker
      },
    },
    {
      loader: "babel-loader",
      options: {
        presets: ["@babel/preset-env"],
        plugins: ["@babel/plugin-transform-runtime"],
        cacheDirectory: true,  // 缓存转译结果
      },
    },
  ],
}

thread-loader 的原理:它放在其他 Loader 前面,把后面的 Loader 放到 Worker 池中运行。每个 Worker 是一个独立的 Node.js 进程,通过进程间通信传递数据。

workers: os.cpus().length - 1:Worker 数量设为 CPU 核数减 1,留一个核给 Webpack 主线程做模块依赖分析。

poolTimeout: 2000:生产构建完成后 2 秒回收 Worker 进程,释放系统资源。

9.2 JS 压缩

optimization: {
  minimize: true,
  minimizer: [
    new TerserPlugin({
      cache: true,
      parallel: true,
      terserOptions: {
        compress: {
          drop_console: true,
        },
      },
    }),
  ],
}
  • cache: true:缓存压缩结果,没有变更的模块不重复压缩
  • parallel: true:多进程并行压缩
  • drop_console: true:删除所有 console.log,减小体积,避免生产环境泄露调试信息

9.3 CSS 抽离与压缩

生产环境的 CSS 处理和开发环境完全不同:

graph LR
    subgraph 开发环境
        A1[".css"] --> B1["css-loader"] --> C1["style-loader<br/>注入 DOM 的 style 标签<br/>支持 HMR"]
    end

    subgraph 生产环境
        A2[".css"] --> B2["css-loader"] --> C2["MiniCssExtractPlugin.loader<br/>抽离为独立 .css 文件"]
        C2 --> D2["CSSMinimizerPlugin<br/>压缩"]
    end

    style C1 fill:#e3f2fd,stroke:#1565c0
    style C2 fill:#fce4ec,stroke:#c62828
    style D2 fill:#fce4ec,stroke:#c62828

为什么生产环境要抽离 CSS?

  • 独立的 CSS 文件可以被浏览器并行加载,不阻塞 JS 执行
  • CSS 文件使用 contenthash,内容不变 hash 不变,缓存更精准
  • 可以单独压缩优化
new MiniCssExtractPlugin({
  chunkFilename: "css/[name]_[contenthash:8].css",
}),
new CSSMinimizerPlugin(),

9.4 构建前清理

new CleanWebpackPlugin(["public/dist"], {
  root: path.resolve(process.cwd(), "./app/"),
});

每次生产构建前清空 dist 目录,避免旧文件残留。因为文件名带 hash,不清理的话旧文件会一直堆积。


十、Hash 策略:让浏览器缓存生效

文件名中的 hash 是缓存的关键。Webpack 提供三种 hash:

graph LR
    A["hash<br/>整个构建共享<br/>任何文件变 → 全部变"] ~~~ B["chunkhash<br/>按 chunk 计算<br/>chunk 内容变才变"]
    B ~~~ C["contenthash<br/>按文件内容计算<br/>文件内容变才变"]

    style A fill:#ffcdd2,stroke:#c62828
    style B fill:#fff9c4,stroke:#f9a825
    style C fill:#e8f5e9,stroke:#2e7d32

项目中的使用:

资源 Hash 类型 示例 为什么
JS chunkhash page1_a1b2c3d4.bundle.js 同一 chunk 内容不变则 hash 不变
CSS contenthash common_e5f6g7h8.css CSS 和 JS 独立计算,改 JS 不影响 CSS 的 hash

如果 CSS 也用 chunkhash,那改了 JS 代码,CSS 的 hash 也会变(因为它们在同一个 chunk 里),导致 CSS 缓存失效。用 contenthash 就不会有这个问题。


十一、前端应用启动器:boot.js

每个页面的入口文件只需要两行:

// app/pages/page1/entry.page1.js
import page1 from "./page1.vue";
import boot from "$pages/boot";
boot(page1, {});

boot.js 统一处理 Vue 应用的初始化:

// app/pages/boot.js
export default async (pageComponent, { routes = [], libs }) => {
  const app = createApp(pageComponent);
  app.use(ElementUI);
  app.use(pinia);

  if (libs?.length) {
    for (let i = 0; i < libs.length; ++i) {
      app.use(libs[i]);
    }
  }

  const router = createRouter({
    history: createWebHashHistory(),
    routes,
  });
  app.use(router);
  await router.isReady();

  app.mount("#root");
};

Vue、ElementPlus、Pinia、Router 的初始化全部收口在这里。每个页面只需要关心"用哪个组件"和"传什么路由",不需要重复写初始化代码。


十二、前后端签名通信

前端封装了统一的请求方法 curl.js,和后端的签名校验中间件配合:

sequenceDiagram
    participant 前端 as curl.js
    participant 后端 as Koa 中间件

    前端->>前端: 取当前时间戳 st
    前端->>前端: md5(signKey + "_" + st) 生成签名
    前端->>后端: headers 携带 s_t 和 s_sign
    后端->>后端: apiSignVerify:用同样的算法算签名,比对
    后端->>后端: 检查时间戳是否在 10 分钟内
    后端->>后端: apiParamsVerify:JSON Schema 校验参数
    后端->>后端: Controller → Service 处理业务
    后端-->>前端: { success, data, metadata }

前端和后端使用相同的密钥和算法生成签名。后端额外检查时间戳,超过 10 分钟的请求会被拒绝,防止请求被截获后重放。


十三、完整数据流

从写代码到用户看到页面,完整链路:

graph TD
    A["开发者编写<br/>app/pages/page1/page1.vue"] --> B["Webpack 编译<br/>vue-loader → babel-loader → 打包"]
    B --> C["产物<br/>entry.page1.tpl + JS + CSS"]
    D["用户访问 /view/page1"] --> E["Koa Router 匹配"]
    E --> F["ViewController 渲染模板"]
    F --> G["Nunjucks 输出 HTML<br/>(已注入 JS/CSS 引用)"]
    G --> H["浏览器加载 JS"]
    H --> I["boot.js 初始化 Vue 应用"]
    I --> J["页面渲染完成"]

    style A fill:#e3f2fd,stroke:#1565c0
    style D fill:#e8f5e9,stroke:#2e7d32
    style J fill:#e8f5e9,stroke:#2e7d32

十四、开发环境与生产环境配置对比

维度 开发环境 生产环境
mode development production
Source Map eval-cheap-module-source-map 不生成
CSS 处理 style-loader 注入 DOM MiniCssExtract 抽离文件 + 压缩
JS 压缩 不压缩 TerserPlugin 压缩 + 去 console
多线程 不启用 MultiThreadPlugin
HMR 开启 不需要
产物存储 内存(DevServer) 磁盘
清理旧产物 不需要 CleanWebpackPlugin
publicPath DevServer 完整 URL 相对路径 /dist/prod

Elpis-Core 技术解析:从零构建一个基于 Koa 的企业级 Node.js 框架内核

作者 NickJiangDev
2026年4月12日 13:36

本文会拆解 elpis-core 的设计哲学、架构实现与工程实践。如果你正在思考"如何设计一个 Node.js 框架",这篇文章会给你一个完整的答案。


一、为什么要做这个事情

作为程序员的我们如果只是单纯守着自己所谓的技术壁垒,去熟练的使用一门工具,或者一直做着重复性的增删改查工作,会导致我们的竞争力逐步减小,想要成为AI浪潮下的复合型人才,就需要逐步提升自身的竞争力,不再成为使用工具的奴隶,而是逐步扩充自身的全栈开发能力。本人作为前端开发从学习路径上会先选择拥抱Node.js 生态,但核心还是要构建自己的知识体系,Egg, Koa, Express 切记都是只是工具。本文围绕着如何设计一个可运行的服务端框架开展,也是我们掌握全栈开发能力的万里长征第一步。

在 Node.js 生态中,Express 和 Koa 提供了极简的 HTTP 抽象,但"极简"也意味着每个团队都要自己回答一系列问题:

  • 项目目录怎么组织?
  • 路由、控制器、服务层怎么拆分?
  • 中间件加载顺序谁来保证?
  • 多环境配置怎么管理?

Elpis-Core 的定位很明确:在 Koa 之上,用最小的代码量实现一套"约定优于配置"的自动化加载框架,让开发者只需要关注业务代码本身。


二、整体架构一览

graph TD
    A["index.js 启动入口<br/>ElpisCore.start(options)"] --> B["elpis-core/index.js 框架内核<br/>new Koa() → 设置路径/环境 → 执行 Loader<br/>→ 注册中间件 → 挂载路由 → app.listen()"]
    B --> C["Loaders"]
    B --> D["全局中间件<br/>middleware.js"]
    B --> E["koa-router<br/>路由注册"]

    style A fill:#e1f5fe,stroke:#0288d1
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e8f5e9,stroke:#388e3c
    style D fill:#e8f5e9,stroke:#388e3c
    style E fill:#e8f5e9,stroke:#388e3c

整个框架的核心就是 Loader 机制—— 扫描约定目录下的文件,按照统一规则解析文件名、转换命名风格,然后挂载到 app 实例上。业务代码只需要放在正确的目录里,框架会自动完成发现和注册。


三、启动流程

项目的入口:

// index.js
const ElpisCore = require("./elpis-core");
ElpisCore.start({ name: "Eplis", homePage: "/" });

start() 内部的执行序列如下:

// elpis-core/index.js(简化)
start(options = {}) {
  const app = new Koa();
  app.options = options;
  app.baseDir = process.cwd();
  app.businessPath = path.resolve(app.baseDir, `./app`);
  app.env = env();

  // 严格按序加载
  configLoader(app);       // 1. 配置
  serviceLoader(app);      // 2. 服务层
  middlewareLoader(app);   // 3. 中间件
  controllerLoader(app);   // 4. 控制器
  routerSchemaLoader(app); // 5. 路由校验规则
  extendLoader(app);       // 6. 扩展

  // 7. 注册全局中间件(业务层编排)
  require(`${app.businessPath}/middleware.js`)(app);

  // 8. 路由(最后加载,依赖前面所有组件)
  routerLoader(app);

  app.listen(port, host);
}

加载顺序的设计考量

这个顺序不是随意的,每一步都有依赖关系:

graph LR
    A["① Config<br/>最基础"] --> B["② Service<br/>业务逻辑层"]
    B --> C["③ Middleware<br/>中间件函数"]
    C --> D["④ Controller<br/>依赖 Service"]
    D --> E["⑤ RouterSchema<br/>校验规则"]
    E --> F["⑥ Extend<br/>扩展工具"]
    F --> G["⑦ 全局中间件<br/>编排执行顺序"]
    G --> H["⑧ Router<br/>依赖所有前置组件"]

    style A fill:#ffecb3,stroke:#ff8f00
    style B fill:#c8e6c9,stroke:#2e7d32
    style C fill:#bbdefb,stroke:#1565c0
    style D fill:#c8e6c9,stroke:#2e7d32
    style E fill:#d1c4e9,stroke:#4527a0
    style F fill:#ffe0b2,stroke:#e65100
    style G fill:#bbdefb,stroke:#1565c0
    style H fill:#ffcdd2,stroke:#c62828
顺序 模块 原因
1 Config 最基础,所有模块都可能读取配置
2 Service 业务逻辑层,Controller 会调用它
3 Middleware 中间件函数加载到内存,供后续注册
4 Controller 请求处理器,依赖 Service
5 RouterSchema 路由参数校验规则,供中间件使用
6 Extend 扩展工具,可能被任何模块使用
7 全局中间件 编排中间件执行顺序,依赖已加载的中间件实例
8 Router 最后注册,依赖 Controller + Middleware

四、Loader 机制深度解析

Loader 是 Elpis-Core 的灵魂。7 个 Loader 共享同一套设计模式,但各有细节差异。

4.1 通用模式:扫描 → 解析 → 转换 → 挂载

graph LR
    A["glob 扫描<br/>递归查找 JS 文件"] --> B["路径解析<br/>提取相对路径"]
    B --> C["命名转换<br/>kebab-case → camelCase"]
    C --> D["嵌套挂载<br/>按目录层级构建对象树"]
    D --> E["app.controller.*<br/>app.service.*<br/>app.middlewares.*"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#e8eaf6,stroke:#283593
    style C fill:#f3e5f5,stroke:#6a1b9a
    style D fill:#fff3e0,stroke:#e65100
    style E fill:#e8f5e9,stroke:#2e7d32

以 Controller Loader 为例,核心流程:

// elpis-core/loader/controller.js
module.exports = (app) => {
  // 1. 扫描:用 glob 递归查找所有 JS 文件
  const controllerPath = path.resolve(app.businessPath, `./controller`);
  const fileList = glob.sync(path.resolve(controllerPath, `./**/*.js`));

  const controller = {};
  fileList.forEach((file) => {
    // 2. 解析:提取相对路径
    let name = file.substring(
      file.lastIndexOf(`controller/`) + `controller/`.length,
      file.lastIndexOf("."),
    );

    // 3. 转换:kebab-case → camelCase
    name = name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());

    // 4. 挂载:按目录层级构建嵌套对象
    let temp = controller;
    const names = name.split(sep);
    for (let i = 0; i < names.length; i++) {
      if (i === names.length - 1) {
        const Module = require(file)(app);
        temp[names[i]] = new Module(); // 实例化
      } else {
        temp[names[i]] = temp[names[i]] || {};
        temp = temp[names[i]];
      }
    }
  });

  app.controller = controller;
};

命名转换示例:

graph LR
    A["app/controller/<br/>project.js"] -->|转换| B["app.controller.project"]
    C["app/controller/<br/>custom-module/user.js"] -->|转换| D["app.controller.customModule.user"]
    E["app/controller/<br/>my_api/data-list.js"] -->|转换| F["app.controller.myApi.dataList"]

    style A fill:#fff3e0,stroke:#e65100
    style B fill:#e8f5e9,stroke:#2e7d32
    style C fill:#fff3e0,stroke:#e65100
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#fff3e0,stroke:#e65100
    style F fill:#e8f5e9,stroke:#2e7d32

正则 /[_-][a-z]/gi 同时处理了短横线和下划线两种风格,统一转为驼峰。

4.2 各 Loader 的差异对比

Loader 目录 挂载位置 是否实例化 特殊处理
Config config/ app.config default + env 合并覆盖
Service app/service/ app.service.* 是(new 支持多级目录嵌套
Middleware app/middleware/ app.middlewares.* 否(函数) 返回 Koa 中间件函数
Controller app/controller/ app.controller.* 是(new 支持多级目录嵌套
RouterSchema app/router-schema/ app.routerSchema 扁平合并,key 为 API 路径
Extend app/extend/ app.*(直接挂载) 冲突检测,防止覆盖已有属性
Router app/router/ Koa 中间件栈 兜底路由 302 重定向

关键区别在于:

  • Controller 和 Service 会 new 出实例,因为它们是有状态的类
  • Middleware 返回的是函数,直接作为 Koa 中间件使用
  • Extend 直接挂载到 app 顶层,并做了冲突检测
  • RouterSchema 采用扁平合并,以 API 路径为 key

4.3 跨平台兼容

所有 Loader 都使用 path.sep 处理路径分隔符:

const { sep } = path;
// Windows: sep = '\\'
// macOS/Linux: sep = '/'

这保证了框架在不同操作系统上的行为一致性。


五、环境管理

5.1 三环境模型

// elpis-core/env.js
module.exports = () => ({
  isLocal() {
    return process.env._ENV === "local";
  },
  isBeta() {
    return process.env._ENV === "beta";
  },
  isProduction() {
    return process.env._ENV === "production";
  },
  get() {
    return process.env._ENV ?? "local";
  },
});

通过 process.env._ENV 控制,默认回退到 local。启动脚本中注入:

{
  "dev": "_ENV='local' nodemon ./index.js",
  "beta": "_ENV='beta' nodemon ./index.js",
  "prod": "_ENV='production' nodemon ./index.js"
}

5.2 配置合并策略

// elpis-core/loader/config.js
// 1. 加载 config.default.js 作为基础配置
// 2. 根据当前环境加载 config.{env}.js
// 3. Object.assign 合并,环境配置覆盖默认配置
app.config = Object.assign({}, defaultConfig, envConfig);
graph LR
    A["config.default.js<br/>基础配置"] -->|Object.assign 合并| C["app.config"]
    B["config.{env}.js<br/>环境配置"] -->|覆盖同名字段| C

    subgraph 环境配置文件
        D["config.local.js"]
        E["config.beta.js"]
        F["config.prod.js"]
    end

    D -.->|_ENV=local| B
    E -.->|_ENV=beta| B
    F -.->|_ENV=production| B

    style A fill:#e8f5e9,stroke:#388e3c
    style B fill:#fff3e0,stroke:#f57c00
    style C fill:#e1f5fe,stroke:#0288d1

这是一种简洁有效的配置管理方式:公共配置写一次,差异化配置按环境覆盖。


六、分层架构实战

Elpis 采用经典的 MVC 分层,请求的完整生命周期如下:

graph TD
    A(("HTTP Request")) --> B["全局中间件链<br/>koa-static → nunjucks → bodyparser<br/>→ errorHandler → apiSignVerify → apiParamsVerify"]
    B --> C["koa-router 路由匹配<br/>/api/project/list → ProjectController<br/>/view/:page → ViewController"]
    C --> D["Controller 层<br/>解析参数 → 调用 Service → 组装响应"]
    D --> E["Service 层<br/>封装业务逻辑 / 数据库操作 / 外部 API 调用"]
    E --> F(("HTTP Response"))

    style A fill:#ffcdd2,stroke:#c62828
    style B fill:#e1f5fe,stroke:#0288d1
    style C fill:#fff3e0,stroke:#f57c00
    style D fill:#e8f5e9,stroke:#388e3c
    style E fill:#f3e5f5,stroke:#7b1fa2
    style F fill:#ffcdd2,stroke:#c62828

6.1 Controller:请求的入口

Controller 负责接收请求、调用 Service、返回响应。项目中通过基类统一了响应格式:

// app/controller/base.js
module.exports = (app) =>
  class BaseController {
    constructor() {
      this.app = app;
      this.config = app.config;
    }
    success(ctx, data = {}, metadata = {}) {
      ctx.status = 200;
      ctx.body = { success: true, data, metadata };
    }
    fail(ctx, message, code) {
      ctx.body = { success: false, message, code };
    }
  };

业务 Controller 继承基类:

// app/controller/project.js
module.exports = (app) => {
  const BaseController = require("./base")(app);
  return class ProjectController extends BaseController {
    async getList(ctx) {
      const { proj_key: projKey } = ctx.request.body;
      const projectList = await app.service.project.getList();
      this.success(ctx, projectList);
    }
  };
};

统一的 success/fail 方法确保所有 API 返回结构一致,前端无需猜测响应格式。

6.2 Service:业务逻辑的归宿

Service 层通过基类封装了公共能力(配置访问、HTTP 客户端):

// app/service/base.js
const superagent = require("superagent");
module.exports = (app) => {
  return class BaseService {
    constructor() {
      this.app = app;
      this.config = app.config;
      this.curl = superagent; // HTTP 客户端,用于调用外部 API
    }
  };
};

业务 Service 继承基类,专注于逻辑实现:

// app/service/project.js
module.exports = (app) => {
  const BaseService = require("./base")(app);
  return class ProjectService extends BaseService {
    async getList() {
      // 实际项目中这里会查询数据库或调用外部接口
      return [{ a: "1" }];
    }
  };
};

6.3 路由:连接 URL 与 Controller

// app/router/project.js
module.exports = (app, router) => {
  const { project: ProjectController } = app.controller;
  router.get(
    "/api/project/list",
    ProjectController.getList.bind(ProjectController),
  );
};

注意 .bind(ProjectController) 的使用——因为 koa-router 调用处理函数时会改变 this 指向,bind 确保 Controller 方法内部的 this 始终指向正确的实例。

路由加载器还提供了兜底机制:

// elpis-core/loader/router.js
router.get("*", async (ctx) => {
  ctx.status = 302;
  ctx.redirect(app?.options?.homepage ?? "/");
});

未匹配的路径会被 302 重定向到首页,避免用户看到空白页或错误页。


七、中间件体系

7.1 全局中间件编排

app/middleware.js 是中间件的编排中心,决定了中间件的执行顺序:

// app/middleware.js
module.exports = (app) => {
  // 静态资源服务
  app.use(koaStatic(path.resolve(process.cwd(), "./app/public")));

  // 模板渲染引擎(Nunjucks)
  app.use(koaNunjucks({ ext: "tpl", path: "./app/public" }));

  // 请求体解析
  app.use(
    bodyParser({ formLimit: "1000mb", enableTypes: ["json", "form", "text"] }),
  );

  // 异常兜底(最外层 try-catch)
  app.use(app.middlewares.errorHandler);

  // API 签名校验
  app.use(app.middlewares.apiSignVerify);

  // API 参数校验
  app.use(app.middlewares.apiParamsVerify);
};

Koa 的中间件模型是洋葱模型,请求从外到内穿透,响应从内到外返回:

graph LR
    subgraph 洋葱模型 - 请求方向 →
        A["koa-static"] --> B["nunjucks"]
        B --> C["bodyparser"]
        C --> D["errorHandler"]
        D --> E["apiSignVerify"]
        E --> F["apiParamsVerify"]
        F --> G["Router + Controller"]
    end

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#e3f2fd,stroke:#1565c0
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#fff8e1,stroke:#f9a825
    style E fill:#fce4ec,stroke:#c62828
    style F fill:#fce4ec,stroke:#c62828
    style G fill:#e8f5e9,stroke:#2e7d32

这里的顺序意味着:

  1. 请求先经过静态资源检查
  2. 然后是模板引擎注入
  3. body 解析
  4. 错误处理包裹后续所有逻辑
  5. 签名校验
  6. 参数校验
  7. 最后到达路由和 Controller

7.2 错误处理中间件

// app/middleware/error-handler.js
module.exports = (app) => {
  return async (ctx, next) => {
    try {
      await next();
    } catch (error) {
      app.logger.error("[-- exception --]: ", error);

      // 模板未找到 → 重定向首页
      if (message?.indexOf("template not found") > -1) {
        ctx.status = 302;
        ctx.redirect(app.options?.homePage);
        return;
      }

      // 其他异常 → 统一错误响应
      ctx.status = 200;
      ctx.body = {
        success: false,
        code: 50000,
        message: "网络异常,请稍候重试",
      };
    }
  };
};

这个中间件放在业务逻辑之前,利用 Koa 的洋葱模型,try-catch 可以捕获后续所有中间件和路由处理中抛出的异常。

7.3 API 签名校验

// app/middleware/api-sign-verify.js
module.exports = (app) => {
  return async (ctx, next) => {
    if (ctx.path.indexOf("/api") < 0) return await next(); // 非 API 请求跳过

    const { s_sign, s_t } = ctx.request.headers;
    const signature = md5(`${signKey}_${s_t}`);

    // 校验签名 + 时间窗口(10 分钟)
    if (
      !s_sign ||
      !s_t ||
      signature !== s_sign.toLowerCase() ||
      Date.now() - s_t > 600000
    ) {
      ctx.body = {
        success: false,
        message: "signature not correct or api timeout!",
        code: 445,
      };
      return;
    }
    await next();
  };
};

签名算法:md5(signKey + "_" + timestamp),同时校验时间戳防止重放攻击(10 分钟窗口)。

7.4 参数校验中间件(JSON Schema + AJV)

// app/middleware/api-params-verify.js
module.exports = (app) => {
  return async (ctx, next) => {
    if (ctx.path.indexOf("/api") < 0) return await next();

    const schema = app.routerSchema[ctx.path]?.[ctx.method.toLowerCase()];
    if (!schema) return await next();

    // 依次校验 headers → body → query → params
    // 使用 AJV 编译 JSON Schema 并验证
    // 校验失败返回 442 错误码
  };
};

配合 app/router-schema/ 下的 Schema 定义:

// app/router-schema/project.js
module.exports = {
  "/api/project/list": {
    get: {
      query: {
        type: "object",
        properties: {
          proj_key: { type: "string" },
        },
        required: ["proj_key"],
      },
    },
  },
};

这种声明式的参数校验方式,让接口约束一目了然,且与业务逻辑完全解耦。


八、扩展机制

8.1 Extend Loader

app/extend/ 目录下的模块会被直接挂载到 app 实例上:

// elpis-core/loader/extend.js
// 冲突检测:如果 app 上已存在同名属性,跳过并警告
for (const key in app) {
  if (key === name) {
    console.log(`[extend load error] name: ${name} is already in app`);
    return;
  }
}
app[name] = require(file)(app);

8.2 实际案例:日志扩展

// app/extend/logger.js
module.exports = (app) => {
  if (app.env.isLocal()) {
    return console; // 本地开发直接用 console
  }

  // 非本地环境:log4js 日志落盘
  log4js.configure({
    appenders: {
      console: { type: "console" },
      dateFile: {
        type: "dateFile",
        filename: "./logs/application.log",
        pattern: ".yyyy-MM-dd", // 按天切分
      },
    },
    categories: {
      default: { appenders: ["console", "dateFile"], level: "trace" },
    },
  });

  return log4js.getLogger();
};

加载后通过 app.logger 访问。本地环境用 console 减少噪音,线上环境用 log4js 实现日志落盘和按天切分。


九、项目目录全景

elpis/
├── index.js                        # 启动入口(2 行代码)
├── package.json
│
├── elpis-core/                     # 框架内核
│   ├── index.js                    # 核心启动逻辑
│   ├── env.js                      # 环境管理
│   └── loader/                     # 7 个自动加载器
│       ├── config.js
│       ├── service.js
│       ├── middleware.js
│       ├── controller.js
│       ├── router-schema.js
│       ├── extend.js
│       └── router.js
│
├── config/                         # 多环境配置
│   ├── config.default.js
│   ├── config.local.js
│   ├── config.beta.js
│   └── config.prod.js
│
├── app/                            # 业务代码
│   ├── middleware.js                # 全局中间件编排
│   ├── controller/                 # 控制器层
│   │   ├── base.js                 # 控制器基类
│   │   ├── project.js
│   │   └── view.js
│   ├── service/                    # 服务层
│   │   ├── base.js                 # 服务基类
│   │   └── project.js
│   ├── middleware/                  # 中间件
│   │   ├── error-handler.js
│   │   ├── api-sign-verify.js
│   │   └── api-params-verify.js
│   ├── router/                     # 路由定义
│   │   ├── project.js
│   │   └── view.js
│   ├── router-schema/              # 路由参数校验规则
│   │   └── project.js
│   ├── extend/                     # 扩展
│   │   └── logger.js
│   └── public/                     # 静态资源
│       ├── static/
│       └── output/
│
└── logs/                           # 日志输出
    └── application.log

十、设计亮点与工程思考

10.1 约定优于配置的实践

整个框架没有一个 YAML/JSON 配置文件来声明"哪些文件是 Controller"。你只需要把文件放到 app/controller/ 目录下,框架就知道它是 Controller。这种约定带来的好处是:

  • 新成员看到目录结构就能理解项目组织
  • 不需要维护额外的注册/配置文件
  • 减少了"配置漂移"的风险

10.2 工厂模式 + 依赖注入

所有业务模块都导出一个接收 app 的工厂函数:

module.exports = (app) => {
  return class SomeController {
    // 通过闭包访问 app
  };
};

这种模式的优势:

  • 每个模块都能访问完整的应用上下文
  • 不需要全局变量或 import 循环
  • 测试时可以轻松 mock app 对象

10.3 容错设计

框架在关键位置都加了 try-catch

// 配置加载失败不阻塞启动
try {
  defaultConfig = require(path.resolve(configPath, `./config.default.js`));
} catch (error) {
  console.error("[exception] there is no config.default file");
}

// 全局中间件文件不存在也不崩溃
try {
  require(`${app.businessPath}/middleware.js`)(app);
} catch (error) {
  console.log("[exception] there is no global middleware file.");
}

这保证了框架的健壮性——即使某个非关键模块加载失败,应用仍然可以启动。

10.4 统一的命名转换

一个正则搞定文件名到代码标识符的映射:

name.replace(/[_-][a-z]/gi, (s) => s.substring(1).toUpperCase());

api-params-verify.jsapiParamsVerifymy_service.jsmyService。文件系统用 kebab-case 保持可读性,代码中用 camelCase 符合 JavaScript 惯例。


十一、总结

Elpis-Core 实现了一个功能完备的框架内核:

  • 7 个 Loader 覆盖了 Web 应用的所有核心组件
  • 严格的加载顺序保证了依赖关系的正确性
  • 约定式目录结构让项目组织零成本
  • 工厂模式 + 闭包实现了优雅的依赖注入
  • 多环境配置、参数校验、签名验证、错误兜底一应俱全

理解了 Elpis-Core,你就理解了大多数 Node.js 框架的底层思路。

本文基于 Elips-Core 框架源码分析撰写,适用于了解 Node.js Web 框架设计原理的开发者。

❌
❌