打造靈活可擴充套件的前端工程化框架

BDEEFE發表於2019-05-07

前言

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

相關文章