
作者 | 包續兵(柳千)
來源 |
阿裡技術公衆号前言
由于篇幅過長,本文會拆分成系列文章,借助 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 代碼導緻全局樣式污染。
有一章節 How Plugins Run 對其插件系統運作機制進行了簡單的介紹,簡單來說 Figma 為插件中邏輯層的 main 入口建立了一個最小的 JavaScript 執行環境,它運作在浏覽器主線程上,在這個執行環境中插件代碼無法通路到一些浏覽器全局的 API,進而也就無法在代碼層面對 Figma 本身運作造成影響。而 UI 層有且僅有一份 HTML 代碼片段,在插件被激活後被渲染到一個彈窗中。
對其插件的沙箱機制做了詳細的闡述。起初他們嘗試的方案是
iframe
,一個浏覽器自帶的沙箱環境。将插件代碼由 iframe 包裹起來,由于 iframe 天然的限制,這将確定插件代碼無法操作 Figma 主界面上下文,同時也可以隻開放一份白名單 API 供插件調用。乍一看似乎解決了問題,但由于 iframe 中的插件腳本隻能通過 postMessage 與主線程通信,這導緻插件中的任何 API 調用都必須被包裝為一個異步
async/await
的方法,這無疑對 Figma 的目标使用者非專業前端開發者的設計師不夠友好。其次對于較大的文檔,postMessage 通信序列化的性能成本過高,甚至會導緻記憶體洩漏。
Figma 團隊選擇回到浏覽器主線程,但直接将第三方代碼運作在主線程,由此引發的安全問題是不可避免的。最終他們發現了一個尚在 stage2 階段的草案
Realm APIRealm
旨在建立一個領域對象,用于隔離第三方 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 應用中運作第三方代碼及其自定義控件,它有與開頭提到的微前端架構非常相似的一些問題。
- 一定程度上的 JavaScript 代碼沙箱隔離機制,應用主體對第三方代碼(或子應用)有一定的管控能力
- 樣式強隔離,第三方代碼樣式不對應用主體産生 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 等。