前言
本文将通过设计一个前端工程化解决方案的实际经验(踩过的坑)来教大家如何设计一个灵活可扩展的前端工程化解决方案。为了让大家更清晰地了解如此设计的前因后果,我将秉承不厌其详(LuoLiBaSuo)的态度讲解从最开始一步步的设计思路和过程。
开端 🌟
我们团队最开始开发中后台项目用的是 create-react-app 生成的模版。
但 create-react-app 生成的功能是不够的,比如使用 ant-design 时需要配置 babel-plugin-import ,此时就只能覆盖 create-react-app 的配置,create-react-app 并不提供覆盖默认配置的方法(选择 eject 会导致模版不能升级,显然不是个好的方案),因此只能使用 react-app-rewired 来实现我们的目的。
但随着业务需求/技术需求的发展,我们想要集成更多工程设施,此时 react-app-rewired 就有些不够用了,而且我们希望每个项目都公用一套工程设施,而不是每个项目新建之后还要各自单独配置,这样的设计不利于团队技术选型规范的统一。
最后,我们选择自己开发一套适合我们团队的脚手架工具。
最初的方案 😉
好了,我们现在需要的功能有两个:
- 按照我们的团队的技术选型和规范,在新建项目时生成一套集成了默认配置、工程设施和工作流的模版。
- 这个模版要是可升级的,而且升级的同时要可以接受外部自定义。
为了实现这个功能,我参考了 create-react-app 的实现 😝,编写了一套我们自己的脚手架,其实也就是一套封装成 npm package 的 webpack 工作流模版 + 一个模版生成器,区别在于,这个模版工作时会引用工程目录下 byted.config.js 的自定义配置和本身的默认配置进行 merge。
虽然比较简单,但似乎完美实现了我们的技术需求。
缺陷 😵
简单实现的山寨进化版 create-react-app 开心地工作了一段时间后,我们发现它还是并不能解决我们的一些问题。主要有两个:
- 各种功能不能拆开使用、发布
- 很多项目并不需要脚手架提供的全部功能,但脚手架本身提供的各种设施并不能拆解开来使用,比如有的老项目只想集成 i18n 的功能,但要使用脚手架却需要把本身的打包编译一起替换掉。
- 由于我们的团队分布在不同城市地区,每个团队有自己的技术输出,都可以为这个脚手架增加不同功能,添砖加瓦。但总不能让大家都来改这一个脚手架的仓库吧,这显然不合适。
- 只是提供模版并不能解决所有的问题
- 由于是一个大家都全局安装的命令行工具(让大家全局安装的工具不能太多,需要尽可能地把功能集成到一个),我们希望这个工具能帮大家简化更多的问题,比如触发 CI 构建,代码提交 review,测试/发布/上线等,希望它的使用能覆盖到项目从启动到上线的各方面。
重构 😈
经过一番思考后,我尴尬地发现,现有的设计并不好解决上面提到的两个问题。
因为目前的设计只是生成一个我配置好的模版,要想解决第一个问题只能是把这个模版拆分成更多的模版,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;
}
}
复制代码
- 插件执行时可以结合 context 上赋予的能力来完成各种功能
- 命令行工具能自动收集工程下依赖安装的插件和全局插件,用户可以通过一个配置文件来配置插件执行顺序和插件参数
下图是重构后的运行流程:
可以看出按这个方案之前的脚手架只是一个生成新项目的插件,实际上我们也是这么做的,把生成模版的逻辑收敛到了一个 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
链接:https://juejin.im/post/5cd125d4e51d45475f4de2b0
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。