本文將經過設計一個前端工程化解決方案的實際經驗(踩過的坑)來教你們如何設計一個靈活可擴展的前端工程化解決方案。爲了讓你們更清晰地瞭解如此設計的來龍去脈,我將秉承不厭其詳(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
通過一番思考後,我尷尬地發現,現有的設計並很差解決上面提到的兩個問題。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;
}
}
複製代碼
下圖是重構後的運行流程:
能夠看出按這個方案以前的腳手架只是一個生成新項目的插件,實際上咱們也是這麼作的,把生成模版的邏輯收斂到了一個 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 在全國各地長期招聘優秀的前端工程師,招聘需求瞭解下?