1. 背景:从"一个能运行的项目"变成"可被安装的框架"
当前 完成的工作,不只是给仓库补一个 package.json 并执行 npm publish。真正的变化是:elpis-core 从一个同时包含框架代码、页面代码和业务示例的单体项目,转变为由业务工程通过 @flicoh/elpis 引用的全栈框架包。
改造后的业务工程只需要保留自己的模型、配置和扩展代码,通过包入口启动服务端或触发前端构建:
js
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. 当前完成了什么
交依次解决了四件事:
- 将根入口改造成 SDK API,导出
serverStart、frontendBuild、Controller.Base和Service.Base。 - 服务端 Loader 同时加载框架内置模块与业务工程模块。
- Webpack 同时编译框架页面与业务自定义页面,并把产物写回消费项目。
- 增加路由、动态组件、表单项和搜索项的业务扩展钩子,并处理 npm 安装后的构建报错。
整体关系如下:
这张图里最重要的是两套坐标系:包内资源以当前文件所在位置为基准,业务资源和构建产物以消费项目为基准。抽包过程中多数卡点都来自这两套坐标系混用。
3. 难点一:__dirname、app.baseDir 与 process.cwd() 的边界
3.1 为什么原来的路径在 npm 场景下失效
在源码仓库里运行时,下面几种写法经常指向相同或相近的位置:
js
path.resolve(__dirname, '../app')
path.resolve(app.baseDir, './app')
path.resolve(process.cwd(), './app')
但包被安装后,它们的语义完全不同:
| 基准 | 指向 | 适合读取 |
|---|---|---|
__dirname |
node_modules/@flicoh/elpis 内的当前模块目录 |
框架内置页面、模板、配置和 Loader |
app.baseDir |
框架启动代码设置的基础目录 | 需要结合框架生命周期判断 |
process.cwd() |
启动命令所在的消费项目根目录 | 业务 app、model、config、Webpack 配置和构建产物 |
原实现把业务模型解析为 path.resolve(app.baseDir, './model')。抽包后,模型必须来自消费项目,因此当前分支改为:
js
const modelPath = path.resolve(process.cwd(), './model')
服务端配置也被拆成两层:框架默认配置从包内读取,业务默认配置和环境配置从当前工作目录读取。
js
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 为例,核心结构是:
js
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:
js
use: ['style-loader', 'css-loader', 'less-loader']
Webpack 会根据上下文寻找模块,安装方式、npm 扁平化结果或包管理器变化都可能导致 Module not found。当前增加了 resolveLoader.modules:
js
resolveLoader: {
modules: [
path.resolve(__dirname, '../../../node_modules'),
path.resolve(process.cwd(), 'node_modules'),
'node_modules',
],
}
并在生产配置中对关键 Loader、Preset 和 Plugin 使用 require.resolve:
js
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,或者让框架源码跳过转译。当前显式设置:
js
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:
js
alias: {
vue: require.resolve('vue'),
}
Element Plus 的语言包还受到 package.json#exports 白名单约束。当前分支对两个历史导入路径做精确映射,并从业务项目的 node_modules 读取对应文件:
js
'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 配置分别扫描两类入口,再合并为一个多页面构建:
js
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 模板和静态资源必须落到业务项目:
js
new HtmlWebpackPlugin({
filename: path.resolve(
process.cwd(),
'./app/public/dist/',
`${entryName}.tpl`
),
template: path.resolve(__dirname, '../../view/entry.tpl'),
})
生产资源也写入:
js
output: {
path: path.resolve(process.cwd(), './app/public/dist/prod/'),
publicPath: '/dist/prod',
}
这里有两个容易忽略的卡点:
- 框架入口和业务入口可能生成相同的
entryName,Object.assign会由业务入口覆盖框架入口,但 HtmlWebpackPlugin 仍可能存在重复实例。 - 构建输出路径依赖启动命令的当前目录。如果通过脚本切换目录、Monorepo 根目录执行或测试工具修改 CWD,产物可能写到错误位置。
更稳妥的做法是让 frontendBuild 接受显式的 rootDir、outputDir,并在构建前校验入口重名。
7. 难点五:既要可扩展,又要保证"没有扩展文件也能构建"
当前分支支持四类前端扩展:Dashboard 路由、SchemaView 动态组件、SchemaForm 表单项和 SchemaSearchBar 搜索项。框架组件通过别名导入业务注册表:
js
import BusinessSearchItem from '$businessSearchItem'
export default {
...SearchItemConfig,
...BusinessSearchItem,
}
如果消费项目没有对应文件,Webpack 在编译阶段就会报错。当前实现通过 fs.existsSync 判断并回退到空模块:
js
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 规则:
js
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 之前打补丁:
js
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,不能立刻监听端口或启动构建。当前分支把行为封装为显式函数:
js
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.js 与 prod.js 也从"加载文件即执行"改成导出函数。这个变化很关键:公共包的导入应该是可预测的,真正产生端口监听、文件输出和编译等副作用的动作,应由调用方显式触发。
10. 推荐的发布检查清单
npm pack --dry-run中不包含日志、测试、业务示例和历史构建产物。- 在空白消费工程安装生成的 tgz,而不是依赖仓库内的
node_modules。 require('@flicoh/elpis')不启动服务、不监听端口、不写文件。- 框架默认路由、Controller、Service 和中间件可以独立工作。
- 业务同名配置的覆盖顺序符合文档约定。
- 不提供任何前端扩展文件时仍可完成构建。
- 提供路由、动态组件、表单项和搜索项扩展后均能被加载。
- 开发构建可以热更新,生产构建可以输出模板、JS 和 CSS。
- 消费工程中只有一份 Vue 运行时,Element Plus 版本符合支持区间。
- 非法环境名、入口重名和业务配置内部错误会明确失败。
11. 总结
elpis-core 的 npm 抽离,本质上是一次运行边界重建。服务端要区分框架资源与业务资源,前端要同时处理两套源码和两套依赖解析,扩展机制要在"默认可运行"和"业务可覆盖"之间建立稳定协议,包入口还必须消除导入时副作用。
当前分支已经打通了核心链路:SDK 入口、双目录 Loader、多页面构建、SSR 模板输出和业务扩展钩子都已形成。接下来最优先的工作不是继续增加功能,而是收紧发布文件、修正业务路由别名、明确依赖分类,并建立基于 tgz 的消费工程集成测试。完成这些工作后,elpis-core 才能从"仓库内能够打包"进入"任意项目安装后可稳定使用"的状态。