Elpis — 抽离并发布 npm 包
一、 准备工作
在发布 npm 包之前,我们需要先在本地做好测试工作,如何在本地将我们的工程作为包来使用呢?
- 在 elpis 目录下使用
npm link创建符号链接 - 创建 elpis-demo 工程,并在 elpis-demo 使用
npm link elpis将 elpis 链接到 node_modules 目录下。
只需要简单的两个步骤就让在本地开始测试啦。
二、抽离 elpis-core
elpis-core 是一个约定大于配置的服务框架,在使用时,只需要在对应的目录下写下相对应的文件,elpis-core 会帮你配置好其他的细节,并将内容挂载到 app 实例下。以 controller为例:
module.exports = (app) => {
// 读取 app/controller/**/**.js 下所有的文件
const controllerPath = path.resolve(app.businessPath, `.${sep}controller`);
const fileList = glob.sync(
path.resolve(controllerPath, `.${sep}**${sep}**.js`)
);
// 遍历所有文件目录,把内容加载到 app.controllers 下
const controllers = {};
fileList.forEach((file) => {
// 提取文件名称
let name = path.resolve(file);
// 截取路径 app/controller/custom-module/custom-controller.js ==> custom-module/custom-controller
name = name.substring(
name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length,
name.lastIndexOf(".")
);
// 把 '-' 更改为驼峰 'custom-module' -> 'customModule'
name = name.replace(/[_-]([a-z])/gi, (s) => s.substring(1).toUpperCase());
let temController = controllers;
const names = name.split(sep); // [customModule(目录), customController(文件)]
for (let i = 0, len = names.length; i < len; i++) {
if (i === len - 1) {
const ControllerModel = require(path.resolve(file))(app);
temController[names[i]] = new ControllerModel();
} else {
if (!temController[names[i]]) {
temController[names[i]] = {};
}
temController = temController[names[i]];
}
}
});
// 挂载 controller 到内存 app 对象中
app.controller = controllers;
};
当我们将 elpis 当做包来使用时,这份代码只会为我们自动挂载业务目录下的 contrloler 。我们不仅要将业务上的 controller 挂载到
app 下,还要提供自己的 controller 。所以将上述代码做一点修改:
module.exports = (app) => {
const controllers = {};
// 读取 elpis/app/controller/**/**.js 下所有的文件
const elpisControllerPath = path.resolve(
__dirname,
`..${sep}..${sep}app${sep}controller`
);
const elpisFileList = glob.sync(
path.resolve(elpisControllerPath, `.${sep}**${sep}**.js`)
);
elpisFileList.forEach((file) => {
handleFile(file);
});
// 读取 业务/app/controller/**/**.js 下所有的文件
const businessControllerPath = path.resolve(
app.businessPath,
`.${sep}controller`
);
const businessFileList = glob.sync(
path.resolve(businessControllerPath, `.${sep}**${sep}**.js`)
);
businessFileList.forEach((file) => {
handleFile(file);
});
// 把内容加载到 app.controllers 下
function handleFile(file) {
// 提取文件名称
let name = path.resolve(file);
// 截取路径 app/controller/custom-module/custom-controller.js ==> custom-module/custom-controller
name = name.substring(
name.lastIndexOf(`controller${sep}`) + `controller${sep}`.length,
name.lastIndexOf(".")
);
// 把 '-' 更改为驼峰 'custom-module' -> 'customModule'
name = name.replace(/[_-]([a-z])/gi, (s) => s.substring(1).toUpperCase());
let temController = controllers;
const names = name.split(sep); // [customModule(目录), customController(文件)]
for (let i = 0, len = names.length; i < len; i++) {
if (i === len - 1) {
const ControllerModel = require(path.resolve(file))(app);
temController[names[i]] = new ControllerModel();
} else {
if (!temController[names[i]]) {
temController[names[i]] = {};
}
temController = temController[names[i]];
}
}
}
// 挂载 controller 到内存 app 对象中
app.controller = controllers;
};
三、抽离 webpack
3.1 暴露 webpack
在 elpis 内部,我们分别提供了 dev.js 和 prod.js 来运行 webpack 的开发服务器和打包。我们需要将这些方法暴露出来,只需要将dev.js 和 prod.js 的功能封装成函数并暴露出去即可。
// elpis/app/webpack/dev.js
module.exports = () => {
// 启动开发服务器
}
// elpis/app/webpack/prod.js
module.exports = () => {
// 打包...
}
// elpis/index.js
const FEBuildDev = require("./app/webpack/dev.js");
const FEBuildProd = require("./app/webpack/prod.js");
module.exports = () => {
/**
* 编译构建前端工程
* @param {String} env 环境变量 local/production
*/
frontedBuild(env) {
if (env === "local") {
FEBuildDev();
} else if (env === "production") {
FEBuildProd();
}
},
// 其他方法...
}
3.2 修改 webpack 配置
-
路径别名: 在将 eplis 当做包使用后之前的路径别名称会定位到我们的业务目录下,需要对此作出修改:
// 抽离前 resolve: { extensions: ['.js', '.vue', '.less', '.css'], // 自动补全文件扩展名 alias: { $pages: path.resolve(process.cwd(), './app/pages'), // 路径别名 $common: path.resolve(process.cwd(), './app/pages/common'), // 路径别名 $widgets: path.resolve(process.cwd(), './app/pages/widgets'), // 路径别名 $store: path.resolve(process.cwd(), './app/pages/store'), // 路径别名 }, }, // 抽离后 resolve: { $elpisPages: path.resolve(__dirname, "../../pages"), // 路径别名 $elpisBoot: path.resolve(__dirname, "../../pages/boot.js"), $elpisCommon: path.resolve(__dirname, "../../pages/common"), // 路径别名 $elpisUtils: path.resolve(__dirname, "../../pages/common/utils.js"), $elpisCurl: path.resolve(__dirname, "../../pages/common/curl.js"), $elpisWidgets: path.resolve(__dirname, "../../pages/widgets"), // 路径别名 } -
Loader: 在外部使用 webpack 打包 loader 会从业务目录下的
node_modules下定位,而我们的包中提供了自己的 loader ,不需要用户额外的下载其他版本的 loader 。只需要使用require.resolve('swc-loader')包裹起来即可。同理 elpis 也提供了自己使用的 vue 版本,使用路径别名的方式给 vue 定位即可。resolve: { vue: require.resolve('vue') // ... } -
**入口:**在 elpis 中,提供了 dashbord 和 project-list 入口文件,除了用户的
entry,内部的入口文件也需要被解析。// 动态构造 elpisPageEntries elpisHtmlWebpackPluginList const elpisPageEntries = {}; const elpisHtmlWebpackPluginList = []; // 获取 elpis/app/pages 目录下所有入口文件 (entry.xx.js) const elpisEntryList = path.resolve(__dirname, "../../pages/**/entry.*.js"); glob.sync(elpisEntryList).forEach((file) => { handleFile(file, elpisPageEntries, elpisHtmlWebpackPluginList); }); // 动态构造 businessPageEntries businessHtmlWebpackPluginList const businessPageEntries = {}; const businessHtmlWebpackPluginList = []; // 获取 业务/app/pages 目录下所有入口文件 (entry.xx.js) const businessEntryList = path.resolve( process.cwd(), "./app/pages/**/entry.*.js" ); glob.sync(businessEntryList).forEach((file) => { handleFile(file, businessPageEntries, businessHtmlWebpackPluginList); }); // 构造相关 webpack 处理的数据结构 function handleFile(file, entries = {}, htmlWebpackPluginList = []) { const entryName = path.basename(file, ".js"); // 构造 entry entries[entryName] = file; // 构造最终渲染的页面 htmlWebpackPluginList.push( // html-webpack-plugin 辅助注入 bundle 到 tpl 文件中 new HtmlWebpackPlugin({ // 产物(最终模板)输出路径 filename: path.resolve( process.cwd(), "./app/public/dist/", `${entryName}.tpl` ), // 指定要使用的模板 template: path.resolve(__dirname, "../../view/entry.tpl"), chunks: [entryName], // 引入的 chunk }) ); } // webpackConfig { entry: Object.assign({}, elpisPageEntries, businessPageEntries), // ... } -
允许用户提供自己的 webpack 配置
// 加载 业务 webpack 配置 let businessWebpackConfig = {}; try { businessWebpackConfig = require(`${process.cwd()}/app/webpack.config.js`); } catch (e) {} webpackconfig = marge.smart( // ..., businessWebpackConfig )
四、提供公共组件扩展
以 schema-view 为例,在 schema-view 中,我们提供了动态组件的扩展能力,需要为用户提供在外部扩展的能力。
// 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";
// 业务扩展 component 配置
import BusinessComponentConfig from "$businessComponentConfig";
const componentConfig = {
createForm: {
component: createForm,
},
editForm: {
component: editForm,
},
detailPanel: {
component: detailPanel,
},
};
export default {
...componentConfig,
...BusinessComponentConfig,
};
若用户未配置自己的component-config.js文件,webpack 在打包时会发生 can not resolve module 的错误,我们需要对这种情况做进一步的处理,将 webpack 配置的中的路径别名的设置使用立即执行函数来避免错误的发生。
// webpackConfig.resolve
resolve: {
extensions: [".js", ".vue", ".less", ".css"], // 自动补全文件扩展名
alias: (() => {
const aliasMap = {};
const blankModulePath = path.resolve(__dirname, "../../pages/blank");
// dashboard 路由扩展配置
const businessDashboardRouterConfig = path.resolve(
process.cwd(),
"./app/pages/dashboard/router.js"
);
aliasMap["$businessDashboardRouterConfig"] = fs.existsSync(
businessDashboardRouterConfig
)
? businessDashboardRouterConfig
: blankModulePath;
// schema-view component 扩展配置
const BusinessComponentConfig = path.resolve(
process.cwd(),
"./app/pages/dashboard/complex-view/schema-view/components/component-config.js"
);
aliasMap["$businessComponentConfig"] = fs.existsSync(
BusinessComponentConfig
)
? BusinessComponentConfig
: blankModulePath;
// schema-from component 扩展配置
const BusinessFormItemConfig = path.resolve(
process.cwd(),
"./app/pages/widgets/schema-form/form-item-config.js"
);
aliasMap["$businessFormItemConfig"] = fs.existsSync(
BusinessFormItemConfig
)
? BusinessFormItemConfig
: blankModulePath;
// schema-search-bar component 扩展配置
const BusinessSearchItemConfig = path.resolve(
process.cwd(),
"./app/pages/widgets/schema-search-bar/search-item-config.js"
);
aliasMap["$businessSearchItemConfig"] = fs.existsSync(
BusinessSearchItemConfig
)
? BusinessSearchItemConfig
: blankModulePath;
return {
vue: require.resolve("vue"),
$elpisPages: path.resolve(__dirname, "../../pages"), // 路径别名
$elpisBoot: path.resolve(__dirname, "../../pages/boot.js"),
$elpisCommon: path.resolve(__dirname, "../../pages/common"), // 路径别名
$elpisUtils: path.resolve(__dirname, "../../pages/common/utils.js"),
$elpisCurl: path.resolve(__dirname, "../../pages/common/curl.js"),
$elpisWidgets: path.resolve(__dirname, "../../pages/widgets"), // 路径别名
$elpisHeaderContainer: path.resolve(
__dirname,
"../../pages/widgets/header-container/header-container.vue"
),
$elpisSiderContainer: path.resolve(
__dirname,
"../../pages/widgets/sider-container/sider-container.vue"
),
$elpisSchemaForm: path.resolve(
__dirname,
"../../pages/widgets/schema-form/schema-form.vue"
),
$elpisSchemaTable: path.resolve(
__dirname,
"../../pages/widgets/schema-table/schema-table.vue"
),
$elpisSchemaSearchBar: path.resolve(
__dirname,
"../../pages/widgets/schema-search-bar/schema-search-bar.vue"
),
$elpisStore: path.resolve(__dirname, "../../pages/store"), // 路径别名
...aliasMap,
};
})(),
},
五、总结
在抽离 npm 包的整个过程需要对各个环节细致把控,充分考虑不同目录下资源的处理以及用户的定制需求,如此才能成功抽离并发布功能完备、适应性强的 npm 包。
出处: 《哲玄课堂-大前端全栈实践》