精讀《插件化思維》

知乎排版不太舒服,建議直接去 Github 精讀 閱讀原文。

本周精讀內容是 《插件化思維》。沒有參考文章,資料源自 webpack、fis、egg 以及筆者自身開發經驗。

1 引言

用過構建工具的同學都知道,grunt, webpack, gulp 都支持插件開發。後端框架比如 egg koa 都支持插件機制拓展,前端頁面也有許多可拓展性的要求。插件化無處不在,所有的框架都希望自身擁有最強大的可拓展能力,可維護性,而且都選擇了插件化的方式達到目標。

我認為插件化思維是一種極客精神,而且大量可拓展、需要協同開發的程序都離不開插件機制支撐。

沒有插件化,核心庫的代碼會變得冗餘,功能耦合越來越嚴重,最後導致維護困難。插件化就是將不斷擴張的功能分散在插件中,內部集中維護邏輯,這就有點像資料庫橫向擴容,結構不變,拆分數據。

2 精讀

理想情況下,我們都希望一個庫,或者一個框架具有足夠的可拓展性。這個可拓展性體現在這三個方面:

  • 讓社區可以貢獻代碼,而且即使代碼存在問題,也不會影響核心代碼的穩定性。
  • 支持二次開發,滿足不同業務場景的特定需求。
  • 讓代碼以功能為緯度聚合起來,而不是某個片面的邏輯結構,在代碼數量龐大的場景尤為重要。

我們都清楚插件化應該能解決問題,但從哪下手呢?這就是筆者希望分享給大家的經驗。

做技術設計時,最好先從使用者角度出發,當設計出舒服的調用方式時,再去考慮實現。所以我們先從插件使用者角度出發,看看可以提供哪些插件使用方式給開發者。

2.1 插件化分類

插件化許多都是從設計模式演化而來的,大概可以參考的有:命令模式,工廠模式,抽象工廠模式等等,筆者根據個人經驗,總結出三種插件化形式:

  • 約定/注入插件化。
  • 事件插件化。
  • 插槽插件化。

最後還有一個不算插件化實現方式,但效果比較優雅,姑且稱為分形插件化吧。下面一一解釋。

2.1.1 約定/注入插件化

按照某個約定來設計插件,這個約定一般是:入口文件/指定文件名作為插件入口,文件形式.json/.ts 不等,只要返回的對象按照約定名稱書寫,就會被載入,並可以拿到一些上下文。

舉例來說,比如只要項目的 package.jsonapollo 存在 commands 屬性,會自動註冊新的命令行:

{ "apollo": { "commands": [{ "name": "publish", "action": "doPublish" }] }}

當然 json 能力很弱,定義函數部分需要單獨在 ts 文件中完成,那麼更廣泛的方式是直接寫 ts 文件,但按照文件路徑決定作用,比如:項目的 ./controllers 存在 ts 文件,會自動作為控制器,響應前端的請求。

這種情況根據功能類型決定對 ts 文件代碼結構的要求。比如 node 控制器這層,一個文件要響應多個請求,而且邏輯單一,那就很適合用 class 的方式作為約定,比如:

export default class User { async login(ctx: Context) { ctx.json({ ok: true }); }}

如果功能相對雜亂,沒有清晰的功能入口規劃,比如 gulp 這種插件,那用對象會更簡潔,而且更傾向於用一個入口,因為主要操作的是上下文,而且只需要一個入口,內部邏輯種類無法控制。所以可能會這樣寫:

