精讀《外掛化思維》

黃子毅發表於2019-03-04

本週精讀內容是 《外掛化思維》。沒有參考文章,資料源自 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

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。

相關文章