原文:www.figma.com/blog/how-we…git
在 Figma,咱們最近解決了迄今爲止最大的工程挑戰之一:支持插件。 咱們的插件 API 使第三方開發人員能夠直接在基於瀏覽器的設計工具中運行代碼,所以團隊可使 Figma 適應本身的工做流程。他們能夠用可訪問性檢查器測量對比度,用翻譯應用程序轉換語言,進口商能夠用內容填充設計,以及其餘需求。
程序員
咱們必須仔細設計該插件的功能。在整個軟件歷史中,有不少第三方擴展對平臺產生負面影響的例子。在某些狀況下,他們拖慢了工具的運行速度,在其餘狀況下,每當平臺有新版本發佈時,插件就會中斷。咱們但願在可控範圍內,用戶對 Figma 有更好的插件體驗。github
此外,咱們但願確保插件對用戶而言是安全的,所以不能簡單地使用 eval(PLUGIN_CODE)——不安全的典型定義! 可是,本質上運行插件能夠歸結爲 eval。算法
更具挑戰性的是,Figma 創建在一個很是規的堆棧上,有一些其餘工具沒有的限制。其中,設計編輯器基於 WebGL 和 WebAssembly,部分用戶界面用 Typescript&React 實現,能夠多人同時編輯一個文件。咱們依賴於瀏覽器技術的支持,同時也受到它們的限制。編程
這篇博客將引導你實現一個完美的插件解決方案。最終,咱們的工做歸結爲一個問題:如何安全地、穩定地、高性能地運行插件? api
咱們考慮了不少不一樣路線的方法,進行了數週的討論、原型製做和頭腦風暴。這篇博客僅關注其中構成核心路徑的三種嘗試。跨域
在最初幾周的研究中,咱們發現了許多有趣的嘗試,如 code-to-code 的轉換,可是,大多數未經生產環境應用程序驗證,存在必定的風險。瀏覽器
最後咱們嘗試了最接近標準沙箱的方法:<inline-iframe> 標籤,運行第三方代碼的應用中有用到,如 CodePen。安全
<inline-iframe> 不是普通的 HTML 標籤,要了解爲何它是安全的,有必要考慮一下須要保證哪些特性。<inline-iframe> 一般用於將一個網站嵌入另外一個網站,例如yelp.com 中嵌入的 Google Map。bash
在這裏,你不會但願 Yelp 僅經過嵌入就能讀取 Google 網站中的內容(可能有私人的用戶信息),一樣地,也不但願 Google 讀取 Yelp 網站中的內容。
這意味着與 <inline-iframe> 的通訊受到瀏覽器的嚴格限制。 若是 <inline-iframe> 的 origin 與容器不一樣(例如 yelp.com 與 google.com),則它們是徹底隔離的,與 <inline-iframe> 通訊的惟一方法是消息傳遞。這些消息都是純字符串,收到消息後,網站能夠自行處理或者忽略。HTML 規範容許瀏覽器將 <inline-iframe> 做爲單獨的進程實現。
瞭解了<inline-iframe>的工做原理後,咱們能夠在每次插件運行時建立一個新的<inline-iframe>,將代碼嵌入<inline-iframe>中來實現插件,插件能夠在<inline-iframe>內執行任何所需的操做。可是隻有經過明確的白名單消息,它才能與 Figma document 交互,而且 <inline-iframe> 的 origin 爲 null,任何往 figma.com 發出的請求都會被瀏覽器的跨域資源共享策略拒絕。
實際上,<inline-iframe>充當了插件的沙箱,沙箱的安全性由瀏覽器供應商保證,他們花了多年時間尋找並修復沙箱中的漏洞。
採用沙箱模型的插件將使用咱們添加到沙箱中的 API,大體以下所示:
const scene = await figma.loadScene() // gets data from the main thread
scene.selection[0].width *= 2
scene.createNode({
type: 'RECTANGLE',
x: 10, y: 20,
...
})
await figma.updateScene() // flush changes back, to the main thread複製代碼
關鍵在於插件經過調用 loadScene(發送消息給 Figma 獲取 document 的副本)進行初始化,並以調用 updateScene(將插件所作的更改發回給 Figma)結束。 注意:
咱們花了大概一個月時間構建起來,還邀請了一些 Alpha 測試人員,很快就發現了兩個主要缺陷:
1. async/await 對用戶不夠友好
咱們獲得的第一個反饋是,用戶在使用 async/await 時遇到了麻煩。在這種方法中,這是不可避免的。消息傳遞從根本上講是一種異步操做,JavaScript 沒法對異步操做進行同步的阻塞調用,至少須要使用 await 關鍵字將全部調用函數標記爲異步。總的來講,async/await 仍然是一個至關新的 JavaScript 功能,而且須要對併發性有所解。這是個問題,由於咱們預計許多插件開發人員都對 JavaScript 熟悉,但可能沒有接受過正規的 CS 教育。
若是隻須要在插件開始時使用一次 await,結束時使用一次 await,那還不錯。咱們只是告訴開發人員,即便不太瞭解它的功能,也要始終在 loadScene 和 updateScene 使用 await。
問題是某些 API 調用須要大量複雜的邏輯計算,更改一個 layer 上的屬性有時會致使多個 layer 更新,例如調整 frame 的大小將遞歸地應用於子元素上。
這些行爲一般是精細複雜的算法,爲插件從新實現它們是個壞主意。咱們編譯好的 WebAssembly 也存在一樣的邏輯,所以不太好重用。並且,若是不在插件的沙箱中運行這些邏輯,插件將讀取過期的數據。
雖然下面這樣是可控的:
await figma.loadScene()
... do stuff ...
await figma.updateScene()複製代碼
即使是有經驗的工程師,也可能很快變得難以處理:
await figma.loadScene()
... do stuff ...
await figma.updateScene()
await figma.loadScene()
... do stuff ...
await figma.updateScene()
await figma.loadScene()
... do stuff ...
await figma.updateScene()複製代碼
2. 複製 scene 代價很昂貴
<inline-iframe>方法的第二個問題是,在發送給插件前須要序列化大部分document。事實證實,用戶可能在 Figma 中建立很是大的文檔,以致於達到內存限制。例如,Microsoft 的設計系統文件,須要花費14秒才能對 document 進行序列化。鑑於大多數插件都涉及諸如「在個人選擇中交換兩個項目」之類的快速操做,這將使插件沒法使用。
增量或者延遲加載數據也不現實,由於:
總而言之,因爲 Figma 文檔可能包含大量互相依賴的數據,<inline-iframe>方案不適合咱們。
簡單的方案行不通,咱們從新開始,花了兩週時間認真考慮更多奇特的想法。可是大多數方法都有一個或多個主要缺陷:
最終咱們得出的結論是,須要找到一種能夠直接操做 document 的方法。編寫插件應該像設計師在自動化動做,所以應該容許插件運行在主線程上。
在第二次嘗試以前,咱們須要從新審視容許插件運行在主線程上的含義,咱們起初沒有考慮它,由於知道可能很危險,在主線程上運行聽起來很像 eval(UNSAFE_CODE)。
在主線程上運行的好處是插件能夠:
可是,如今咱們遇到了如下問題:
咱們決定放棄對(1)的要求,當插件凍結時,會影響 Figma 被感知的穩定性。可是,咱們的插件模型在明確的用戶操做下能夠正常運行。在插件運行時更改 UI,凍結老是會歸因於插件。這也意味着插件不能 「破壞」 document。
eval 很危險意味着什麼?
爲了解決插件可以發送網絡請求並訪問全局狀態的問題,首先須要正確理解 「隨意的eval JavaScript 代碼是危險的」 的含義。
若是 JavaScript 變量只能進行相似 7 * 24 * 60 * 60的算術運算(簡稱SimpleScript),執行 eval 是很安全的。向 SimpleScript 添加一些功能,例如變量賦值和if 語句,使其更像一種編程語言,仍然是很是安全的。添加函數求值,就有了 lambda 演算和圖靈完整性。
換句話說,JavaScript 不必定是危險的。在最簡單的狀況下,它只是算術運算的一種擴展方式,當它訪問輸入和輸出時比較危險,包括網絡、DOM 等,危險的是這些瀏覽器 API。
API 都是全局變量,因此隱藏全局變量!
從理論上講,隱藏全局變量聽起來不錯,可是僅經過「隱藏」它們來保證安全是困難的。 好比,你可能考慮刪除 window 對象上的全部屬性,或將其設置爲 null,可是代碼仍然能夠訪問諸如 ({}).constructor 之類的全局變量。尋找全部可能泄漏全局變量的方式很是具備挑戰性。
相反,咱們須要一種更強大的沙箱,在這些沙箱裏,全局變量首先就不存在。
說到先前僅支持算術運算的 SimpleScript 示例,編寫算術求值程序是 CS 101的一個簡單練習,在該程序的任何合理實現中,SimpleScript 都不能執行算術運算以外的任何操做。
如今,擴展 SimpleScript 支持更多的語言功能,直到它變成 JavaScript ,這樣的程序稱爲解釋器,這是運行 JavaScript 這種動態解釋語言的方式。
對於像咱們這樣的小型創業公司來講,實現 JavaScript 太繁重了,爲了驗證這種方法,咱們使用 Duktape(一種 C++ 編寫的輕量級 JavaScript 解釋器),將其編譯爲 WebAssembly。
咱們在上面運行了標準 JavaScript 測試套件 test262,它經過了全部 ES5 測試,一些不重要的測試除外。使用 Duktape 運行插件代碼,須要調用已編譯解釋器的 eval 函數。
這種方法的特性以下:
(注:幾個月後,Fabrice Bellard 發佈了 QuickJS,它自己就支持 ES6。)
如今,編譯一個 JavaScript 解釋器! 做爲程序員你可能會想到:
太讚了!
或者
真的嗎?已有 JavaScript 引擎的瀏覽器中的 JavaScript 引擎?接下來是什麼,瀏覽器中的操做系統嗎?
有些懷疑是對的!除非必要,最好避免從新實現瀏覽器。咱們已經花費了不少精力實現整個渲染系統,作到了必不可少的性能和跨瀏覽器支持,可是咱們仍然儘可能不從新發明輪子。
這不是咱們最終採用的方法,有一個更好的方法。可是,覆蓋這一點很重要,由於這是理解咱們最終的沙箱模型的一個步驟,該模型更爲複雜。
儘管咱們有一種編譯 JS 解釋器的好方法,但還有另一種工具,由 Agoric 創造的稱爲 Realms shim 的技術。該技術將建立沙箱和支持插件做爲潛在用例,Realms API 大大體以下:
let g = window; // outer global
let r = new Realm(); // realm object
let f = r.evaluate("(function() { return 17 })");
f() === 17 // true
Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.global.Function.prototype // true複製代碼
實際上,可使用已有的(儘管不爲人知的)JavaScript 功能來實現該技術,沙箱能夠隱藏全局變量,shim 起做用的核心大體以下:
function simplifiedEval(scopeProxy, userCode) {
'use strict'
with (scopeProxy) {
eval(userCode)
}
}複製代碼
這是用於演示的簡化版本,實際版本中還有一些細微差別,可是,它展現了難題的關鍵部分:with 語句 和 Proxy 對象。
with(obj) 建立了一個新的做用域,在該做用域內可使用 obj 的屬性來解析變量。在下例中,咱們能夠從 Math 對象的屬性中解析出變量 PI,cos 和 sin ,而 console 是從全局做用域解析的,它不是 Math 的屬性。
with (Math) {
a = PI * r * r
x = r * cos(PI)
y = r * sin(PI)
console.log(x, y)
}複製代碼
Proxy 對象是 JavaScript 對象最動態的形式。
嘗試訪問如下 proxy 上的任何屬性(白名單中的除外),將返回 undefined。
const scopeProxy = new Proxy(whitelist, {
get(target, prop) {
// here, target === whitelist
if (prop in target) {
return target[prop]
}
return undefined
}
}複製代碼
如今,將這個 proxy 做爲 with 的參數,它將截獲全部變量解析,永遠不會使用全局做用域:
with (proxy) {
document // undefined!
eval("xhr") // undefined!
}複製代碼
好吧,仍然能夠經過 ({}).constructor 這樣的表達式訪問某些全局變量。此外,沙箱確實須要訪問某些全局變量,如 Object,它常出如今合法的 JavaScript 代碼(如 Object.keys )中。
爲了使插件可以訪問全局變量又不弄亂 window 對象,Realms 沙箱建立了一個同源 iframe 來實例化全部這些全局變量的副本。這個 iframe 與嘗試1中的版本不一樣,同源 iframe 不受 CORS 的限制。
當 <inline-iframe> 與父 document 同源時:
1. 它擁有全部全局變量的副本,如 Object.prototype
2. 能夠從父 document 訪問這些全局變量。
將這些全局變量放入 Proxy 對象的白名單,這樣插件就能夠訪問到。 最後,這個新的 <inline-iframe> 帶有一個 eval 函數的副本,與現有的 eval 函數有一個重要區別:即使是隻能經過 ({}).constructor 這樣的語法訪問的內置值,也會解析爲 iframe 中的副本
這種使用 Realms 的沙箱方法具備許多不錯的特性:
可是它安全嗎?
使用 Realms 安全地實現 API
咱們對 Realms 的沙箱功能感到滿意。儘管比 JavaScript 解釋器方法包含更多微妙之處,它仍然能夠做爲白名單,其實現規模較小且易於審覈,而且是由網絡社區中德高望重的成員建立的。
可是,使用 Realms 並非故事的結局,這僅僅是一個沙箱,插件沒法執行任何操做,咱們仍然須要實現提供 API 的插件。這些 API 也要保證安全,由於大多數插件確實須要顯示 UI 併發送網絡請求(例如,使用 Google 表格中的數據填充設計)。
考慮到默認狀況下沙箱是不包含 console 對象的,畢竟 console 是瀏覽器 API,而不是 JavaScript 的功能,能夠將其做爲全局變量傳遞到沙箱。
realm.evaluate(USER_CODE, { log: console.log })複製代碼
或者將原始值隱藏在函數中,這樣沙箱就沒法修改:
realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })複製代碼
不幸的是,這是一個安全漏洞。即便在第二個例子中,匿名函數也是在 realm 以外建立的,而後直接提供給了 realm,這意味着插件能夠沿着 log 函數的原型鏈到達沙箱外。
實現 console.log 的正確方法是將其包裝在 realm 內建立的函數中,下面是一個簡化的示例(實際上,也有必要轉換 realms 拋出的全部異常)。
// Create a factory function in the target realm.
// The factory return a new function holding a closure.
const safeLogFactory = realm.evaluate(`
(function safeLogFactory(unsafeLog) {
return function safeLog(...args) {
unsafeLog(...args);
}
})
`);
// Create a safe function
const safeLog = safeLogFactory(console.log);
// Test it, abort if unsafe
const outerIntrinsics = safeLog instanceof Function;
const innerIntrinsics = realm.evaluate(`log instanceof Function`, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError();
// Use it
realm.evaluate(`log("Hello outside world!")`, { log: safeLog });複製代碼
一般,沙箱永遠不能直接訪問在沙箱外部建立的對象,由於它們能夠訪問全局做用域。一樣重要的是,API 必須謹慎對待來自沙箱內部的對象,它們有可能與沙箱外部的對象混在一塊兒。
這帶來了一個問題。儘管能夠建立安全的 API,但讓開發人員每次向 API 添加新功能時,都擔憂難以捉摸的對象源語義是不可行的。 該如何解決這個問題呢?
一個解釋器一個API
問題在於,直接基於 Realms 建立 Figma API 會使每一個 API 端點都須要審覈,包括輸入和輸出值,這範圍太大了。
儘管 Realms 沙箱中的代碼使用相同的 JavaScript 引擎運行(爲咱們提供了便利的工具),仍然能夠假裝成受到 WebAssembly 方法的限制。
考慮一下 Duktape,嘗試2中編譯爲 WebAssembly 的 JavaScript 解釋器。主線程 JavaScript 代碼不可能直接保存沙箱中對象的引用,畢竟在沙箱中,WebAssembly 管理着本身的堆和這些堆中全部的 JavaScript 對象,實際上,Duktape 甚至可能不使用與瀏覽器引擎相同的內存來實現 JavaScript 對象!
結果,只有經過低階操做(例如從虛擬機中複製整數和字符串)才能爲 Duktape 實現API,能夠在解釋器內部保留對象或函數的引用,但只能做爲不透明的控制代碼。
這樣的接口以下所示:
// vm == virtual machine == interpreter
export interface LowLevelJavascriptVm {
typeof(handle: VmHandle): string
getNumber(handle: VmHandle): number
getString(handle: VmHandle): string
newNumber(value: number): VmHandle
newString(value: string): VmHandle
newObject(prototype?: VmHandle): VmHandle
newFunction(name: string, value: (this: VmHandle, ...args: VmHandle[]) => VmHandle): VmHandle
// For accessing properties of objects
getProp(handle: VmHandle, key: string | VmHandle): VmHandle
setProp(handle: VmHandle, key: string | VmHandle, value: VmHandle): void
defineProp(handle: VmHandle, key: string | VmHandle, descriptor: VmPropertyDescriptor): void
callFunction(func: VmHandle, thisVal: VmHandle, ...args: VmHandle[]): VmCallResult
evalCode(code: string): VmCallResult
}
export interface VmPropertyDescriptor {
configurable?: boolean
enumerable?: boolean
get?: (this: VmHandle) => VmHandle
set?: (this: VmHandle, value: VmHandle) => void
}複製代碼
請注意,這是實現 API 用到的接口,但它或多或少 1:1 映射到 Duktape 的解釋器 API。畢竟,Duktape(和相似的虛擬機)是專門爲嵌入式設計的,且容許嵌入程序與 Duktape 通訊。
使用此接口,對象 { x: 10,y: 10 } 能夠這樣傳遞給沙箱:
let vm: LowLevelJavascriptVm = createVm()
let jsVector = { x: 10, y: 10 }
let vmVector = vm.createObject()
vm.setProp(vmVector, "x", vm.newNumber(jsVector.x))
vm.setProp(vmVector, "y", vm.newNumber(jsVector.y))複製代碼
Figma 節點對象 」opacity」 屬性的 API 以下所示:
vm.defineProp(vmNodePrototype, 'opacity', {
enumerable: true,
get: function(this: VmHandle) {
return vm.newNumber(getNode(vm, this).opacity)
},
set: function(this: VmHandle, val: VmHandle) {
getNode(vm, this).opacity = vm.getNumber(val)
return vm.undefined
}
})複製代碼
使用 Realms 沙箱一樣能夠很好地實現這個底層接口,這樣實現的代碼量是相對少的(咱們的例子中大約 500 行代碼)。而後就是仔細審覈代碼,一旦完成,即可以基於這些接口建立新的 API,而不用擔憂沙盒相關的安全性問題。 在文獻中,這稱爲膜模式。
本質上,這是將 JavaScript 解釋器和 Realms 沙箱都視爲 「運行 JavaScript 的某些獨立環境」。
在沙箱上建立底層抽象還有一個關鍵,儘管咱們對 Realms 的安全性充滿信心,但在安全性方面再當心也不爲過。咱們意識到 Realms 可能存在未被發現的漏洞,某天會變成須要處理的問題,這就是接下來咱們討論編譯解釋器(甚至不會用到)的緣由。API 是經過實現可互換接口實現的,因此使用解釋器仍然是備選方案,能夠在不從新實現任何 API 或不破壞任何現有插件的狀況下使用它。
插件豐富的功能
如今,咱們有了能夠安全運行任意插件的沙箱和容許插件操做 Figma document 的 API,這已經開啓了不少可能性。
可是,咱們最初的問題是爲設計工具構建一個插件系統,大部分這樣的插件都有建立 UI 的功能,須要某種形式的網絡訪問。更通常地說,咱們但願插件儘量多地利用瀏覽器和 JavaScript 生態系統。
像前面 console.log 的例子那樣,咱們能夠每次當心地暴露一個安全的受限版本的瀏覽器 API。可是,瀏覽器 API(尤爲是 DOM)的範圍很大,甚至比 JavaScript 自己還要大。這樣的嘗試可能因爲過於嚴格而沒法使用,或者可能存在安全漏洞。
咱們再次引入 origin 爲 null 的<inline-iframe>來解決這個問題。插件能夠建立 <inline-iframe> 並在其中放置任意的 HTML 和 Javascript。
與咱們最初嘗試使用 <inline-iframe> 不一樣的是,如今插件由兩部分組成:
1. Realms 沙箱內,運行在主線程上,能夠訪問 Figma document 的部分。
2. 運行在 <inline-iframe> 內,能夠訪問瀏覽器 API 的部分。
這兩部分能夠經過消息傳遞通訊。這種結構比起在同一個環境中運行兩個部分,會使瀏覽器 API 用起來更加繁瑣。 可是,鑑於當前的瀏覽器技術,這是咱們能作到的最好方法了。咱們發佈測試版兩個月以來,它並無阻止開發人員建立出色的插件。
咱們可能走了一段彎路,但最終找到了在 Figma 中實現插件的可行方案。Realm shim 使咱們可以隔離第三方代碼,同時在相似瀏覽器的環境中運行。
這對咱們來講是最好的解決方案,但可能並不適用於每一個公司或平臺。若是你須要隔離第三方代碼,則值得評估一下是否存在與咱們類似的性能或 API 工程學方面的問題,若是沒有,那麼使用 iframe 隔離代碼就足夠了,簡單老是好的。咱們但願保持簡單!
最後,咱們很是關注最終的用戶體驗——插件的用戶將發現它們穩定可靠,具有基本 Javascript 知識的開發人員也可以建立。
在基於瀏覽器的設計工具的團隊中工做,最讓人激動的事情之一就是,可以遇到不少未知領域,而且創造解決此類技術難題的新方法。若是您喜歡這些工程冒險之旅,請查看咱們博客的其他部分獲取更多信息。