本文将经过设计一个前端工程化解决方案的实际经验(踩过的坑)来教你们如何设计一个灵活可扩展的前端工程化解决方案。为了让你们更清晰地了解如此设计的来龙去脉,我将秉承不厌其详(LuoLiBaSuo)的态度讲解从最开始一步步的设计思路和过程。javascript
咱们团队最开始开发中后台项目用的是 create-react-app 生成的模版。前端
但 create-react-app 生成的功能是不够的,好比使用 ant-design 时须要配置 babel-plugin-import ,此时就只能覆盖 create-react-app 的配置,create-react-app 并不提供覆盖默认配置的方法(选择 eject 会致使模版不能升级,显然不是个好的方案),所以只能使用 react-app-rewired 来实现咱们的目的。vue
但随着业务需求/技术需求的发展,咱们想要集成更多工程设施,此时 react-app-rewired 就有些不够用了,并且咱们但愿每一个项目都公用一套工程设施,而不是每一个项目新建以后还要各自单独配置,这样的设计不利于团队技术选型规范的统一。java
最后,咱们选择本身开发一套适合咱们团队的脚手架工具。react
好了,咱们如今须要的功能有两个:webpack
为了实现这个功能,我参考了 create-react-app 的实现 😝,编写了一套咱们本身的脚手架,其实也就是一套封装成 npm package 的 webpack 工做流模版 + 一个模版生成器,区别在于,这个模版工做时会引用工程目录下 byted.config.js 的自定义配置和自己的默认配置进行 merge。git
虽然比较简单,但彷佛完美实现了咱们的技术需求。github
简单实现的山寨进化版 create-react-app 开心地工做了一段时间后,咱们发现它仍是并不能解决咱们的一些问题。主要有两个:web
通过一番思考后,我尴尬地发现,现有的设计并很差解决上面提到的两个问题。vue-cli
由于目前的设计只是生成一个我配置好的模版,要想解决第一个问题只能是把这个模版拆分红更多的模版,em ... 🤔️,这个一看就不靠谱,由于无法控制模版的规范和加载方式,况且把这些模版集合起来呢。第二个问题就更无法入手解决,由于如今全局安装的只是一个模版生成器,无法作其它事。
最后,咱们选择对脚手架进行重构。参考了如今社区上最新的脚手架设计方案(vue-cli,angular-cli,umi),设计了一个以插件为基础的灵活可扩展的工程化解决方案:
每一个插件都是一个 Class ,对外暴露 apply 方法和 afterInstall beforeUninstall 等生命周期方法,做为 npm 包发布到 npm registry 上,使用时做为依赖安装在工程内,部分插件也能够全局安装
全局安装的命令行工具只提供一套运行机制,用于启动协调各个插件
插件经过 apply 或 生命周期方法做为入口执行
最开始咱们只设计了一个 apply 方法做为插件执行的入口,以后发现有些场景知足不了,好比安装插件时须要初始化环境,卸载插件时须要移除一些配置因此提供了 apply、afterInstall、beforeUninstall 的生命周期方法。
插件执行时会传入整个命令行运行时的上下文 Context 对象,插件能够往 Context 上挂载一些方法、监听/触发一些事件用于和其它插件交流
// 构造 Context 对象的部分代码
export class BaseContext extends Hook {
private _api: Api = {};
public api: Api;
constructor() {
super();
this.api = new Proxy(this._api, {
get: this._apiGet,
set: this._apiSet,
});
}
// ...
private _apiSet(target, key, value, receiver) {
console.log(chalk.bgRed(`please use mountApi('${key}',func) !!!`));
return true;
}
private _apiGet(target, key, receiver) {
if (target[key]) {
return target[key];
} else {
console.log(chalk.bgRed(`there have not api.${key}`));
return new Function();
}
}
mountApi(apiName: string, func) {
if (!this._api[apiName]) {
this._api[apiName] = func;
return this._api[apiName];
}
return false;
}
}
复制代码
下图是重构后的运行流程:
能够看出按这个方案以前的脚手架只是一个生成新项目的插件,实际上咱们也是这么作的,把生成模版的逻辑收敛到了一个 generate 插件里。
把功能分配到插件中实现,可以解决第一个问题,让方案自己提供的功能能拆开使用,须要某个功能只要安装该功能的插件便可,且方便插件的维护发布,不一样插件能够由不一样开发者团队维护。
不一样工程下安装了不一样的插件,执行 light 命令能够支持不一样的功能,如:
bytedance 目录下只安装了一些基础的插件,命令行提示只有简单几个操做插件和物料的指令
larksuite 目录下安装了 i18n lint larklet 等插件,即提示可使用其相关的指令
插件具备共享 Context 的能力是为了方便不一样功能之间的配合(好比 i18n 的插件须要调用 webpack 的插件补充一个 webpack plugin),并提升代码复用的能力(好比 basePlugin 就在 Context 上挂载了大量代码物料和命令行方面的 api 给其它插件使用),好比: 调用 webpackPlugin 提供的 setEntry 方法新加 webpack entry:
this.ctx.api.setEntry(entries);
复制代码
给插件完善生命周期机制,并提供全局插件是为了解决咱们的第二个问题(好比不少插件能够在安装的时候初始化好所需的环境),一些经常使用的开发工具能够做为全局插件安装,和工程插件配合使用。
下面是一个插件的使用示例:
class MyPlugin implements Plugin {
// 成员变量 ctx 用于保存 constructor 获取到的 ctx 对象
ctx: Cli;
constructor(ctx: Cli, option) {
// new 的时候会将 lightblue context 和用户自定义的 option 传入构造函数
this.ctx = ctx;
}
/** * 生命周期函数 afterInstall * afterInstall 函数会在 lightblue add 安装该插件后当即执行 * 能够在这里初始化该插件须要的工做环境,如 lint-plugin 生成 .eslintrc 文件 * */
afterInstall(ctx: Cli) {
// 这里用了一个 lightblue 自带的 api 用于复制模版到初始化工做区
this.ctx.api.copyTemplate('template path', 'workpath');
}
/** * 生命周期函数 apply * apply 函数会在 lightblue 启动时执行 * 能够在这里注册命令,注册各类 api,监听事件等, * 如 webpack-plugin 提供 build/serve 命令和 getEntry api * */
apply(ctx: Cli) {
// 用 registerCommand 方法注册一条命令
this.ctx.registerCommand({
cmd: 'hello',
desc: 'say hello in terminal',
builder: (argv) =>
argv.option('name', {
alias: 'n',
default: 'bytedancer',
type: 'string',
desc: 'name to say hello'
}),
handler: (argu) => {
let { name } = argu;
// 请使用 lightblue 内置的 log 方法打印消息
this.ctx.api.logSuccess('hello ' + name);
}
});
// 用 mountApi 挂载一个 api
this.ctx.mountApi('hello', (name) => {
this.ctx.api.logSuccess('hello ' + name);
});
// 别的插件能够这样使用这个 api
this.ctx.api.hello('bytedancer');
// 触发一个事件 emitAsync
this.ctx.emit('hello');
// 别的插件能够这样监听这个事件
this.ctx.on('hello', async () => {});
}
}
export default MyPlugin;
复制代码
咱们的解决方案终于成型,并接入一些项目中使用,可是革命还没有成功,同志还需努力。使用一段时间后,收集了你们的意见和建议,咱们做出了一些优化:
问题:没有日志机制,当出现问题时没法查看执行记录和异常。
优化方案:基于 winston 封装了一套日志记录 api 挂在 Context 上,给其它插件使用。
ctx.mountApi('log', Logger.getInstance().log);
ctx.mountApi('logError', Logger.getInstance().logErr);
ctx.mountApi('logWarn', Logger.getInstance().logWarn);
ctx.mountApi('logSuccess', Logger.getInstance().logSuccess);
复制代码
问题:虽然提供了插件机制,但没有提供编写插件相关的工具,致使愿意编写插件的人比较少。
优化方案:重构时使用了 TypeScript ,并补全了各类 interface ,编写插件时能够直接根据 TS 的提示编码,而且提供了一个生成插件开发环境的插件,用于自动搭建插件开发环境。
问题:安装以后不少人就不肯意更新,致使新的 feature 用户数较少。
优化方案:在每次执行完成后检查版本信息和 npm 上最新的版本比对,若是须要更新打印更新的提示。
咱们从最开始的一个简单的脚手架工具一步步添加了插件、生命周期等概念,最终打造了一个前端工程化框架,过程虽然曲折,但其实无法避免。技术方案的设计须要迎合业务需求的变动,工程化方案的设计也一样须要迎合技术需求的变动。设计方案的时候要考虑到将来可能的变化,但也不能过分设计,本着优先知足需求的原则便可,当须要变动方案的时候,先讨论可行性和方向设计,再着手优化/重构。
文章做者:胡钺
BDEEFE 在全国各地长期招聘优秀的前端工程师,招聘需求了解下?