如何設計開發一個 Web 插件系統?

藉助插件,開發者可以很方便地解決自己的問題或者擴展特定場景功能。系統用戶可以使用到更多功能特性。系統擁有者可以構建一個產品生態,並減少維護成本。顯然這個一個三贏的方案。

插件可以說是一種優秀的設計模式。本文通過介紹插件的使用場景、如何設計插件系統、如何開發插件系統、如何保證插件系統的安全等四個方面來介紹插件,讓你瞭解並掌握這個日益廣泛使用的強大的思想模式。

真的需要插件嗎?

讓系統支持插件並且可以高枕無憂卻絕非易事。爲了支持插件,我們的系統必將會引入一些功能無關的代碼和邏輯,增加了系統的複雜度。另外,插件的安全性纔是最大的挑戰,需要確保插件不會相互影響,確保插件不會影響到主系統。在 web 環境中,是可以很容易修改對象原型和操作 DOM 的。

所以,不要輕易引入插件系統,除非你能確保它是安全、可控的。

Atom 的沒落有人歸因其插件,插件拖慢了整個編輯器的性能,引入了安全隱患等。

是否支持要插件,這是一個需要權衡的過程,如果滿足以下場景,可以優先考慮插件系統。帶來的收益是遠遠大於我們投入的成本(主要成本是確保安全性)。

插件可以看作是一種思想,給現有的系統 (組件) 擴展新的功能,而不需要關心該功能的具體實現。

  1. 確保核心層的穩定:微內核架構 (Microkernel Architecture),也稱爲插件化架構(Plug-in Architecture)。核心系統提供通用能力,由插件去實現業務功能。比如 Web PPT

  2. 開放能力,讓使用者自己解決特定場景問題。比如:Webpack、Figma。

  3. 旨在搭建一個產品平臺,讓第三方開發者自己開發新的功能。

  4. 適應多變的業務場景。某個模塊會隨業務場景多變時,就可以將其抽象爲插件。

設計原則(Design Principles)

一個優秀的插件系統首先是個優秀的軟件系統。

最優解就是最簡單解

一個優秀的軟件系統,我覺得它一定是簡單 (清晰) 的,不管是設計方案、實現思路,還是系統交互,都應該是簡單清晰的。

Nicholas Zakas(JavaScript 高級程序設計作者) 在 On Designing Great Systems[1] 一文中說到:

A good framework or a good architecture makes it hard to do the wrong thing. A lot of people will always go for the easiest way to get something done. So as a systems designer, your job is to make sure that the easiest thing to do is the right thing. And once you get that, the whole system gets more maintainable.

是的,最優解往往是最簡單的,當你的設計或方案很複雜時,可能需要重新審視下,是否還有更優解。

系統設計者應該設計出可以讓功能實現起來更容易、修改起來更簡單、擴展起來更輕鬆的系統架構。

抽象變與不變

好書推薦:架構整潔之道 (豆瓣)[2]

系統架構最核心的原則就是抽象隔離系統中變與不變的部分,對於不變的部分,我們需要保持它的穩定;對於變的部分,進行封裝和隔離,讓它易於擴展。而插件系統的初衷就是對改原則的踐行,把容易變的部分以插件的形式提供,不變的部分作爲系統的核心,插件的變化就不會影響到系統的核心層。

SOLID 設計原則

而到具體模塊 (組件、類、函數) 設計實現,我們需要遵循 SOLID 設計原則。

  1. SRP:單一職責原則。每個軟件模塊都有且只有一個需要被改變的理由。

  2. OCP:開閉原則。對擴展是開放的,對修改是封閉的。允許新增代碼來擴展,而不是修改原有的代碼。這裏具體設計往往需要結合 SRP 和 DIP 原則。

  3. LSP:里氏替換原則。多態思想,依賴於一種接口,則實現該接口的具體類之間就都具有了可替換性。

  4. ISP:接口隔離原則。只依賴需要的東西,不受其他實現改動的影響。比如 lodash 按需引入。

  5. DIP:依賴反轉原則。面向接口編程,多使用穩定的抽象接口,少依賴多變的具體實現。

在設計開發一個系統時,可以分爲三層:

高層:架構設計。

中層:設計原則、設計模式。

底層:代碼整潔之道。

如何設計插件系統

目標:設計一個簡單、靈活、安全的插件系統。

核心概念

任何的設計都需要建立在具體的需求上面,沒有一個方案是放之四海而皆準的。插件系統有多種模式和形式,以下的基本設計可以幫助你構想和設計特定場景的系統:

