大型 Web 應用插件化架構探索

隨着 Web 技術的逐漸成熟,越來越多的應用架構趨向於複雜,例如阿里雲、騰訊雲等巨型控制檯項目,每個產品下都有各自的團隊來負責維護和迭代。不論是維護還是發佈以及管控成本都隨着業務體量的增長而逐漸不可控。在這個背景下微前端應用而生,微前端在阿里內部已經有許多成熟的實踐,這裏不再贅述。本文以微前端爲引子 (蹭熱度),探討一些另類的 Web 應用所面臨的類似問題。

現代文本編輯器沉浮

2018 年微軟 GitHub 後,Atom 便經常被拿來調侃,所謂一山不容二虎。在 VS Code 已經成爲一衆前端工程師編輯器首選的當下,Atom 的地位顯得很尷尬,論性能被同爲 Electron 的 VS Code 秒殺,論插件,VS Code 去年插件總數就已經突破 1w 大關,而早發佈一年多的 Atom 至今還停留在 8k +。再加上微軟官方主導的 LSP/DAP 等重量級協議的普及,時至今日 Atom 作爲曾經 Web/Electron 技術標杆應用的地位早已被 VS Code 斬落馬下。

知乎上關於 Atom 的日漸衰落的討論,始終離不開性能。Atom 的確太慢了,究其原因很大程度上是被其插件架構所拖累的。尤其是 Atom 在 UI 層面開放過多的權限給插件開發者定製,插件質量良萎不齊以及 UI 完全開放給插件後帶來的安全隱患都成爲 Atom 的阿喀琉斯之踵。甚至其主界面的 FileTree、Tab 欄、Setting Views 等重要組件都是通過插件實現的。相比之下 VS Code 則封閉很多,VS Code 插件完全運行在 Node.js 端,對於 UI 的定製性只有極個別被封裝爲純方法調用的 API。

但另一方面,VS Code 這種相對封閉的插件 UI 方案,一些需要更強定製性的功能便無法滿足,更多插件開發者開始魔改 VS Code 底層甚至源碼來實現定製。例如社區很火的 VS Code Background,這款插件通過強行修改 VS Code 安裝文件中的 CSS 來實現編輯器區域的背景圖。而另一款 VSC Netease Music 則更激進,因爲 VS Code 捆綁包中的 Electron 剔除了 FFmpeg 導致在 Webview 視圖下無法播放音視頻,使用此插件需要自行替換 FFmpeg 的動態鏈接庫。而這些插件不免會對 VS Code 安裝包造成一定程度的破壞,導致用戶需要卸載重裝。

不止編輯器 - 飛個馬

Figma 是一個在線協作式 UI 設計工具, 相比 Sketch 它具有跨平臺、實時協作等優點,近年來逐漸受到 UI 設計師們的青睞。而近期 Figma 也正式上線了其插件系統。

作爲一個 Web 應用,Figma 的插件系統自然也是基於 JavaScript 構建的,這一定程度上降低了開發門檻。自去年 6 月份 Figma 官方宣佈開放插件系統測試以來,已經有越來越多的 Designner/Developer 開發了 300+ 插件,其中包括圖形資源、文件歸檔、甚至是導入 3D 模型等。

Figma 的插件系統是如何工作的?

這是一個基於 TypeScript + React 技術棧,使用 Webpack 構建的 Figma 插件目錄結構

.
├── README.md
├── figma.d.ts
├── manifest.json
├── package-lock.json
├── package.json
├── src
│   ├── code.ts
│   ├── logo.svg
│   ├── ui.css
│   ├── ui.html
│   └── ui.tsx
├── tsconfig.json
└── webpack.config.js

在其 manifest.json 文件中包含了一些簡單的信息。

{
  "name": "React Sample",
  "id": "738168449509241862",
  "api": "1.0.0",
  "main": "dist/code.js",
  "ui": "dist/ui.html"
}

可以看出 Figma 將插件入口分爲了 main 與 ui 兩部分, main 中包含了插件實際運行時的邏輯,而 ui 則是一個插件的 HTML 片段。即 UI 與邏輯分離。安裝一個 Color Search 插件後觀察頁面結構可以發現 main 中的 js 文件被包裹在一個 iframe 里加載到頁面上,關於 main 入口的沙箱機制後文中有詳細的闡述。而 ui 中的 HTML 最終也被包裹在一個 iframe 裏渲染出來,這將有效的避免插件 UI 層 CSS 代碼導致全局樣式污染。

