elpis-core 抽离 npm 包过程的难点和卡点

1 阅读10分钟

1. 背景:从“一个能运行的项目”变成“可被安装的框架”

当前 完成的工作,不只是给仓库补一个 package.json 并执行 npm publish。真正的变化是:elpis-core 从一个同时包含框架代码、页面代码和业务示例的单体项目,转变为由业务工程通过 @flicoh/elpis 引用的全栈框架包。

改造后的业务工程只需要保留自己的模型、配置和扩展代码,通过包入口启动服务端或触发前端构建:

const {
  serverStart,
  frontendBuild,
  Controller,
  Service,
} = require('@flicoh/elpis')

frontendBuild(process.env.__ENV)

const app = serverStart({
  name: 'product-admin',
  homePage: '/view/dashboard/schema',
})

class ProductController extends Controller.Base {}
class ProductService extends Service.Base {}

这一步会改变所有“相对路径理所当然成立”的前提。源码仓库运行时,框架目录与业务目录是同一个根;安装为 npm 包后,框架位于 node_modules/@flicoh/elpis,业务代码位于消费项目根目录。路径解析、模块查找、构建输出和扩展覆盖都必须重新定义。

2. 当前完成了什么

交依次解决了四件事:

  1. 将根入口改造成 SDK API,导出 serverStartfrontendBuildController.BaseService.Base
  2. 服务端 Loader 同时加载框架内置模块与业务工程模块。
  3. Webpack 同时编译框架页面与业务自定义页面,并把产物写回消费项目。
  4. 增加路由、动态组件、表单项和搜索项的业务扩展钩子,并处理 npm 安装后的构建报错。

整体关系如下:

flowchart TB
  A[业务工程] --> B[require @flicoh/elpis]
  B --> C[index.js 公共入口]
  C --> D[serverStart]
  C --> E[frontendBuild]
  C --> F[Controller / Service 基类]

  D --> G[elpis-core 服务端内核]
  G --> H[框架内置 config/router/controller/service]
  G --> I[业务 config/router/controller/service]

  E --> J[包内 Webpack 配置]
  J --> K[框架页面入口]
  J --> L[业务页面入口]
  J --> M[业务扩展注册表]
  J --> N[业务 app/public/dist]

这张图里最重要的是两套坐标系:包内资源以当前文件所在位置为基准,业务资源和构建产物以消费项目为基准。抽包过程中多数卡点都来自这两套坐标系混用。

3. 难点一:__dirnameapp.baseDirprocess.cwd() 的边界

3.1 为什么原来的路径在 npm 场景下失效

在源码仓库里运行时,下面几种写法经常指向相同或相近的位置:

path.resolve(__dirname, '../app')
path.resolve(app.baseDir, './app')
path.resolve(process.cwd(), './app')

但包被安装后,它们的语义完全不同:

基准指向适合读取
__dirnamenode_modules/@flicoh/elpis 内的当前模块目录框架内置页面、模板、配置和 Loader
app.baseDir框架启动代码设置的基础目录需要结合框架生命周期判断
process.cwd()启动命令所在的消费项目根目录业务 appmodelconfig、Webpack 配置和构建产物

原实现把业务模型解析为 path.resolve(app.baseDir, './model')。抽包后,模型必须来自消费项目,因此当前分支改为:

const modelPath = path.resolve(process.cwd(), './model')

服务端配置也被拆成两层:框架默认配置从包内读取,业务默认配置和环境配置从当前工作目录读取。

const elpisConfigPath = path.resolve(__dirname, '../../config')
let defaultConfig = require(
  path.resolve(elpisConfigPath, './config.default.js')
)

const businessConfigPath = path.resolve(process.cwd(), './config')
defaultConfig = {
  ...defaultConfig,
  ...require(path.resolve(businessConfigPath, './config.default.js')),
}

3.2 这类问题为什么难排查