Hook

系統需要提供一些 Hook,以讓插件決定在什麼時候被調用執行。比如 webpack 插件就定義了很多 hook。

compiler.hooks.beforeCompile.tapAsync("MyPlugin"(params, callback) ={
  params["MyPlugin - data"] = "important stuff my plugin will use later";
  callback();
});

Plugin Interface

面向接口編程。定義了插件需要實現的接口,要想開發一個插件,就必須實現系統定義的插件接口。這是插件必須要遵循的一個規範,基於這些規範,系統就可以對插件進行檢驗、註冊掛載、註銷等操作。

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap(
      "Hello World Plugin",
      (
        stats /* stats is passed as an argument when done hook is tapped.  */
      ) ={
        console.log("Hello World!");
      }
    );
  }
}

module.exports = HelloWorldPlugin;

Plugin Loader

插件加載器,主要功能是註冊插件。主要有兩種模式:

插件驅動:插件知道如何訪問程序並進行自注冊。系統提供了一種方法,插件可通過該方法將自身註冊到程序中,比如 Vue.use。

系統驅動:系統知道如何查找插件並加載找到的插件。比如加載 plugins 文件下的文件、從也定接口拉取插件列表等。比如 Webpack 是從配置文件中拉取插件並加載。

開發插件系統

上面我們瞭解了插件系統的核心概念和雛形設計,接下來我們來簡單實現一個插件系統。給計算器擴展插件系統,支持開發者擴展功能。

第一步:實現個簡單的計算器。

class Calculator {
  currentValue: number = 0;

  setValue(newValue: number): void {
    this.currentValue = newValue;
    console.log(this.currentValue);
  }

  plus(addend: number): void {
    this.setValue(this.currentValue + addend);
  }

  minus(subtrahend: number): void {
    this.setValue(this.currentValue - subtrahend);
  }
}

const calculator = new Calculator();
calculator.setValue(3); // =3
calculator.plus(3); // =6
calculator.minus(2); // =4

這是一個沒有插件系統的 Web 應用,所有的操作(加法、減法)都需要在主系統中開發,這樣的系統不易於維護和擴展。接下來,基於這個簡單的計算器,我們改造一下,將其設計爲插件系統。

第二步:讓計算器支持插件。

type IExec = (currentValue: number, operand?: number) => number;

interface IPlugin {
  name: string;
  exec: IExec;
}

class Calculator {

  constructor(private currentValue: number = 0) {}

  private plugins: { [name: string]: IExec } = {};

  private setValue(newValue: number): void {
    this.currentValue = newValue;
    console.log(this.currentValue);
  }

  public register(plugin: IPlugin) {
    const { name, exec } = plugin;
    this.plugins[name] = exec;
  }

  public press(operation: string, operand?: number) {
    const exec = this.plugins[operation];
    if (typeof exec !== 'function') {
      throw Error(`${operation} operation no support yet!`);
    }
    this.setValue(exec(this.currentValue, operand));
  }

}

const calculator = new Calculator(3);
calculator.press('plus', 2); // Uncaught Error: plus operation no support yet!

我們將所有的運算操作都獨立成一個插件,計算器的核心功能只提供插件的註冊和調用邏輯。接下來,我們爲各個運算符開發對應的插件。

第三步:實現自定義插件

// plus plugin
const plus: IPlugin = {
  name: "plus",
  exec: (currentValue, addend) ={
    if (!addend) {
      throw Error("addend is required!");
    }
    return currentValue + addend;
  },
};

// minus plugin
const minus: IPlugin = {
  name: "minus",
  exec: (currentValue, subtrahend) ={
    if (!subtrahend) {
      throw Error("subtrahend is required!");
    }
    return currentValue - subtrahend;
  },
};

const calculator = new Calculator(3);
calculator.register(plus);
calculator.press("plus", 2); // =5

至此,我們完成了一個簡單的插件系統。

我們將計算器抽象成了兩部分,控制和行爲。所有的行爲,比如加法、平方等都是通過插件方式提供,確保了核心層的穩定以及便於擴展。(架構層的設計)

我們將系統中的變量設計爲私有變量,避免被外部篡改,只提供唯一修改數據的方法 press 和插件註冊方法 register。定義了插件接口,遵循 DIP 原則。對插件進行隔離,所有插件的執行代碼都是一個純函數,不僅便於測試,也減少了插件對系統的耦合和降低插件對系統的攻擊風險。(模塊層的設計)

然而,在 Web 中,以上方式對插件的隔離是不是就是安全的了?