export default (context: Context) => { // context.sourceFiles.xx};

舉例:fisgulpwebpackegg

2.1.2 事件插件化

顧名思義,通過事件的方式提供插件開發的能力。

這種方式的框架之間跨界更大,比如 dom 事件:

document.on("focus", callback);

雖然只是普通的業務代碼,但這本質上就是插件機制:

  • 可拓展:可以重複定義 N 個 focus 事件相互獨立。
  • 事件相互獨立:每個 callback 之間互相不受影響。

也可以解釋為,事件機制就是在一些階段放出鉤子,允許用戶代碼拓展整體框架的生命周期。

service worker 就更明顯,業務代碼幾乎完全由一堆時間監聽構成,比如 install 時機,隨時可以新增一個監聽,將 install 時機進行 delay,而不需要侵入其他代碼。

在事件機制玩出花樣的應該算 koa 了,它的中間件洋蔥模型非常有名,換個角度理解,可以認為是能控制執行時機的事件插件化,也就是只要想把執行時機放在所有事件執行完畢時,把代碼放在 next() 之後即可,如果想終止插件執行,可以不調用 next()

舉例:koaservice workerdom events

2.1.3 插槽插件化

這種插件化一般用在對 UI 元素的拓展。react 的內置數據流是符合組件物理結構的,而 redux 數據流是符合用戶定義的邏輯結構,那麼對於 html 布局來說也是一樣:html 默認布局是物理結構,那插槽布局方式就是 html 中的 redux。

正常 UI 組織邏輯是這樣的:

<div> <Layout> <Header> <Logo /> </Header> <Footer> <Help /> </Footer> </Layout></div>

插槽的組織方式是這樣的:

{ position: "root", View: <Layout>{insertPosition("layout")}</Layout>}{ position: "layout", View: [ <Header>{insertPosition("header")}</Header>, <Footer>{insertPosition("footer")}</Footer> ]}{ position: "header", View: <Logo />}{ position: "footer", View: <Help />}

這樣插件中的代碼可以不受物理結構的約束,直接插入到任何插入點。

更重要的是,實現了 UI 解耦,父元素就不需要知道子元素的具體實例。一般來說,決定一個組件狀態的都是其父元素而不是子元素,比如一個按鈕可能在 <ButtonGroup/> 中表現為一種組合態的樣式。但不可能說 <ButtonGroup/> 因為有了 <Select/>作為子元素,自身的邏輯而發生變化的。

這就意味著,父元素不需要知道子元素的實例,比如 Tabs:

<Tabs>{insertPosition(`tabs-${this.state.selectedTab}`)}</Tabs>

當然有些情況看似是例外,比如 Tree 的查詢功能,就依賴子元素 TreeNode 的配合。但它依賴的是基於某個約定的子元素,而不是具體子元素的實例,父級只需要與子元素約定介面即可。真正需要關心物理結構的恰恰是子元素,比如插入到 Tree 子元素節點的 TreeNode 必須實現某些方法,如果不滿足這個功能,就不要把組件放在 Tree 下面;而 Tree 的實現就無需顧及啦,只需要默認子元素有哪些約定即可。

舉例:gaea-editor

2.1.4 分型插件化

代表 egg,特點是插件結構與項目結構分型,也就是組成大項目的小插件,自身結構與項目結構相同。

因為對於 node server 的插件來說,要實現的功能應該是項目功能的子集,而本身 egg 對功能是按照目錄結構劃分的,所以插件的目錄結構與項目一致,看起來也很美觀。

舉例:egg

當然不是所有插件都能寫成目錄分形的,這也恰好解釋了 eggkoa 之間的關係:koa 是 node 框架,與項目結構無關,egg 是基於 koa 上層的框架,將項目結構轉化成 server 功能,而插件需要拓展的也是 server 功能,恰好可以用項目結構的方式寫插件。

2.2 核心代碼如何載入插件

一個支持插件化的框架,核心功能是整合插件以及定義生命周期,與功能相關的代碼反而可以通過插件實現,下一小節再展開說明。

2.2.1 確定插件載入形式

根據 2.1 節的描述,我們根據項目的功能,找到一個合適的插件使用方式,這會決定我們如何執行插件。

2.2.2 確定插件註冊方式

插件註冊方式非常多樣,這裡舉幾個例子:

通過 npm 註冊:比如只要 npm 包符合某個前綴,就會自動註冊為插件,這個很簡單,不舉例子了。

通過文件名註冊:比如項目中存在 xx.plugin.ts 會自動做到插件引用,當然這一般作為輔助方案使用。

通過代碼註冊:這個很基礎,就是通過代碼 require 就行,比如 babel-polyfill,不過這個要求插件執行邏輯正好要在瀏覽器運行,場景比較受限。

通過描述註冊:比如在 package.json 描述一個屬性,表明了要載入的插件,比如 .babelrc:

{ "presets": ["es2015"]}

自動註冊:比較暴力,通過遍歷可能存在的位置,只要滿足插件約定的,會自動註冊為插件。這個行為比較像 require 行為,會自動遞歸尋找 node_modules,當然別忘了像 require 一樣提供 paths 讓用戶手動配置定址起始路徑。

2.2.3 確定生命周期

確定插件註冊方式後,一般第一件事就是載入插件,後面就是根據框架業務邏輯不同而不同的生命周期了,插件在這些生命周期中扮演不同的功能,我們需要通過一些方式,讓插件能夠影響這些過程。

2.2.4 插件對生命周期的攔截

一般通過事件、回調函數的方式,支持插件對生命周期的攔截,最簡單的例子比如:

document.on("click", callback);

就是讓插件攔截了 click 這個事件,當然這個事件與 dom 的生命周期相比微乎其微,但也算是一個微小的生命周期,我們也可以 event.stopPropagation() 阻止冒泡,來影響這個生命周期的邏輯。

2.2.5 插件之間的依賴與通信

插件之間難免有依賴關係,目前有兩種方式處理,分為:依賴關係定義在業務項目中,與依賴關係定義在插件中

稍微解釋下,依賴關係定義在業務項目中,比如 webpack 的配置,我們在業務項目里是這麼配的:

{ "use": ["babel-loader", "ts-loader"]}

在 webpack 中,執行邏輯是 ts-loader -> babel-loader,當然這個規則由框架說了算,但總之插件載入執行肯定有個順序,而且與配置寫法有關,而且配置需要寫在項目中(至少不在插件中)。

另一種行為,將插件依賴寫在插件中,比如 webpack-preload-plugin 就是依賴 html-webpack-plugin

這兩種場景各不同,一個是業務有關的順序,也就是插件無法做主的業務邏輯問題,需要把順序交給業務項目配置;一種是插件內部順序,也就是業務無需關心的順序問題,由插件自己定義就好啦。注意框架核心一般可能要同時支持這兩種配置方式,最終決定插件的載入順序。

插件之間通信也可以通過 hook 或者 context 方式支持,hook 主要傳遞的是時機信息,而 context 主要傳遞的是數據信息,但最終是否能生效,取決於上面說到的插件載入順序。

context 可以拿 react 做個類比,一般都有作用域的,而且與執行順序嚴格相關。

hook 等於插件內部的一個事件機制,由一個插件註冊。業界有個比較好的實現,叫 tapable,這裡簡單介紹一下。

利用 tapable 在 A 插件註冊新 hook:

const SyncWaterfallHook = require("tapable").SyncWaterfallHook;compilation.hooks.htmlWebpackPluginAlterChunks = new SyncWaterfallHook([ "chunks", "objectWithPluginRef"]);

在 A 插件某個地方使用此 hook,實現某個特定業務邏輯。

const chunks = compilation.hooks.htmlWebpackPluginAlterChunks.call(chunks, { plugin: self});

B 插件可以拓展此 hook,來改變 A 的行為:

compilation.hooks.htmlWebpackPluginAlterChunks.tap( "HtmlWebpackIncludeSiblingChunksPlugin", chunks => { const ids = [] .concat(...chunks.map(chunk => [...chunk.siblings, chunk.id])) .filter(onlyUnique); return ids.map(id => allChunks[id]); });

這樣,A 拿到的 chunks 就被 B 修改掉了。

2.3 核心功能的插件化

2.2 開頭說到,插件化框架的核心代碼主要功能是對插件的載入、生命周期的梳理,以及實現 hook 讓插件影響生命周期,最後補充上插件的載入順序以及通信,就比較完備了。

那麼寫到這裡,衡量代碼質量的點就在於,是不是所有核心業務邏輯都可以由插件完成?因為只有用插件實現核心業務邏輯,才能檢驗插件的能力,進而推導出第三方插件是否擁有足夠的拓展能力。

如果核心邏輯中有一部分代碼沒有通過插件機制編寫,不僅讓第三方插件也無法拓展此邏輯,而且還不利於框架的維護。

所以這主要是個思想,希望開發者首先明確哪些功能應該做成插件,以及將哪些插件固化為內置插件。

筆者認為應該提前思考清楚三點:

2.3.1 哪些插件需要內置

這個是業務相關的問題,但總體來看,開源的,基礎功能以及體現核心競爭力的可以內置,可以開源與核心競爭力都比較好理解,主要說下基礎功能:

基礎功能就是一個業務的架子。因為插件機制的代碼並不解決任何業務問題,一個沒有內置插件的框架肯定什麼都不是,所以選擇基礎功能就尤為重要。

舉個例子,比如做構建工具,至少要有一個基本的配置作為模版,其他插件通過拓展這個配置來修改構建效果。那麼這個基本配置就決定了其他插件可以如何修改它,也決定了這個框架的配置基調。

比如:create-react-app 對 dev 開發時的模版配置。如果沒有這個模版,本地就無法開發,所以這個插件必須內置,而且需要考慮如何讓其他插件對其拓展,這個在 2.3.2 節詳細說明。

另一種情況就是非常基本,而又不需要再拓展加工的可以做成內置插件,比如 babel 對 js 模塊的 commonjs 分析邏輯就不需要暴露出來,因為這個標準已經確定,既不需要拓展,又是 babel 運行的基礎,所以肯定要內置。

2.3.2 插件是依賴型還是完全正交的

功能完全正交的插件是最完美的,因為它既不會影響其他插件,也不需要依賴任何插件,自身也不需要被任何插件拓展。

在寫非正交功能的插件時就要擔心了,我們還是分為三個點去看:

2.3.2.1 依賴其他插件的插件

舉個例子,比如插件 X 需要拓展命令行,在執行 npm start 時統計當前用戶信息並打點。那麼這個插件就要知道當前登陸用戶是誰。這個功能恰好是另一個 「用戶登陸」 插件完成的,那麼插件 X 就要依賴 「用戶登陸」 插件了。

這種情況,根據 2.2.5 插件依賴小節經驗,需要明確這個插件是插件級別依賴,還是項目級別依賴。

當然,這種情況是插件級別依賴,我們把依賴關係定義在插件 X 中即可,比如 package.json:

"plugin-dep": ["user-login"]

另一種情況,比如我們寫的是 babel-loader 插件,它在 ts 項目中依賴 ts-loader,那隻能在項目中定義依賴了,此時需要補充一些文檔說明 ts 場景的使用順序。

2.3.2.2 依賴並拓展其他插件的插件

如果插件 X 在以來 「用戶登陸」 插件的基礎上,還要拓展登陸時獲取的用戶信息,比如要同時獲取用戶的手機號,而 「用戶登陸」 插件默認並沒有獲取此信息,但可以通過擴展方式實現,插件 X 需要注意什麼呢?

首先插件 X 最好不要減少另一個插件的功能(具體拓展方式,參考 2.2.5 節,這裡假設插件都比較具有可拓展性),否則插件 X 可能破壞 「用戶登錄」 插件與其他插件之間的協作。

減少功能的情況非常普遍,為了加深理解,這裡舉一個例子:某個插件直接 pipeTemplate 拓展模版內容,但插件 X 直接返回了新內容,而沒有 concat 原有內容,就是減少了功能。

但也不是所有情況都要保證不減少功能,比如當缺少必要的配置項時,可以直接拋出異常,提前終止程序。

其次,要確保增加的功能儘可能少的與其他插件產生可能的衝突。拿拓展 webpack 配置舉例,現在要拓展對 node_modules js 文件的處理,讓這些文件過一遍 babel。

不好的做法是直接修改原有對 js 的 rules,增加一項對 node_modules 的 include,以及 babel-loader。因為這樣會破壞原先插件對項目內 js 文件的處理,可能項目的 js 文件不需要 babel 處理呢?

比較好的做法是,新增一個 rules,單獨對 node_modules 的 js 文件處理,不要影響其他規則。

2.3.2.3 可能被其他插件拓展的插件

這點是最難的,難在如何設計拓展的粒度。

由於所有場景都類似,我們拿對模版的拓展舉例子,其他場景可以類比:插件 X 定義了入口文件的基礎內容,但還要提供一些 hook 供其他插件修改入口文件。

假設入口文件一般是這樣的:

import * as React from "react";import * as ReactDOM from "react-dom";import { App } from "./app";ReactDOM.render(<App />, document.getELementById("root"));

這種最簡單的模版,其實內部要考慮以下幾點潛在拓展需求:

  1. 在某處需要插入其他代碼,怎麼支持?
  2. 如何保證插入代碼的順序?
  3. 用 react-lite 替換 react,怎麼支持?
  4. dev 模式需要用 hot(App) 替換 App 作為入口,怎麼支持?
  5. 模版入口 div 的 id 可能不是 root,怎麼支持?
  6. 模版入口 div 是自動生成的,怎麼支持?
  7. 用在 reactNative,沒有 document,怎麼支持?
  8. 後端渲染時,需要用 ReactDOM.hydrate 而不是 ReactDOM.render,怎麼支持?
  9. 以上 8 種場景可能會不同組合,需要保證任意組合都能正確運行,所以無法全量模版替換,那怎麼辦?

筆者此處給出一種解決方案,供大家參考。另外要注意,這個方案隨著考慮到的使用場景增多,是要不斷調整變化的。

get( "entry", ` ${get("importBefore", "")} ${get("importReact", `import * as React from "react"`)} ${get("importReactDOM", `import * as ReactDOM from "react-dom"`)} import { App } from "./app" ${get("importAfter", "")} ${get("renderMethod", `ReactDOM.render`)}(${get( "renderApp", "<App/>" )}, ${get("rootElement", `document.getELementById("root")`)}) ${get("renderAfter", "")}`);

以上八種情況讀者腦補一下,不詳細說明了。

2.3.3 內置插件如何與第三方插件相處

內置的插件與第三方插件的衝突點在於,內置插件如果拓展性很差,那還不如不要內置,內置了反而阻礙第三方插件的拓展。

所以參考 2.3.2.3 節,為內置插件考慮最大化的拓展機制,才能確保內置插件的功能不會變成拓展性瓶頸。

每新增一個內置的插件,都在消滅一部分拓展能力,因為由插件拓展後的區塊擁有的拓展能力,應該是逐漸減弱的。這裡比較拗口,可以比喻為,一條小溪流,插件就是層層的水處理站,每新增一個處理站就會改變下游水勢變化,甚至可能將水攔住,下游一滴水也拿不到。

2.3.1 節說了哪些插件需要內置,而這一節想說明的是,謹慎增加內置插件數量,因為內置的越多,框架拓展能力就越弱。

2.4 哪些場景可以插件化

最後梳理下插件化適用場景,筆者根據有限的經驗列出一下一些場景。

2.4.1 前後端框架

如果你要做一個前/後端開發框架,插件化是必然,比如 react 的生命周期,koa 的中間件,甚至業務代碼用到的 request 處理,都是插件化的體現。

2.4.2 腳手架

支持插件化的腳手架具有拓展性,社區方便提供插件,而且腳手架為了適配多種代碼,功能可插拔是非常重要的。

2.4.3 工具庫

一些小的工具庫,比如管理數據流的 redux 提供的中間件機制,就是讓社區貢獻插件,完善自身的功能。

2.4.4 需要多人協同的複雜業務項目

如果業務項目很複雜,同時又有多人協作完成,最好按照功能劃分來分工。但是分工如果只是簡單的文件目錄分配方式,必然導致功能的不均勻,也就是每個人開發的模塊可能不能訪問所有系統能力,或者涉及到與其他功能協同時,文件相互引用帶來代碼的耦合度提高,最終導致難以維護。

插件化給這種項目帶來的最大優勢就是,每一個人開發的插件都是一個擁有完整功能的個體,這樣只需要關心功能的分配,不用擔心局部代碼功能不均衡;插件之間的調用框架層已經做掉了,所以協同不會發生耦合,只需要申明好依賴關係。

插件化機制良好的項目開發,和 git 功能分支開發的體驗有相似之處,git 給每個功能或需求開一個分支,而插件化可以讓每個功能作為一個插件,而 git 功能分支之間是無關聯的,所以只有功能之間正交的需求才能開多個分支,而插件機制可以考慮到依賴情況,進行更複雜的功能協同。

3 總結

現在還沒有找到對插件化系統化思考的文章,所以這一篇算是拋磚引玉,大家一定有更多的框架開發心得值得分享。

同時也想借這篇文章提高大家對插件化必要性的重視,許多情況插件化並不是小題大做,因為它能帶來更好的分工協作,而分工的重要性不言而喻。

4 更多討論

討論地址是:精讀《插件化思維》 · Issue #75 · dt-fe/weekly

如果你想參與討論,請點擊這裡,每周都有新的主題,周末或周一發布。


推薦閱讀:

我理解的前端性能 & 優化
七層網路協議--tcp/ip協議
自學編程,從【前端開發】開始。
再談前端虛擬列表的實現
看別人吵架對你來說應該是好事兒

TAG:前端開發 | 軟體架構 |