前言
最近完成了 elpis 的 npm 包抽离工作,说实话,把框架从业务代码里抽出来做成 npm 包,听起来好像挺简单的,但真正动手的时候才发现,路径问题、加载顺序、构建配置...每一个都是坑。
架构设计
抽离后的结构:
scss
elpis (框架包) elpis-demo (业务项目)
├── elpis-core/ ├── app/
├── app/ │ ├── controller/
└── index.js │ └── router/
└── server.js
框架提供基础能力,业务项目通过 npm 依赖使用。
核心难点
1. 路径解析问题
这是最大的坑。框架需要同时加载两个位置的文件:
- 框架自身的文件(在 node_modules 中)
- 业务项目的文件(在项目根目录)
javascript
module.exports = {
start(options = {}) {
const app = new Koa();
// 业务项目根目录
app.baseDir = process.cwd();
// 业务代码目录
app.businessPath = path.resolve(app.baseDir, `./app`);
return app;
}
}
关键点:
- 用
process.cwd()定位业务项目 - 用
__dirname定位框架内部文件 - 统一使用
path.sep处理跨平台路径
2. 动态加载器的优先级
框架和业务都有 Controller、Service,如何处理?
采用"框架先行,业务覆盖"策略:
javascript
module.exports = (app) => {
const controller = {};
// 1. 先加载框架的
const elpisFileList = glob.sync(path.resolve(__dirname, `../../app/controller/**/*.js`));
elpisFileList.forEach(file => handleFile(file));
// 2. 再加载业务的(会覆盖同名)
const businessFileList = glob.sync(path.resolve(app.businessPath, `./controller/**/*.js`));
businessFileList.forEach(file => handleFile(file));
app.controller = controller;
};
3. Webpack 构建配置
需要同时扫描框架和业务的入口文件:
javascript
// 扫描框架入口
const elpisEntryList = glob.sync(
path.resolve(__dirname, '../../pages/**/entry.*.js')
);
// 扫描业务入口
const businessEntryList = glob.sync(
path.resolve(process.cwd(), './app/pages/**/entry.*.js')
);
module.exports = {
entry: Object.assign({}, elpisPageEntries, businessPageEntries),
module: {
rules: [{
test: /\.js$/,
include: [
path.resolve(__dirname, '../../pages'),
path.resolve(process.cwd(), './app/pages'),
],
use: { loader: require.resolve('babel-loader') }
}]
},
resolve: {
alias: {
'$elpisWidgets': path.resolve(__dirname, '../../pages/widgets'),
// 业务扩展配置(不存在则指向空模块)
'$businessConfig': fs.existsSync(businessConfigPath)
? businessConfigPath
: path.resolve(__dirname, '../libs/blank.js')
}
}
};
关键点:
- 使用
require.resolve确保 loader 路径正确 - 通过 alias 提供统一引用路径
- 业务扩展不存在时指向空模块
4. 中间件注册顺序
javascript
// 先注册框架中间件
const elpisMiddleware = require(path.resolve(__dirname, `../app/middleware.js`));
elpisMiddleware(app);
// 再注册业务中间件(可选)
try {
require(`${app.businessPath}/middleware.js`)(app);
} catch (error) {
console.log('no global businessMiddleware file');
}
5. 对外 API 设计
保持简洁:
javascript
// elpis/index.js
module.exports = {
Controller: {
Base: require('./app/controller/base.js')
},
Service: {
Base: require('./app/service/base.js')
},
frontEndBuild(env) {
if (env === 'local') FEBuildDev();
else if (env === 'production') FEBuildProd();
},
serverStart(options = {}) {
return ElpisCore.start(options);
}
};
业务项目使用:
javascript
// server.js
const { serverStart } = require('@gordonlzg/elpis');
const app = serverStart({ name: 'ElpisDemo' });
// build.js
const { frontEndBuild } = require('@gordonlzg/elpis');
frontEndBuild(process.env._ENV);
// controller/business.js
const { Controller } = require('@gordonlzg/elpis');
module.exports = (app) => {
return class BusinessController extends Controller.Base {
async list(ctx) { /* ... */ }
}
}
踩坑点
1. require.resolve 的妙用
javascript
// ❌ 错误:业务项目找不到
use: { loader: 'vue-loader' }
// ✅ 正确:从框架包中解析
use: { loader: require.resolve('vue-loader') }
2. glob 路径处理
javascript
// ❌ 错误:Windows 上会失败
glob.sync('./app/**/*.js')
// ✅ 正确:使用 path.resolve
glob.sync(path.resolve(app.businessPath, `./**/*.js`))
3. 文件不存在的处理
javascript
// ❌ 直接 require 会报错
const config = require(`${app.businessPath}/config.js`);
// ✅ 先判断是否存在
if (fs.existsSync(configPath)) {
const config = require(configPath);
}
4. Webpack alias 空模块技巧
javascript
alias: {
'$businessConfig': fs.existsSync(businessConfigPath)
? businessConfigPath
: path.resolve(__dirname, '../libs/blank.js') // 空模块
}
// blank.js
module.exports = {};
总结
抽离 npm 包的核心难点:
- 路径解析 - 正确处理框架和业务的路径关系
- 加载顺序 - 框架先行,业务覆盖
- 构建配置 - Webpack 的路径和 loader 处理
- API 设计 - 简洁易用