如果有人在插件中加入以下代碼:

console.log = function () {
  alert("你被攻擊了");
};

在 JS 中,可以直接訪問到很多全局方法和對象,如果某個對象被篡改了,那麼將會影響到所有地方。另外,CSS 樣式也是全局的,插件之間,或者插件與系統之間,都可能存在樣式衝突和污染問題。

而我們的目標是設計一個安全的插件系統,特別是當我們插件是向第三方開發者開發時,插件安全是一個必須要引起重視的問題。

插件安全性

目前比較流行的保證插件安全性的方案是採用 Sandboxing (沙箱) 方案。插件一般是第三方開發者的代碼,如果能在一個與系統隔離的環境 (作用域) 中執行插件代碼,這樣插件就不會對系統產生副作用了。Node 中有 vm 模塊,可以讓代碼在一個獨立的環境中執行,但是瀏覽器不行,需要我們自己實現。

接下來,將會介紹下目前比較流行的幾種沙箱方案,包括 JS 沙箱和 CSS 沙箱。

JS 沙箱

Iframe

一個天然自帶的沙箱,同時也是隔離性最好的,iframe 應該沙箱方案的首選。比如 CodePen。

當我們想把 iframe 設置爲沙箱時,最好設置 sandbox 屬性。

該屬性會對 iframe 內容做更多的限制:

更多信息請查看 iframe[3]

  1. script 腳本不能執行。

  2. 不能發送 ajax 請求。

  3. 不能使用本地存儲,即 localStorage, cookie 等。

  4. 不能創建新的彈窗和 window。

  5. 不能發送表單。

  6. 不能加載額外插件比如 flash 等。

如果 sandbox 設置爲空字符值,則表示應用所有的限制(最嚴格)。可以通過給 sandbox 設置特定的值來解除特定限制。

<!-- 所有限制生效 --->
<iframe src="xxx" sandbox=""></iframe>
<!-- 允許執行腳本、允許提交表單、允許同域請求 --->
<iframe src="xxx" sandbox="allow-scripts,allow-forms,allow-same-origin"></iframe>

然而,在實際應用過程中,iframe 也會遇到其他一些問題:

  1. postMessage 傳遞的消息只能是純字符串,如果插件與系統交互數據比較大,數據的序列化將會耗費大量時間。

  2. 特定場景下,不好集合到系統中,因爲插件只能運行在一個獨立的 iframe 中。

運行在主線程

當插件代碼在系統的主線程執行時,這是很危險的,主要是因爲它可以任意訪問和調用瀏覽器的全局 API。所以我們要隱藏掉全局變量。

我們可以將 windowdocument 對象設置爲 null,不過由於 JS 原型鏈模式,消除所有的全局變量是很困難的。比如通過 ({}).constructor 就可以拿到 Object 對象,還可以修改所有對象的原型鏈方法和屬性。

所以我們需要構建一個沙箱,在沙箱裏訪問不到全局變量 (或者是隻能訪問經過我們處理的全局變量)。

獨立的 JS 解釋器

這是 Figma 嘗試過的一個方案,他們想自己寫個 JS 解釋器,不過成本太大,最終使用了 [Duktape](https://github.com/svaarala/duktape "Duktape")(一種 C++ 編寫的輕量級 JavaScript 解釋器),然後將其編譯爲 WebAssembly。Duktape 不支持任何瀏覽器 API,在 WebAssembly 中運行,無法訪問瀏覽器 API,這看起來是一個成功方案。

但是它還是有些問題,主要是 Duktape 解釋器太落後了,不方便調試,還有執行腳本性能差等,是無法跟瀏覽器的 JS 引擎相比的。

最終,Fimga 沒有采用該方案,他們採用了一種更好的方案。

Realms[4]

這是一個 Stage 2 的新提案,Realms 提案提供了一種新的機制,可以在新的全局對象和一組 JavaScript 內置對象的上下文中執 JavaScript 代碼。

const red = new Realm();
globalThis.someValue = 1;
red.evaluate("globalThis.someValue = 2"); // Affects only the Realm's global
console.assert(globalThis.someValue === 1);

這個提案的最佳實踐之一就是 Sandboxing,然而現在還是 stage 2 提案,無法在生產中使用。

不過,該思想是可以使用已有 JavaScript 功能來實現該技術的,主要思想是創建一個獨立的代碼執行環境上下文。核心實現如下:

function simplifiedEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode);
  }
}

with 語句會擴展一個語句的作用域鏈,它會把給定表達式添加到執行語句的最近作用域鏈上。

