Elpis 框架 npm 包抽离思路

6 阅读2分钟

前言

最近完成了 elpis 的 npm 包抽离工作,说实话,把框架从业务代码里抽出来做成 npm 包,听起来好像挺简单的,但真正动手的时候才发现,路径问题、加载顺序、构建配置...每一个都是坑。

架构设计

抽离后的结构:

elpis (框架包)              elpis-demo (业务项目)
├── elpis-core/            ├── app/
├── app/                   │   ├── controller/
└── index.js               │   └── router/
                           └── server.js

框架提供基础能力,业务项目通过 npm 依赖使用。

核心难点

1. 路径解析问题

这是最大的坑。框架需要同时加载两个位置的文件:

  • 框架自身的文件(在 node_modules 中)
  • 业务项目的文件(在项目根目录)
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,如何处理?

采用"框架先行,业务覆盖"策略:

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 构建配置

需要同时扫描框架和业务的入口文件:

// 扫描框架入口
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. 中间件注册顺序

// 先注册框架中间件
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 设计

保持简洁:

// 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);
  }
};

业务项目使用:

// 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 的妙用

// ❌ 错误:业务项目找不到
use: { loader: 'vue-loader' }

// ✅ 正确:从框架包中解析
use: { loader: require.resolve('vue-loader') }

2. glob 路径处理

// ❌ 错误:Windows 上会失败
glob.sync('./app/**/*.js')

// ✅ 正确:使用 path.resolve
glob.sync(path.resolve(app.businessPath, `./**/*.js`))

3. 文件不存在的处理

// ❌ 直接 require 会报错
const config = require(`${app.businessPath}/config.js`);

// ✅ 先判断是否存在
if (fs.existsSync(configPath)) {
  const config = require(configPath);
}

4. Webpack alias 空模块技巧

alias: {
  '$businessConfig': fs.existsSync(businessConfigPath)
    ? businessConfigPath
    : path.resolve(__dirname, '../libs/blank.js')  // 空模块
}

// blank.js
module.exports = {};

总结

抽离 npm 包的核心难点:

  1. 路径解析 - 正确处理框架和业务的路径关系
  2. 加载顺序 - 框架先行,业务覆盖
  3. 构建配置 - Webpack 的路径和 loader 处理
  4. API 设计 - 简洁易用