路径错误往往不是立即抛错。glob.sync 找不到文件时只返回空数组;可选配置又常被 try/catch 忽略。最终表现可能是“服务启动成功但业务路由不存在”或“页面能打开但扩展组件没有注册”,错误点与症状相距很远。

适合抽包后的判断规则是:

  • 框架自带资源使用 __dirname 派生路径。
  • 消费项目资源使用一个明确的项目根,当前实现暂用 process.cwd()
  • 构建输出写入消费项目,也使用业务根路径。
  • 不要用静默空数组表达必需目录缺失;关键目录应在启动阶段输出最终解析结果。

4. 难点二:框架默认能力与业务能力如何合并

抽包后,框架不能只加载业务文件,否则 npm 包内置的项目、视图和中间件会消失;也不能只加载框架文件,否则消费方无法定制。当前分支对 Controller、Service、Middleware、Router、Router Schema 和 Extend 都采用“双目录扫描”。

以 Controller 为例,核心结构是:

const controller = {}

const elpisControllerDir = path.resolve(__dirname, '../../app/controller')
glob.sync(path.resolve(elpisControllerDir, '**/*.js'))
  .forEach(handleFile)

const businessControllerDir = path.resolve(app.businessPath, './controller')
glob.sync(path.resolve(businessControllerDir, '**/*.js'))
  .forEach(handleFile)

app.controller = controller

难点不在扫描本身,而在合并语义:

  • 同名模块是业务覆盖框架,还是启动时报冲突?
  • Router 的注册顺序是否会让框架路由提前截获请求?
  • router-schema 使用对象展开,后加载项天然覆盖前项;Controller 和 Service 则通过路径逐级挂载,覆盖行为并不完全相同。
  • 全局中间件有顺序语义,框架中间件先执行还是业务中间件先执行会影响鉴权、错误处理和上下文初始化。

当前实现选择“框架先加载、业务后加载”,给业务保留覆盖机会。这是合理的默认值,但需要把覆盖规则写成稳定契约,而不能只依赖 forEach 的执行顺序。

5. 难点三:Webpack 的模块解析不再只有一个 node_modules

5.1 Loader 与应用依赖的查找方向不同

Webpack 配置在 npm 包内部执行时,Loader 可能安装在框架包自己的依赖树中;业务 Vue 页面和业务扩展又位于消费项目。若仍只写字符串形式的 Loader:

use: ['style-loader', 'css-loader', 'less-loader']

Webpack 会根据上下文寻找模块,安装方式、npm 扁平化结果或包管理器变化都可能导致 Module not found。当前增加了 resolveLoader.modules

resolveLoader: {
  modules: [
    path.resolve(__dirname, '../../../node_modules'),
    path.resolve(process.cwd(), 'node_modules'),
    'node_modules',
  ],
}

并在生产配置中对关键 Loader、Preset 和 Plugin 使用 require.resolve

use: {
  loader: require.resolve('babel-loader'),
  options: {
    babelrc: false,
    configFile: false,
    presets: [[require.resolve('@babel/preset-env'), {
      modules: 'commonjs',
    }]],
    plugins: [[require.resolve('@babel/plugin-transform-runtime'), {
      regenerator: false,
    }]],
  },
}

这里的原则是:框架负责执行的构建工具,由框架锁定和解析;业务运行时库是否复用,则需要单独设计依赖策略。

5.2 Babel 配置污染与 ESM/CJS 转换

安装为 npm 包后,不能假设消费项目存在兼容的 Babel 配置。消费方的 .babelrc 可能改变模块格式、缺少 Preset,或者让框架源码跳过转译。当前显式设置:

babelrc: false,
configFile: false,

这样构建结果只受框架内置配置控制。生产构建又将模块转换为 CommonJS,用于处理此前出现的引入后打包错误。

需要注意的是,@babel/plugin-transform-runtime 会引入 @babel/runtime。它必须作为生产依赖随包安装,而不能只存在于开发环境。当前 package.json 已将构建链依赖放进 dependencies,解决了消费方安装包后缺少 Loader 或 Runtime 的问题,但也带来了包依赖较重的问题。