with (Math) {
  const r = 2;
  a = PI * r * r;
  x = r * cos(PI);
  y = r * sin(PI);
  console.log(x, y);
}

在執行語句內的變量,如果在當前塊作用域內找不到時,則會沿着作用域鏈往上找,而 with 後的表達式就是最近的作用域。

所以,我們可以藉助 with + proxy 來實現沙箱。比如上面的例子,eval(userCode) userCode 中的全局變量會先在 scopeProxy 上查找,我們只要對 scopeProxy 設置 get 和 set ,那麼 userCode 內訪問和修改全局變量都會被我們攔截。

使用 with + proxy + whitelist 實現一個簡單的沙盒 eval。

const whitelist = {
  window: undefined,
  document: undefined,
};
const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    }
    return undefined;
  },
  set(target, name, value) {
    if (Object.keys(whitelist).includes(name)) {
      whitelist[name] = value;
      return true;
    }
    return false;
  },
});

function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode);
  }
}
const code = `
  console.log(window); // undefined
  window.aa = 123; // Cannot set property 'aa' of undefined
`;
sandBoxingEval(scopeProxy, code);

以上我們通過白名單機制,隱藏了 window 和 document 等全局變量。然而,仍然可以通過 ({}).constructor 這樣的表達式來訪問某些全局變量。此外,沙箱確實需要訪問某些全局變量,如 Object,它常出現在合法的 JavaScript 代碼(如 Object.keys )中。

這時候,iframe 又發揮作用了,iframe 內有個 contentWindow,它擁有所有全局變量的副本,如 Object.prototype。在同源情況下,我們可以在主線程獲取到 contentWindow 。

const iframe = document.createElement('iframe'{ url:'about:blank' });

document.body.appendChild(iframe);

const sandboxGlobal = iframe.contentWindow;

console.log(sandboxGlobal); // Window {window: Window, self: Window, document: document, name: "", location: Location, …}

我們對上面的實現稍微修改下,把 contentWindow 當做 whitelist。
const iframe = document.createElement('iframe'{ url:'about:blank' });
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
const scopeProxy = new Proxy(sandboxGlobal, {
  get(target, prop) {
    if (prop in target) {
      return target[prop]
    }
    return undefined
  },
  set(target, name, value){
    if(Object.keys(target).includes(name)){
      target[name] = value;
      return true;
    }
    return false;
  },
})

function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(userCode)
  }
}
const code = `
  window.aa = 123;
  ({}).constructor.prototype.aa = 'aa';
`;
sandBoxingEval(scopeProxy, code);

// 主線程下
window.aa; // undefined
({}).aa; // undefined

至此,我們實現了個隔離性很好的沙箱,但這並不是終點。

雖然插件代碼可以獨立在沙箱裏執行,但是在實現場景中,我們是需要向插件提供能力的。我們可能會暴露一些工具方法給插件,然而這是極其危險,因爲插件內部可以通過該方法,順着原型鏈來到我們的主線程,從而可以訪問並修改主線程的全局變量。

所以,爲插件提供能力也是一件需要極其小心的事情,一不小心可能就前功盡棄。更多信息可以瞭解下 Figma 的實現思路:How to build a plugin system on the web and also sleep well at night[5]。

這裏簡單實現下,主要的思路就是:通過在沙箱內對傳進來的 function 包一層,改變其原型鏈的引用。

const iframe = document.createElement("iframe"{ url: "about:blank" });
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
sandboxGlobal.log = (v) => v + v;
const scopeProxy = new Proxy(sandboxGlobal, {});

function sandBoxingEval(scopeProxy, userCode) {
  with (scopeProxy) {
    eval(`
      const safeFactory = fun =(...args) => fun(...args);
      log = safeFactory(log);
    `);
    eval(userCode);
  }
}
const code = `
  log.constructor.prototype.__proto__.aa = 'aa';
  console.log(log(1)) // 2
`;
sandBoxingEval(scopeProxy, code);
({}.aa); // undefined

CSS 沙箱

命名空間

這需要大家遵守一定的命名規範,比如定義唯一的 class 前綴。比如 Antd Design 、Iview。這是最簡單直接的處理方式,成本也是最低的,需要人爲的約束,是很容易造成樣式衝突和污染的。

這裏我們藉助工程化工具,或者自己寫個轉化器,對插件代碼進行處理,都加上唯一的命名空間。

Scope CSS

在 Vue 中,我們可以使用 scope 來控制 css 的影響範圍。Scope CSS 與 CSS Module 稍微不太一樣,它使用屬性選擇器,來縮小 CSS 生效的範圍。

