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