本週精讀內容是 《外掛化思維》。沒有參考文章,資料源自 webpack、fis、egg 以及筆者自身開發經驗。
1 引言
用過構建工具的同學都知道,grunt
, webpack
, gulp
都支援外掛開發。後端框架比如 egg
koa
都支援外掛機制擴充,前端頁面也有許多可擴充性的要求。外掛化無處不在,所有的框架都希望自身擁有最強大的可擴充能力,可維護性,而且都選擇了外掛化的方式達到目標。
我認為外掛化思維是一種極客精神,而且大量可擴充、需要協同開發的程式都離不開外掛機制支撐。
沒有外掛化,核心庫的程式碼會變得冗餘,功能耦合越來越嚴重,最後導致維護困難。外掛化就是將不斷擴張的功能分散在外掛中,內部集中維護邏輯,這就有點像資料庫橫向擴容,結構不變,拆分資料。
2 精讀
理想情況下,我們都希望一個庫,或者一個框架具有足夠的可擴充性。這個可擴充性體現在這三個方面:
- 讓社群可以貢獻程式碼,而且即使程式碼存在問題,也不會影響核心程式碼的穩定性。
- 支援二次開發,滿足不同業務場景的特定需求。
- 讓程式碼以功能為緯度聚合起來,而不是某個片面的邏輯結構,在程式碼數量龐大的場景尤為重要。
我們都清楚外掛化應該能解決問題,但從哪下手呢?這就是筆者希望分享給大家的經驗。
做技術設計時,最好先從使用者角度出發,當設計出舒服的呼叫方式時,再去考慮實現。所以我們先從外掛使用者角度出發,看看可以提供哪些外掛使用方式給開發者。
2.1 外掛化分類
外掛化許多都是從設計模式演化而來的,大概可以參考的有:命令模式,工廠模式,抽象工廠模式等等,筆者根據個人經驗,總結出三種外掛化形式:
- 約定/注入外掛化。
- 事件外掛化。
- 插槽外掛化。
最後還有一個不算外掛化實現方式,但效果比較優雅,姑且稱為分形外掛化吧。下面一一解釋。
2.1.1 約定/注入外掛化
按照某個約定來設計外掛,這個約定一般是:入口檔案/指定檔名作為外掛入口,檔案形式.json/.ts 不等,只要返回的物件按照約定名稱書寫,就會被載入,並可以拿到一些上下文。
舉例來說,比如只要專案的 package.json
的 apollo
存在 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
};
複製程式碼
舉例:
fis
、gulp
、webpack
、egg
。
2.1.2 事件外掛化
顧名思義,通過事件的方式提供外掛開發的能力。
這種方式的框架之間跨界更大,比如 dom 事件:
document.on("focus", callback);
複製程式碼
雖然只是普通的業務程式碼,但這本質上就是外掛機制:
- 可擴充:可以重複定義 N 個 focus 事件相互獨立。
- 事件相互獨立:每個 callback 之間互相不受影響。
也可以解釋為,事件機制就是在一些階段放出鉤子,允許使用者程式碼擴充整體框架的生命週期。
service worker
就更明顯,業務程式碼幾乎完全由一堆時間監聽構成,比如 install
時機,隨時可以新增一個監聽,將 install
時機進行 delay,而不需要侵入其他程式碼。
在事件機制玩出花樣的應該算 koa
了,它的中介軟體洋蔥模型非常有名,換個角度理解,可以認為是能控制執行時機的事件外掛化,也就是隻要想把執行時機放在所有事件執行完畢時,把程式碼放在 next()
之後即可,如果想終止外掛執行,可以不呼叫 next()
。
舉例:
koa
、service worker
、dom 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
。
當然不是所有外掛都能寫成目錄分形的,這也恰好解釋了 egg
與 koa
之間的關係: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"));
複製程式碼
這種最簡單的模版,其實內部要考慮以下幾點潛在擴充需求:
- 在某處需要插入其他程式碼,怎麼支援?
- 如何保證插入程式碼的順序?
- 用 react-lite 替換 react,怎麼支援?
- dev 模式需要用
hot(App)
替換App
作為入口,怎麼支援? - 模版入口 div 的 id 可能不是
root
,怎麼支援? - 模版入口 div 是自動生成的,怎麼支援?
- 用在 reactNative,沒有 document,怎麼支援?
- 後端渲染時,需要用
ReactDOM.hydrate
而不是ReactDOM.render
,怎麼支援? - 以上 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 更多討論
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。