<style scope lang="less"></style>

這裏提供個思路,我們可以借鑑這種思想,在處理插件代碼時,開發箇中間處理器,給插件 HTML 節點都加上唯一的屬性,然後在 CSS 中全部替換成屬性選擇器(vue-loader 乾的活)。藉助 @vue/component-compiler-utilspostcss 完全可以自己實現。

刨根問底,揭開 Vue 中 Scope CSS 實現的幕後(原理)

.box[data-v-992092a6] {
  width: 200px;
  height: 200px;
  background: #aff;
}
<div data-v-992092a6>scoped css</div>

Shadow DOM

Shadow DOM 是最新推出的 Web Components[6] 技術的重要組成部分。

Web Components 技術的核心就是封裝,Shadow DOM 就允許我們創建一個獨立的 Shadow 空間,裏面的樣式不會影響到外部,外部的樣式也不會影響到其裏面樣式,它是真正的獨立。

這個方案是未來的一個趨勢,只是現在兼容性還不怎麼好,如果只在 chrome 上使用,該方案是首推。

image.png

通過 Element.attachShadow() 創建一個 Shadow 空間,然後往裏面添加獨立的 DOM。這裏需要注意,不是所以的標籤都可以調用 attachShadow 生成空間的。

const div = document.createElement("div");
const shadowRoot = div.attachShadow({ mode: "closed" });
const pluginDom = getPluginDom();
shadowRoot.appendChild(pluginDom);

上圖來自:https://mp.weixin.qq.com/s/pIRFNpAo8WWinow_2jcGZg

總結

本文首先介紹插件的使用場景,我們需要權衡是否真的有必要支持插件。一個好的插件系統,前提是一個好的軟件系統,所以在設計之前,我們需要了解基本的設計原則和規範。插件系統可以看作是設計原則結合實際場景的一種最佳實踐,本文給出了基本的設計模型和核心概念,可以結合實際業務場景設計我們自己的插件系統。本文還帶領大家簡單實現了一個插件系統,由此我們看到了插件系統安全的重要性。如何確保插件的安全性,我們可以從 JS 沙箱和 CSS 沙箱兩方面着手,簡單介紹了幾種主流方案,供大家參考。

希望你能從中受益。

參考資料

  1. How to build a plugin system on the web and also sleep well at night[7]

  2. Designing a JavaScript Plugin System | CSS-Tricks[8]

  3. How to Design Software — Plugin Systems[9]

  4. tc39/proposal-realms[10]

  5. 刨根問底,揭開 Vue 中 Scope CSS 實現的幕後(原理)

❤️ 謝謝支持

以上便是本次分享的全部內容,希望對你有所幫助 ^_^

喜歡的話別忘了 分享、點贊、收藏 三連哦~。

歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~

公衆號

我們來自字節跳動,是旗下大力教育前端部門,負責字節跳動教育全線產品前端開發工作。

我們圍繞產品品質提升、開發效率、創意與前沿技術等方向沉澱與傳播專業知識及案例,爲業界貢獻經驗價值。包括但不限於性能監控、組件庫、多端技術、Serverless、可視化搭建、音視頻、人工智能、產品設計與營銷等內容。

歡迎感興趣的同學歡迎聯繫小編微信 **rsjy2017 **拍磚 🤪

參考資料

[1]

On Designing Great Systems: https://www.bryanbraun.com/2015/02/16/on-designing-great-systems/

[2]

架構整潔之道 (豆瓣): https://book.douban.com/subject/30333919/

[3]

iframe: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe

[4]

Realms: https://github.com/tc39/proposal-realms

[5]

How to build a plugin system on the web and also sleep well at night: https://www.figma.com/blog/how-we-built-the-figma-plugin-system/#implementing-the-api-using-realms-securely

[6]

Web Components: http://www.ruanyifeng.com/blog/2019/08/web_components.html

[7]

How to build a plugin system on the web and also sleep well at night: https://www.figma.com/blog/how-we-built-the-figma-plugin-system/#implementing-the-api-using-realms-securely

[8]

Designing a JavaScript Plugin System | CSS-Tricks: https://css-tricks.com/designing-a-javascript-plugin-system/

[9]

How to Design Software — Plugin Systems: https://betterprogramming.pub/how-to-design-software-plugins-d051ce1099b2

[10]

tc39/proposal-realms: https://github.com/tc39/proposal-realms

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wv7ASkWRfB9eDaULxBdBDQ