打造靈活可擴展的前端工程化框架

前言

本文將經過設計一個前端工程化解決方案的實際經驗(踩過的坑)來教你們如何設計一個靈活可擴展的前端工程化解決方案。爲了讓你們更清晰地瞭解如此設計的來龍去脈,我將秉承不厭其詳(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 在全國各地長期招聘優秀的前端工程師,招聘需求瞭解下?

相關文章
相關標籤/搜索