Figma Developers 文檔中 有一章節 How Plugins Run 對其插件系統運行機制進行了簡單的介紹,簡單來說 Figma 爲插件中邏輯層的 main 入口創建了一個最小的 JavaScript 執行環境,它運行在瀏覽器主線程上,在這個執行環境中插件代碼無法訪問到一些瀏覽器全局的 API,從而也就無法在代碼層面對 Figma 本身運行造成影響。而 UI 層有且僅有一份 HTML 代碼片段,在插件被激活後被渲染到一個彈窗中。

Figma 官方博客中對其插件的沙箱機制做了詳細的闡述。起初他們嘗試的方案是 iframe,一個瀏覽器自帶的沙箱環境。將插件代碼由 iframe 包裹起來,由於 iframe 天然的限制,這將確保插件代碼無法操作 Figma 主界面上下文,同時也可以只開放一份白名單 API 供插件調用。乍一看似乎解決了問題,但由於 iframe 中的插件腳本只能通過 postMessage 與主線程通信,這導致插件中的任何 API 調用都必須被包裝爲一個異步 async/await 的方法,這無疑對 Figma 的目標用戶非專業前端開發者的設計師不夠友好。其次對於較大的文檔,postMessage 通信序列化的性能成本過高,甚至會導致內存泄漏。

Figma 團隊選擇回到瀏覽器主線程,但直接將第三方代碼運行在主線程,由此引發的安全問題是不可避免的。最終他們發現了一個尚在 stage2 階段的草案 Realm API。Realm 旨在創建一個領域對象,用於隔離第三方 JavaScript 作用域的 API。

let g = window; // outer global
let r = new Realm(); // root realm
let f = r.evaluate("(function() { return 17 })");
f() === 17 // true
Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.globalThis.Function.prototype // true

值得注意的是,Realm 同樣可以使用 JavaScript 目前已有的特性來實現,即 with 與 Proxy。這也是目前社區比較流行的沙箱方案。

const whitelist = {
  windiw: undefined,
  document: undefined,
  console: window.console,
};
const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    if (prop in target) {
      return target[prop]
    }
    return undefined
  }
});
with (scopeProxy) {
  eval("console.log(document.write)") // Cannot read property 'write' of undefined!
  eval("console.log('hello')")        // hello
}

前文中 Figma 插件被 iframe 所包裹的插件 main 入口即包含了一個被 Realm 接管的作用域,你可以認爲是類似這段示例代碼中的一份 白名單 API,畢竟維護一份白名單比屏蔽黑名單實現起來更簡潔。但事實上由於 JavaScript 的原型式繼承,插件仍然可以通過 console.log 方法的原型鏈訪問到外部對象,理想的解決方案是將這些白名單 API 在 Realm 上下文中包裝一次,從而徹底隔離原型鏈。

