前端領域的插件式設計
插件,是一個常見的概念。
例如,當我們需要把我們前端代碼中的 css 樣式提取打包,我們可以用 webpack 的 mini-css-extract-plugin,或者你如果用 rollup 的話,可以選擇 rollup-plugin-postcss。
再比如我們可以給 babel 配置 @babel/plugin-proposal-decorators 插件來支持裝飾器語法;
除了上述打包編譯相關的工具,我們使用的代碼編輯器也都支持各式各樣的插件,動態地給軟件增加各種能力,例如通過 Prettier 插件來是 VsCode 支持 Prettier 的代碼格式化,或者安裝主題插件來改變軟件樣式等。
除此之外,一些前端領域的框架也都有插件的機制,譬如 Vue,DvaJS,Eggjs 等。React 也有一些插件化開發的框架,例如 DevExtreme Reactive (以下簡稱 DR),React Pluggable。
認識插件
那什麼是插件呢?
所謂插件(Plug-in / Plugin),是一種可以把某些能力或特性添加到某個已有主體程序的程序,它通常遵循一定的編寫規範,並只能運行在特定的主體程序中。
插件的設計能帶來許多好處,例如
-
它可以極大地提升軟件的可擴展性。很多工程工具都提供了插件能力給開發者,藉助社區力量基於插件擴展各種原本不具備的能力,從而極大地提升了生命力;
-
它可以讓主體程序和插件代碼解耦,保持主體程序的穩定。可以想象如果 webpack 不是通過插件來擴展能力,那當我們需要某個當前版本不具備的能力時,只能不斷地升級 webpack,而這種升級則很容易引入不穩定因素。
-
它可以幫助我們控制主體程序複雜度。藉助插件,我們可以很好地把能力分而治之,化整爲零,從而有效地控制系統整體的複雜度。
-
它可以幫助我們控制程序體積,做到按需引用。由於插件是可以獨立地動態加載,我們可以針對性地選擇我們需要的插件能力。我們也可以設想,VsCode 如果是把各種能力都由自己完成,其軟件大小會是怎樣的規模(其實也不會是現在的形態了,比如針對各個編程領域提供一個應用包)。
雖然上面提到的軟件都有支持插件,但也各有特點。
從插件的深入程度上來說
-
一些程序插件就是其核心機制,其程序主體相對精簡,大部分能力都是依賴插件來擴充的。例如 webpack 、babel 等大部分代碼都是插件,主體主要是軟件生命週期調配,狀態流轉等,以及少量核心能力的實現。再比如 https://github.com/DevExpress/devextreme-reactive 中提供的幾個複雜 React 組件也基本上是一個一個插件來完成的。
-
相比之下,另一些程序的插件更多是對其能力上的一個補充,其插件能完成的工作相對有限(主體程序暴露的給插件的能力較少),但能很好地完成某些場景的擴展。
而從插件面向的開發者來說,也有幾種方式
-
對主體程序開發和第三方開發者一視同仁,都以相同的插件機制來擴展能力。這類程序通常也具備上面提到的插件作爲核心機制的特徵,例如 webpack 就是典範。個人認爲這種機制可以提供能力強大的插件機制,但同時也模糊了兩類開發者的界限,使得上手開發插件的門檻相對較高,甚至很多時候需要了解原本系統提供的插件實現。
-
插件機制是爲了方便主體程序開發者,不對第三方開發者暴露。https://github.com/DevExpress/devextreme-reactive 的 PivotGrid 底層是基於插件化的框架,但是暴露給 PivotGrid 開發者的屬性則基本沒有插件的痕跡,已經做了封裝。
-
插件是爲了第三方開發者擴展系統能力。這類主體程序通常不依賴插件機制來實現特定功能,插件機制只是爲了提供擴展能力,通常這類插件機制提供的能力也相對特化。
插件化設計的改造案例
除了我們去使用一個一個的插件,我們也可以把插件化的設計引入到我們自己的系統中,下面拋磚引玉,按照筆者的理解介紹一些基本的設計思路,當然插件的設計方式並非固定的,我們也不應當公式化地套用模式,核心在於體會其設計思想。
當我們設計一個插件系統時,我們要考慮幾個問題:
-
程序中哪些是易變的,哪些是相對穩定的。易變的部分應暴露出相應的能力由插件來完成。
-
插件如何影響程序。通常會以擴展行爲,修改狀態,變更展示的方式體現。
設想一個這樣一個例子,我們有一個簡易的計算器程序,支持加減法:
class Calculator {
construct(initial) {
this.num = initial;
}
add(num) {
this.num = this.num + num;
return this;
}
subtract(num) {
this.num = this.num - num;
return this;
}
result() {
return this.num;
}
}
const myCalculator = new Calculator(5);
myCalculator.add(5).subtract(2).result(); // 8
可以很容易想到,計算器的主要抽象是運算,即當前值和一個新值的運算過程,這部分是穩定的,而支持的運算邏輯是可擴展的,適合做成插件,因此我們可以做如下的改造:
// 程序主體,定義程序核心邏輯是增加計算器運算能力
class Calculator {
plugins = [];
construct(initial) {
this.num = initial;
}
use(plugin) {
this.plugins.push(plugin);
this[plugin.name] = plugin.calculate.bind(this);
}
result() {
return this.num;
}
}
// 插件聲明
interface Plugin {
name: string;
calculate(num: number) => this;
}
// 插件實現
class AddPlugin implements Plugin {
name: 'add',
calculate(num) {
this.num = this.num + num;
return this;
}
}
class SubtractPlugin implements Plugin {
name: 'subtract',
calculate(num) {
this.num = this.num - num;
return this;
}
}
const myCalculator = new Calculator(5);
// 插件安裝
myCalculator.use(new AddPlugin());
myCalculator.use(new SubtractPlugin());
myCalculator.add(5).subtract(2).result(); // 8
經過這個改造,未來如果要實現乘法,我們只需要新增一個插件實現即可,無需修改程序主體:
class MultiplicatiPlugin implements Plugin {
name: 'multiplicati',
calculate(num) {
this.num = this.num * num;
return this;
}
}
再比如我們要增加一個 help 來打印支持的計算也可以快速實現:
class Calculator {
...
help() {
return `support ${this.plugins.map(plugin => plugin.name).join(',')}`;
}
}
通過上面的例子,從插件的角度可以分成幾個部分:
-
程序主體(Program),即上面的 Calculator;
-
插件接口聲明(Plugin Interface),即上面的 Plugin;
-
插件實現(Plugin Implementation),即 AddPlugin,SubtractPlugin,MultiplicatiPlugin;
在一些更復雜的例子裏還會有 Plugin Loader 用於加載和管理 plugin。
插件設計的案例分享
我們已經對插件設計有了一個基本的認識,我們再來看看一些開源庫的插件設計案例
Webpack
webpack 核心模塊爲 compiler 和 compilation,他們都有各自的聲明週期鉤子(Hook),插件開發者可以藉助這些 Hook 來完成各種能力,因此 webpack 核心便是定義聲明週期(或者叫事件流),並在各個聲明週期中調用插件在對應聲明週期註冊的方法。不過在同一個生命週期註冊了多個事件時,我們還需要關注幾個問題:
-
同步執行還是異步執行;
-
並行執行還是串行;
-
如果執行會產生結果,那麼對其他 Hook 的結果會產生什麼影響;
上述問題 webpack 封裝了一個 Hook 的核心庫 Tapable, compiler 和 compilation 都是基於 Tapable 的實現,根據上面的維度提供了不同的 Hook 類:
網上有很多很不錯的專門介紹 Tapable 文章,這裏就不繼續展開介紹,但我們可以對其有個感性的認識,實際上可以把它看做加強版的 EventEmitter。不僅 webpack 會面臨這個問題,另一個構建工具 rollup 也能看到類似的模塊來解決這類問題,在其代碼中有一個 PluginDriver 模塊(https://github.com/rollup/rollup/blob/master/src/utils/PluginDriver.ts)就是起類似的作用。
DevExtreme Reactive
最後再來聊一聊一個比較有意思的 React 插件化框架 DevExtreme Reactive。
DevExtreme Reactive (以下簡稱 DR)是 DevExpress 公司開發的一個開源 React 組件庫,目前包含了 Grid / Chart / Scheduler 三個複雜組件,這三個組件都是基於一個插件化框架進行開發的。
一個插件的例子如下:
import { PluginHost, Plugin, Template, Getter, Action } from '@devexpress/dx-react-core';
export default function PluginRoot(props) {
const { chidren } = props;
return <PluginHost>{children}</PluginHost>;
}
export function FeatureA() {
return (
<Plugin >
<Template >...</Template>
<Getter />
<Action />
</Plugin>
);
}
export function FeatureB() {
return (
<Plugin >
<Template>...</Template>
<Getter />
<Action />
</Plugin>
);
}
// App.jsx
import PluginRoot, { FeatrueA, FeatureB, ... FeatureN } from './MyComponnent;'
const App = () => (
<PluginRoot>
<FeatureA />
<FeatureB />
...
<FeatrueN />
</PluginRoot>
);
ReactDOM.render(<App />, rootNode);
其中:
-
PluginHost 是主體程序入口,用於定義主體程序基本組成。
-
Plugin 是插件的根節點。
-
Getter 用於定義或複寫一個狀態,在程序中共享(可以用於自己,或其他插件消費)。即修改狀態。
-
Action 用於定義或複寫一個操作在程序中共享。即擴展行爲。
-
Template 可以用來定義或複寫一個展示片段,同樣可以被自己,或其他插件的 Template 消費。即變更展示。需要注意的是,Template 只是定義,真正生效取決於某個 Template 引用這個具名片段。和上面介紹的構建工具不同,作爲一個組件的插件化框架,展示的插件化也是一個重要的課題,而 Template 就是這個能力的核心。
插件的各個能力和註冊方式都採用組件形式引用,充分契合 React 書寫方式。
TodoApp
當然這些介紹還是不太直觀,我們來看一個具體的例子。
例如我們要做一個 Todo 應用,我們可以如下實現
import { PluginHost, Plugin, Template, Getter, Action } from '@devexpress/dx-react-core';
const TodoApp = ({ children }) => {
return (
<PluginHost>
<TodoCore />
{/* ========= plugins ========= */}
<TodoHeader />
<TodoList />
<TodoStore />
</PluginHost>
)
}
const TodoCore = () => {
return (
<Plugin>
{/* 基本的狀態管理 */}
<TodoStore />
<Template >
{/* 定義佈局 */}
<section class>
<TemplatePlaceholder />
<TemplatePlaceholder />
</section>
</Template>
</Plugin>
);
};
const TodoHeader = () => {...}
const TodoList = () => {...}
const App = () => {
return (
<TodoApp />
)
}
ReactDOM.render(<App />, rootNode);
如果我們需要增加一個功能支持設置完成,我們可以通過編寫如下的插件來支持:
// TodoCompletable.tsx
const TodoCompletable = () => {
...
return (
<Plugin >
{/* 擴展 main 的佈局,增加 footer,在 footer 中展示 complete 對應的操作 */}
<Template >
<TemplatePlaceholder />
<TemplatePlaceholder />
</Template>
{/* 擴展狀態和操作 */}
<Getter computed={getTodoWithCompletedStatus} />
<Action action={completeTodoAction} />
<Action action={activeTodoAction} />
{/* 擴展 todoItem 展示,增加 checkbox */}
<Template >
{({ todo }) => (
<TemplateConnector>
{(getters, { completeTodo, activeTodo }) => (
<div class>
<input
class
type="checkbox"
checked={todo.completed}
onChange={(e) => {
if (e.target.checked) {
completeTodo(todo.id);
} else {
activeTodo(todo.id);
}
}}
/>
{/* TemplatePlaceholder 表示直接使用原來 todoItem 定義的展示 */}
<TemplatePlaceholder />
</div>
)}
</TemplateConnector>
)}
</Template>
{/* Footer 的具體實現 */}
<TodoFooter />
</Plugin>
);
};
// 引入插件
const TodoApp = () => {
return (
<PluginHost>
{...}
{/* completable plugin */}
<TodoCompletable />
</PluginHost>
);
};
可以看到,標籤功能完全插件化了,原來的代碼不需要做任何調整!而且相關功能完全在一個模塊中實現(如果我們正常 React 的寫法會在多個組件中感知邏輯,改動肯定是分散的,而目前的寫法我們可以輕鬆增加或移除此 feature)!詳細例子可以參考寫的例子:https://stackblitz.com/edit/dr-todo-demo?file=App.tsx
通過上面的介紹,想必大家可以感受到插件化設計的魅力了,感謝看到這裏,希望能對大家有所幫助。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/KdpLI0WBR_wyhGEbbqY6QQ