阅读 3210

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

前言

本文将通过设计一个前端工程化解决方案的实际经验(踩过的坑)来教大家如何设计一个灵活可扩展的前端工程化解决方案。为了让大家更清晰地了解如此设计的前因后果,我将秉承不厌其详(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 上赋予的能力来完成各种功能
  • 命令行工具能自动收集工程下依赖安装的插件和全局插件,用户可以通过一个配置文件来配置插件执行顺序和插件参数

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

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

关注下面的标签,发现更多相似文章
评论