5.3 Vue 与 Element Plus 的单实例和 exports 限制

Vue 应用如果同时加载框架侧 Vue 与业务侧 Vue,可能出现响应式上下文或插件实例不一致。当前配置通过别名固定 Vue:

alias: {
  vue: require.resolve('vue'),
}

Element Plus 的语言包还受到 package.json#exports 白名单约束。当前分支对两个历史导入路径做精确映射,并从业务项目的 node_modules 读取对应文件:

'element-plus/es/locale/lang/zh-cn$': path.resolve(
  process.cwd(),
  'node_modules/element-plus/es/locale/lang/zh-cn.mjs'
)

这能绕开当前版本的导出限制,但也意味着业务项目必须安装兼容版本的 Element Plus。长期方案应明确哪些库属于 peerDependencies,并给出支持的版本区间,避免框架和业务各装一份 Vue 或 UI 库。

6. 难点四:页面入口、SSR 模板和构建产物跨越包边界

框架自带 Dashboard 页面,业务也可以创建自己的 entry.*.js。当前 Webpack 配置分别扫描两类入口,再合并为一个多页面构建:

const elpisEntryList = path.resolve(
  __dirname,
  '../../pages/**/entry.*.js'
)

const businessEntryList = path.resolve(
  process.cwd(),
  './app/pages/**/entry.*.js'
)

entry: Object.assign({}, elpisPageEntries, businessPageEntries)

HTML 模板由包内的 app/view/entry.tpl 提供,但生成的 SSR 模板和静态资源必须落到业务项目:

new HtmlWebpackPlugin({
  filename: path.resolve(
    process.cwd(),
    './app/public/dist/',
    `${entryName}.tpl`
  ),
  template: path.resolve(__dirname, '../../view/entry.tpl'),
})

生产资源也写入:

output: {
  path: path.resolve(process.cwd(), './app/public/dist/prod/'),
  publicPath: '/dist/prod',
}

这里有两个容易忽略的卡点:

  1. 框架入口和业务入口可能生成相同的 entryNameObject.assign 会由业务入口覆盖框架入口,但 HtmlWebpackPlugin 仍可能存在重复实例。
  2. 构建输出路径依赖启动命令的当前目录。如果通过脚本切换目录、Monorepo 根目录执行或测试工具修改 CWD,产物可能写到错误位置。

更稳妥的做法是让 frontendBuild 接受显式的 rootDiroutputDir,并在构建前校验入口重名。

7. 难点五:既要可扩展,又要保证“没有扩展文件也能构建”

当前分支支持四类前端扩展:Dashboard 路由、SchemaView 动态组件、SchemaForm 表单项和 SchemaSearchBar 搜索项。框架组件通过别名导入业务注册表:

import BusinessSearchItem from '$businessSearchItem'

export default {
  ...SearchItemConfig,
  ...BusinessSearchItem,
}

如果消费项目没有对应文件,Webpack 在编译阶段就会报错。当前实现通过 fs.existsSync 判断并回退到空模块:

const blankModulePath = path.resolve(__dirname, '../libs/blank.js')

const businessSearchItemConfig = path.resolve(
  process.cwd(),
  './app/pages/widgets/schema-search-bar/schema-item-config.js'
)

aliasMap.$businessSearchItem = fs.existsSync(businessSearchItemConfig)
  ? businessSearchItemConfig
  : blankModulePath

blank.js 导出空对象,因此展开合并仍然成立。这是一种轻量的可选插件协议:存在就加载,不存在就提供中性值。

卡点在于不同扩展位需要不同的中性值。对象注册表需要 {},路由钩子更适合空函数。当前 Dashboard 路由已经使用 typeof businessDashBoardRouterConfig === 'function' 保护调用,因此空对象能够工作;随着钩子增多,建议为每类扩展提供明确的默认实现和运行时类型检查。

