本週精讀內容是 《插件化思惟》。沒有參考文章,資料源自 webpack、fis、egg 以及筆者自身開發經驗。html
用過構建工具的同窗都知道,grunt
, webpack
, gulp
都支持插件開發。後端框架好比 egg
koa
都支持插件機制拓展,前端頁面也有許多可拓展性的要求。插件化無處不在,全部的框架都但願自身擁有最強大的可拓展能力,可維護性,並且都選擇了插件化的方式達到目標。前端
我認爲插件化思惟是一種極客精神,並且大量可拓展、須要協同開發的程序都離不開插件機制支撐。node
沒有插件化,核心庫的代碼會變得冗餘,功能耦合愈來愈嚴重,最後致使維護困難。插件化就是將不斷擴張的功能分散在插件中,內部集中維護邏輯,這就有點像數據庫橫向擴容,結構不變,拆分數據。react
理想狀況下,咱們都但願一個庫,或者一個框架具備足夠的可拓展性。這個可拓展性體如今這三個方面:webpack
咱們都清楚插件化應該能解決問題,但從哪下手呢?這就是筆者但願分享給你們的經驗。git
作技術設計時,最好先從使用者角度出發,當設計出舒服的調用方式時,再去考慮實現。因此咱們先從插件使用者角度出發,看看能夠提供哪些插件使用方式給開發者。github
插件化許多都是從設計模式演化而來的,大概能夠參考的有:命令模式,工廠模式,抽象工廠模式等等,筆者根據我的經驗,總結出三種插件化形式:web
最後還有一個不算插件化實現方式,但效果比較優雅,姑且稱爲分形插件化吧。下面一一解釋。typescript
按照某個約定來設計插件,這個約定通常是:入口文件/指定文件名做爲插件入口,文件形式.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
。
顧名思義,經過事件的方式提供插件開發的能力。
這種方式的框架之間跨界更大,好比 dom 事件:
document.on("focus", callback);
複製代碼
雖然只是普通的業務代碼,但這本質上就是插件機制:
也能夠解釋爲,事件機制就是在一些階段放出鉤子,容許用戶代碼拓展總體框架的生命週期。
service worker
就更明顯,業務代碼幾乎徹底由一堆時間監聽構成,好比 install
時機,隨時能夠新增一個監聽,將 install
時機進行 delay,而不須要侵入其餘代碼。
在事件機制玩出花樣的應該算 koa
了,它的中間件洋蔥模型很是有名,換個角度理解,能夠認爲是能控制執行時機的事件插件化,也就是隻要想把執行時機放在全部事件執行完畢時,把代碼放在 next()
以後便可,若是想終止插件執行,能夠不調用 next()
。
舉例:
koa
、service worker
、dom events
。
這種插件化通常用在對 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
。
表明 egg,特色是插件結構與項目結構分型,也就是組成大項目的小插件,自身結構與項目結構相同。
由於對於 node server 的插件來講,要實現的功能應該是項目功能的子集,而自己 egg 對功能是按照目錄結構劃分的,因此插件的目錄結構與項目一致,看起來也很美觀。
舉例:
egg
。
固然不是全部插件都能寫成目錄分形的,這也剛好解釋了 egg
與 koa
之間的關係:koa
是 node 框架,與項目結構無關,egg
是基於 koa
上層的框架,將項目結構轉化成 server 功能,而插件須要拓展的也是 server 功能,剛好能夠用項目結構的方式寫插件。
一個支持插件化的框架,核心功能是整合插件以及定義生命週期,與功能相關的代碼反而能夠經過插件實現,下一小節再展開說明。
根據 2.1 節的描述,咱們根據項目的功能,找到一個合適的插件使用方式,這會決定咱們如何執行插件。
插件註冊方式很是多樣,這裏舉幾個例子:
經過 npm 註冊:好比只要 npm 包符合某個前綴,就會自動註冊爲插件,這個很簡單,不舉例子了。
經過文件名註冊:好比項目中存在 xx.plugin.ts
會自動作到插件引用,固然這通常做爲輔助方案使用。
經過代碼註冊:這個很基礎,就是經過代碼 require
就行,好比 babel-polyfill
,不過這個要求插件執行邏輯正好要在瀏覽器運行,場景比較受限。
經過描述註冊:好比在 package.json
描述一個屬性,代表了要加載的插件,好比 .babelrc
:
{
"presets": ["es2015"]
}
複製代碼
自動註冊:比較暴力,經過遍歷可能存在的位置,只要知足插件約定的,會自動註冊爲插件。這個行爲比較像 require
行爲,會自動遞歸尋找 node_modules
,固然別忘了像 require
同樣提供 paths
讓用戶手動配置尋址起始路徑。
肯定插件註冊方式後,通常第一件事就是加載插件,後面就是根據框架業務邏輯不一樣而不一樣的生命週期了,插件在這些生命週期中扮演不一樣的功能,咱們須要經過一些方式,讓插件可以影響這些過程。
通常經過事件、回調函數的方式,支持插件對生命週期的攔截,最簡單的例子好比:
document.on("click", callback);
複製代碼
就是讓插件攔截了 click
這個事件,固然這個事件與 dom 的生命週期相比微乎其微,但也算是一個微小的生命週期,咱們也能夠 event.stopPropagation()
阻止冒泡,來影響這個生命週期的邏輯。
插件之間不免有依賴關係,目前有兩種方式處理,分爲:依賴關係定義在業務項目中,與依賴關係定義在插件中。
稍微解釋下,依賴關係定義在業務項目中,好比 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.2 開頭說到,插件化框架的核心代碼主要功能是對插件的加載、生命週期的梳理,以及實現 hook 讓插件影響生命週期,最後補充上插件的加載順序以及通訊,就比較完備了。
那麼寫到這裏,衡量代碼質量的點就在於,是否是全部核心業務邏輯均可以由插件完成?由於只有用插件實現核心業務邏輯,才能檢驗插件的能力,進而推導出第三方插件是否擁有足夠的拓展能力。
若是核心邏輯中有一部分代碼沒有經過插件機制編寫,不只讓第三方插件也沒法拓展此邏輯,並且還不利於框架的維護。
因此這主要是個思想,但願開發者首先明確哪些功能應該作成插件,以及將哪些插件固化爲內置插件。
筆者認爲應該提早思考清楚三點:
這個是業務相關的問題,但整體來看,開源的,基礎功能以及體現核心競爭力的能夠內置,能夠開源與核心競爭力都比較好理解,主要說下基礎功能:
基礎功能就是一個業務的架子。由於插件機制的代碼並不解決任何業務問題,一個沒有內置插件的框架確定什麼都不是,因此選擇基礎功能就尤其重要。
舉個例子,好比作構建工具,至少要有一個基本的配置做爲模版,其餘插件經過拓展這個配置來修改構建效果。那麼這個基本配置就決定了其餘插件能夠如何修改它,也決定了這個框架的配置基調。
好比:create-react-app
對 dev 開發時的模版配置。若是沒有這個模版,本地就沒法開發,因此這個插件必須內置,並且須要考慮如何讓其餘插件對其拓展,這個在 2.3.2 節詳細說明。
另外一種狀況就是很是基本,而又不須要再拓展加工的能夠作成內置插件,好比 babel
對 js 模塊的 commonjs
分析邏輯就不須要暴露出來,由於這個標準已經肯定,既不須要拓展,又是 babel 運行的基礎,因此確定要內置。
功能徹底正交的插件是最完美的,由於它既不會影響其餘插件,也不須要依賴任何插件,自身也不須要被任何插件拓展。
在寫非正交功能的插件時就要擔憂了,咱們仍是分爲三個點去看:
舉個例子,好比插件 X 須要拓展命令行,在執行 npm start
時統計當前用戶信息並打點。那麼這個插件就要知道當前登錄用戶是誰。這個功能剛好是另外一個 「用戶登錄」 插件完成的,那麼插件 X 就要依賴 「用戶登錄」 插件了。
這種狀況,根據 2.2.5 插件依賴小節經驗,須要明確這個插件是插件級別依賴,仍是項目級別依賴。
固然,這種狀況是插件級別依賴,咱們把依賴關係定義在插件 X 中便可,好比 package.json
:
"plugin-dep": ["user-login"]
複製代碼
另外一種狀況,好比咱們寫的是 babel-loader
插件,它在 ts 項目中依賴 ts-loader
,那隻能在項目中定義依賴了,此時須要補充一些文檔說明 ts 場景的使用順序。
若是插件 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 文件處理,不要影響其餘規則。
這點是最難的,難在如何設計拓展的粒度。
因爲全部場景都相似,咱們拿對模版的拓展舉例子,其餘場景能夠類比:插件 X 定義了入口文件的基礎內容,但還要提供一些 hook 供其餘插件修改入口文件。
假設入口文件通常是這樣的:
import * as React from "react";
import * as ReactDOM from "react-dom";
import { App } from "./app";
ReactDOM.render(<App />, document.getELementById("root"));
複製代碼
這種最簡單的模版,其實內部要考慮如下幾點潛在拓展需求:
hot(App)
替換 App
做爲入口,怎麼支持?root
,怎麼支持?ReactDOM.hydrate
而不是 ReactDOM.render
,怎麼支持?筆者此處給出一種解決方案,供你們參考。另外要注意,這個方案隨着考慮到的使用場景增多,是要不斷調整變化的。
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.2.3 節,爲內置插件考慮最大化的拓展機制,才能確保內置插件的功能不會變成拓展性瓶頸。
每新增一個內置的插件,都在消滅一部分拓展能力,由於由插件拓展後的區塊擁有的拓展能力,應該是逐漸減弱的。這裏比較拗口,能夠比喻爲,一條小溪流,插件就是層層的水處理站,每新增一個處理站就會改變下游水勢變化,甚至可能將水攔住,下游一滴水也拿不到。
2.3.1 節說了哪些插件須要內置,而這一節想說明的是,謹慎增長內置插件數量,由於內置的越多,框架拓展能力就越弱。
最後梳理下插件化適用場景,筆者根據有限的經驗列出一下一些場景。
若是你要作一個前/後端開發框架,插件化是必然,好比 react
的生命週期,koa
的中間件,甚至業務代碼用到的 request 處理,都是插件化的體現。
支持插件化的腳手架具備拓展性,社區方便提供插件,並且腳手架爲了適配多種代碼,功能可插拔是很是重要的。
一些小的工具庫,好比管理數據流的 redux 提供的中間件機制,就是讓社區貢獻插件,完善自身的功能。
若是業務項目很複雜,同時又有多人協做完成,最好按照功能劃分來分工。可是分工若是隻是簡單的文件目錄分配方式,必然致使功能的不均勻,也就是每一個人開發的模塊可能不能訪問全部系統能力,或者涉及到與其餘功能協同時,文件相互引用帶來代碼的耦合度提升,最終致使難以維護。
插件化給這種項目帶來的最大優點就是,每個人開發的插件都是一個擁有完整功能的個體,這樣只須要關心功能的分配,不用擔憂局部代碼功能不均衡;插件之間的調用框架層已經作掉了,因此協同不會發生耦合,只須要申明好依賴關係。
插件化機制良好的項目開發,和 git 功能分支開發的體驗有類似之處,git 給每一個功能或需求開一個分支,而插件化可讓每一個功能做爲一個插件,而 git 功能分支之間是無關聯的,因此只有功能之間正交的需求才能開多個分支,而插件機制能夠考慮到依賴狀況,進行更復雜的功能協同。
如今尚未找到對插件化系統化思考的文章,因此這一篇算是拋磚引玉,你們必定有更多的框架開發心得值得分享。
同時也想借這篇文章提升你們對插件化必要性的重視,許多狀況插件化並非小題大作,由於它能帶來更好的分工協做,而分工的重要性不言而喻。
若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。