精讀《插件化思惟》

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

1 引言

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

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

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

2 精讀

理想狀況下,咱們都但願一個庫,或者一個框架具備足夠的可拓展性。這個可拓展性體如今這三個方面:webpack

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

咱們都清楚插件化應該能解決問題,但從哪下手呢?這就是筆者但願分享給你們的經驗。git

作技術設計時,最好先從使用者角度出發,當設計出舒服的調用方式時,再去考慮實現。因此咱們先從插件使用者角度出發,看看能夠提供哪些插件使用方式給開發者。github

2.1 插件化分類

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

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

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

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

若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。

相關文章
相關標籤/搜索