8. 难点六:生产构建与开发构建的配置合并

基础配置使用 style-loader 注入 CSS,生产环境改用 MiniCssExtractPlugin.loader 抽取 CSS。若直接通过 webpack-merge 合并,生产规则可能与基础规则叠加,形成同一文件被两套 Loader 重复处理的问题。

当前分支在合并生产配置前,先过滤基础配置中的 JS、CSS 和 Less 规则:

const prodBaseConfig = {
  ...baseConfig,
  module: {
    rules: baseConfig.module.rules.filter(rule => {
      const test = rule.test && rule.test.toString()
      return !['/\\.js$/', '/\\.css$/', '/\\.less$/'].includes(test)
    }),
  },
}

然后再加入生产规则。这段代码解决了眼前的重复 Loader 问题,但依赖正则表达式的字符串结果,规则稍有变化就可能失效。更稳定的方式是把规则拆成命名函数,由开发、生产配置显式组合,而不是先合并再按字符串删除。

另一个兼容性卡点来自 HappyPack。其间接依赖在较新的 Node.js 中仍调用已经移除的 util.isRegExp,当前代码必须在加载 HappyPack 之前打补丁:

if (!util.isRegExp) {
  util.isRegExp = obj =>
    Object.prototype.toString.call(obj) === '[object RegExp]'
}

这表明构建链存在老旧依赖。补丁可以止血,后续应移除 HappyPack,使用 Webpack 5 自身缓存和现代并行能力,减少 Node.js 版本升级带来的隐性故障。

9. 包入口设计:避免安装或 require 时产生副作用

抽包前的根 index.js 会直接启动服务。作为 npm 包后,require('@flicoh/elpis') 应只返回 API,不能立刻监听端口或启动构建。当前分支把行为封装为显式函数:

module.exports = {
  Controller: {
    Base: require('./app/controller/base.js'),
  },
  Service: {
    Base: require('./app/service/base.js'),
  },
  frontendBuild(env) {
    if (env === 'local') FEBuildDev()
    if (env === 'production') FEBuildProd()
  },
  serverStart(options = {}) {
    return ElpisCore.start(options)
  },
}

app/webpack/dev.jsprod.js 也从“加载文件即执行”改成导出函数。这个变化很关键:公共包的导入应该是可预测的,真正产生端口监听、文件输出和编译等副作用的动作,应由调用方显式触发。

10. 推荐的发布检查清单

  1. npm pack --dry-run 中不包含日志、测试、业务示例和历史构建产物。
  2. 在空白消费工程安装生成的 tgz,而不是依赖仓库内的 node_modules
  3. require('@flicoh/elpis') 不启动服务、不监听端口、不写文件。
  4. 框架默认路由、Controller、Service 和中间件可以独立工作。
  5. 业务同名配置的覆盖顺序符合文档约定。
  6. 不提供任何前端扩展文件时仍可完成构建。
  7. 提供路由、动态组件、表单项和搜索项扩展后均能被加载。
  8. 开发构建可以热更新,生产构建可以输出模板、JS 和 CSS。
  9. 消费工程中只有一份 Vue 运行时,Element Plus 版本符合支持区间。
  10. 非法环境名、入口重名和业务配置内部错误会明确失败。

11. 总结

elpis-core 的 npm 抽离,本质上是一次运行边界重建。服务端要区分框架资源与业务资源,前端要同时处理两套源码和两套依赖解析,扩展机制要在“默认可运行”和“业务可覆盖”之间建立稳定协议,包入口还必须消除导入时副作用。

当前分支已经打通了核心链路:SDK 入口、双目录 Loader、多页面构建、SSR 模板输出和业务扩展钩子都已形成。接下来最优先的工作不是继续增加功能,而是收紧发布文件、修正业务路由别名、明确依赖分类,并建立基于 tgz 的消费工程集成测试。完成这些工作后,elpis-core 才能从“仓库内能够打包”进入“任意项目安装后可稳定使用”的状态。