const safeLogFactory = realm.evaluate(`
  (function safeLogFactory(unsafeLog) { 
    return function safeLog(...args) {
      unsafeLog(...args);
    }
  })
`);
const safeLog = safeLogFactory(console.log);
const outerIntrinsics = safeLog instanceOf Function;
const innerIntrinsics = realm.evaluate(`log instanceOf Function`, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError(); 
realm.evaluate(`log("Hello outside world!")`, { log: safeLog });

顯然爲每一個白名單中的 API 做這樣操作的工作是非常繁雜且容易出錯的。那麼如何構建一個安全且易於添加 API 的沙箱環境呢?

Duktape 是一個由 C++ 實現的用於嵌入式設備的 JavaScript 解釋器,它不支持任何瀏覽器 API,自然地它可以被編譯到 WebAssembly,Figma 團隊將 Duktape 嵌入到 Realm 上下文中,插件最終通過 Duktape 解釋執行。這樣可以安全的實現插件所需 API,且不用擔心插件會通過原型鏈訪問到沙箱外部。

這是一種被稱爲 Membrane Pattern 的防禦性的編程模式,用於在程序中與子組件 (廣義上) 實現一層中介。簡單來說就是代理(Proxy),爲一個對象創建一個可控的訪問邊界,使得它可以保留一部分特性給第三方嵌入腳本,而屏蔽一部分不希望被訪問到的特性。關於 Membrane 的詳細論述可以查看 Isolating application sub-components with membranes 與 Membranes in JavaScript 這兩篇文章。

這是最終 Figma 的插件方案,它運行在主線程,不需要擔心 postMessage 通信帶來的傳輸損耗。多了一次 Duktape 解釋執行的消耗,但得益於 WebAssembly 出色的性能,這部分消耗並不是很大。

另外 Figma 還保留了最初的 iframe ,允許插件可以自行創建 iframe ,並在其中插入任意 JavaScript ,同時它可以與沙箱中的 JavaScript 腳本通過 postMessage 相互通信。

魚和熊掌如何兼得?

我們把這類插件的需求總結爲在 Web 應用中運行第三方代碼及其自定義控件,它有與開頭提到的微前端架構非常相似的一些問題。

  1. 一定程度上的 JavaScript 代碼沙箱隔離機制,應用主體對第三方代碼 (或子應用) 有一定的管控能力

  2. 樣式強隔離,第三方代碼樣式不對應用主體產生 CSS 污染

JavaScript 沙箱

JavaScript 沙箱隔離在社區是個經久不衰的話題,最簡單的 iframe 標籤 Sandbox 屬性就已經能做到 JavaScript 運行時的隔離,社區較爲流行的是利用一些語言特性 (with、realm、Proxy 等 API) 屏蔽(或代理) Window、Document 等全局對象,建立白名單機制,對可能潛在危險操作的 API 重寫(如阿里雲 Console OS - Browser VM)。另外還有 Figma 這種嘗試嵌入平臺無關的 JavaScript 解釋器,所有第三方代碼都通過嵌入的解釋器來執行。以及利用 Web Worker 做 DOM Diff 計算,並將計算結果發送回 UI 線程來進行渲染,這個方案早在 2013 年就已經有人進行了實踐,這篇論文中作者將 JSDOM 這一 Node.js 平臺廣泛流行的測試庫運行在 Web Worker。而近些年來也有 preact-worker-demo 、react-worker-dom 等項目基於 Web Worker 的 DOM Renderer 嘗試將 DOM API 代理到 Worker 線程。而 Google AMP Project 在 JSCONF 2018 US 對外公佈的 worker-dom 則將 DOM API 在 Web Worker 端實現了 DOM API,雖然實踐下來還存在一些問題(例如同步方法無法模擬),但 WorkerDOM 在性能和隔離性上都取得了一定成果。

以上這些解決方案被廣泛的應用在各種插件化架構的 Web 應用中,但大多都是 Case By Case,每種解決方案都有各自的成本與取捨。

CSS 作用域

CSS 樣式隔離方案中,如上文中 Figma 使用 iframe 渲染插件界面,犧牲一部分性能換來了相對完美的樣式隔離。而在現代前端工程化體系下,可以通過 CSS Module 在轉譯時對 class 添加 hash 或 namespace 等方式實現,這類方案較爲依賴插件代碼編譯過程。而更新潮的是利用 Web Component 的 Shadow DOM,將插件元素用 Web Component 包裹起來,Shadow Root 外部樣式無法作用於內部,同樣 Shadow Root 內部的樣式也無法影響到外部。

最後

本文列舉了目前編輯器、設計工具這類大型 Web 應用插件化架構下所面臨的的一些問題,以及社區實踐的解決方案。不論是讓人又愛又恨的 iframe ,還是 Realm、Web Worker 、 Shadow DOM 等,目前來說每種方案都有各自的優勢與不足。但隨着 Web 應用的複雜度增長,插件化這一需求也逐漸被各大標準化組織所重視起來。下一篇將着重介紹 KAITIAN IDE 中插件架構的探索與實踐,包括 JavaScript 沙箱、CSS 隔離、Web Worker 等。

作者 | 包續兵(柳千)

編輯 | 橙子君

出品 | 阿里巴巴新零售淘系技術

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