打造灵活可扩展的前端工程化框架

前言

本文将经过设计一个前端工程化解决方案的实际经验(踩过的坑)来教你们如何设计一个灵活可扩展的前端工程化解决方案。为了让你们更清晰地了解如此设计的来龙去脉,我将秉承不厌其详(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

  • 各类功能不能拆开使用、发布
    • 不少项目并不须要脚手架提供的所有功能,但脚手架自己提供的各类设施并不能拆解开来使用,好比有的老项目只想集成 i18n 的功能,但要使用脚手架却须要把自己的打包编译一块儿替换掉。
    • 因为咱们的团队分布在不一样城市地区,每一个团队有本身的技术输出,均可觉得这个脚手架增长不一样功能,添砖加瓦。但总不能让你们都来改这一个脚手架的仓库吧,这显然不合适。
  • 只是提供模版并不能解决全部的问题
    • 因为是一个你们都全局安装的命令行工具(让你们全局安装的工具不能太多,须要尽量地把功能集成到一个),咱们但愿这个工具能帮你们简化更多的问题,好比触发 CI 构建,代码提交 review,测试/发布/上线等,但愿它的使用能覆盖到项目从启动到上线的各方面。

重构 😈

通过一番思考后,我尴尬地发现,现有的设计并很差解决上面提到的两个问题。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;
  }
}
复制代码
  • 插件执行时能够结合 context 上赋予的能力来完成各类功能
  • 命令行工具能自动收集工程下依赖安装的插件和全局插件,用户能够经过一个配置文件来配置插件执行顺序和插件参数

下图是重构后的运行流程:

flow

能够看出按这个方案以前的脚手架只是一个生成新项目的插件,实际上咱们也是这么作的,把生成模版的逻辑收敛到了一个 generate 插件里。

把功能分配到插件中实现,可以解决第一个问题,让方案自己提供的功能能拆开使用,须要某个功能只要安装该功能的插件便可,且方便插件的维护发布,不一样插件能够由不一样开发者团队维护。

不一样工程下安装了不一样的插件,执行 light 命令能够支持不一样的功能,如:

bytedance 目录下只安装了一些基础的插件,命令行提示只有简单几个操做插件和物料的指令

light-in-dir

larksuite 目录下安装了 i18n lint larklet 等插件,即提示可使用其相关的指令

light-in-project-dir

插件具备共享 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 在全国各地长期招聘优秀的前端工程师,招聘需求了解下?

相关文章
相关标签/搜索