如何爲平臺設計一個插件系統

隨着web瀏覽器的發展,瀏覽器的性能愈來愈好,WebGL和WebAssembly提供愈來愈多的可能性。不少本來只能在終端運行的程序都開始開發web版本例如CAD的web版本,PS的web版本,figma。這一個個的設計協做平臺本來在終端都有插件機制。那麼若是在web端能提供一個插件機制,對於有一點編程能力的用戶,就能夠提供更好的用戶體驗和開發更多的可能性。如何開發一個好的插件系統呢?javascript

一個javascript的插件系統須要知足如下幾個方面:html

安全性

  • 插件不能夠發送請求
  • 插件和程序模塊不能夠非法的調用相互的數據
  • 插件不能夠在不受約束的狀況下執行
  • 插件不能夠任意的修改UI,從而給用戶形成誤導

穩定性

  • 插件不能影響主程序的穩定性
  • 插件不能夠修改主程序中的常量

易開發性

  • 插件應該是容易開發的,即便是面對沒有那麼多編程經驗的設計師,也應該是容易開發的。
  • 插件要可使用調試工具。

效率

  • 插件的執行效率不能太慢從而影響整個主程序的效率。

方案一:iframe沙盒實現方式

當咱們在程序中執行第三方的代碼的時候,首先第一個應該會想到的就是iframe。iframe不是咱們天天都會用到的html標籤。要理解爲何iframe爲何安全,咱們有不要想一下iframe標籤是用來幹什麼的。java

iframe比較典型的使用場景就是在一個網頁中嵌入一個其餘的網頁。舉個例子來講,你須要在網站中嵌入谷歌地圖的頁面來實現地圖的展示功能。你不會但願谷歌地圖的頁面中的代碼有能力訪問你自己的一些代碼和敏感數據,相應的谷歌地圖也不但願你可以訪問他頁面中的數據和代碼。c++

這意味着一切和iframe的交互都受限於瀏覽器。當iframe和原網頁有不一樣的域(imow.cn和google.com),他們是徹底隔絕的。那麼網頁和iframe交互的惟一辦法就是經過 postMessage。這個message是一個string。須要交互的雙方能夠選擇忽略這個message或者作對應的動做。git

iframe和原網頁是徹底獨立的,其實,若是你想要的話瀏覽器容許咱們經過另一個線程來建立一個iframe。這裏.github

當咱們瞭解了iframe是如何工做了之後,咱們能夠在咱們須要執行第三方插件的時候創一個iframe,將插件的代碼在iframe中執行。在iframe中插件能夠執行任何代碼,也不會影響到主程序,除非經過提早申明好的message。同時咱們能夠給iframe的域名設置爲null,這意味着根據瀏覽器跨域保護策略,iframe沒法給域名發送任何請求。web

iframe就這樣很簡單的成爲咱們執行第三方插件的沙盒環境,他的安全性也經過瀏覽器來保證。插件在沙盒中執行,經過主程序提供的api(postMeassge)和主程序進行交互。代碼就像下面這個樣子編程

const scene = await main.loadScene() // 從主程序獲取界面數據
scene.selection[0].width *= 2  // 修改界面數據
scene.createNode({
  type: 'RECTANGLE',
  x: 10, y: 20,
  ...
})
await main.updateScene() // 向主程序發送修改後的界面數據
複製代碼

這裏主要的代碼是loadScene(發送消息給主程序,而後得到主程序界面的document拷貝),而後修改完之後經過調用updateScene(發送更新消息給主程序).這裏須要注意的是api

  • 咱們拷貝了整個document而不是在每次須要讀取或者修改屬性的時候經過message傳輸.postMessage每次傳輸須要0.1ms.每秒鐘大約只容許1000 messages。
  • 咱們沒有讓插件直接使用postMessage api,而是包裝了一個api給插件用戶使用,這樣使用起來不會太笨重。

問題#1:async/await 使用起來不是那麼方便

這種實現方式第一個問題就是對於一些不那麼瞭解javascript的新手或者設計師來講,async/await關鍵字仍是很是陌生的。可是要使用postMessge是一個異步操做。因此不可避免的要使用async/await來控制異步流程。可是若是隻是須要在開頭和結束的時候調用咱們的api還方便,咱們能夠告訴用戶在調用咱們的api時候在前面加上async/await即便他們不知道這個關鍵字的做用也不會對他們的操做形成很是大的困擾。跨域

可是問題是有些插件須要執行很是複雜的邏輯,在修改一個layout的屬性的時候有時候會引發其餘好幾個layout的更新。好比更新外層的layout的屬性以後,內部的layout的屬性也可能發生了更新,這個時候你須要先提交你的屬性,而後在從新或者視圖的屬性,那這個時候你的代碼就會變成這樣:

await mian.loadScene()
... 操做 ...
await mian.updateScene()
await mian.loadScene()
... 操做 ...
await mian.updateScene()
await mian.loadScene()
... 操做 ...
await mian.updateScene()
複製代碼

這個代碼一會兒就變的不可控了,並且用戶也很難確認何時應該要提交個人屬性更新。

問題#2:拷貝視圖給iframe的操做是很是昂貴的

iframe這種實現方式的第二個問題就是,當你須要給插件發送視圖信息的時候你須要序列化你的document發送給你iframe,當你的視圖很是很是大的時候,這個序列化的操做是很是耗時的,甚至會致使內存溢出。 即便咱們可使用增量的加載數據或者懶加載數據這種方式仍然有他的問題:

  • 首先這種方式是很是難實現的,即便有比較好的方案實現了之後,面對比較大的視圖,性能仍然不是很理想,並且對於插件開發者來講是很是難理解的,這違背了咱們的插件易開發性。
  • 異步方法須要等待你須要到的數據達到才能開始後面的操做,對於異步流程控制來講也是一個挑戰(steam? Rx?)。

總的來講若是你的主程序有很是大的document要交給第三方插件來進行操做,那麼iframe的這種實現方式就不是很是理想的解決方案

eval

若是能在主線程上執行插件代碼,那麼在性能上就會好不少,可是咱們又不能簡單的eval(code)執行插件代碼,由於這樣是很不安全的。

什麼致使eval不安全

若是咱們退一步想:是什麼使eval方法不安全?若是咱們只是執行一段很單純的代碼

let code = 'let a = (7 + 1) * 8;'
eval(code)
複製代碼

若是隻是一段邏輯代碼,那麼這個代碼是沒有什麼不安全的。之因此認爲eval執行的代碼不安全是由於在插件代碼中有可能會發送網絡請求,修改全局的state變量,或者直接修改dom對象等等這些使得咱們的插件代碼變的不可控,換句話來講是插件具備瀏覽器api訪問的能力讓咱們插件的代碼變的不可控

是否是能把全局的對象藏起來?

若是咱們能把全局的對象藏起來,保證插件代碼中只能作變量的賦值或者一些if判斷的邏輯代碼,沒有了全局對象xhr,插件將沒法發送請求,沒有document對象,插件也不具有訪問dom的能力,那麼插件能力是否是能在咱們的可控範圍裏面了。

隱藏全局對象,理論上是可行的。可是咱們很難僅僅經過隱藏全局對象來建立一個絕對安全的運行環境。舉例來講,咱們如今把window對象設置爲null,可是代碼仍是能夠經過({}).constructor來訪問全局對象。因此找到全部有可能訪問危險api的對象,把全部的路所有堵死是很是難的一件事情。

是否是咱們能夠找到一個這些全局對象從一開始就不存在的沙盒環境?

方案二:將javascript編譯成WebAssembly

Duktape是一個輕量級的用c++寫的javascript解釋器,他能夠將javascript編譯成WebAssembly,通過test262測試以後,能夠肯定他全面的支持了ES5的語法。

這種實現方法有如下幾種優缺點

  • 首先這是一種安全的執行環境,由於Duktape不支持任何的瀏覽器API。做爲WebAssembly執行,他自己就是一個沙盒環境,他能夠經過提供一個白名單的API和主程序進行交互。
  • 這個解釋器是運行在主線程上的。這意味着咱們能夠建立一個基於主線程的API。(共享document等)
  • 他可能會比本來的javascript慢一些,由於JIT解釋器在編譯的時候作了不少的優化,可是做爲WebAssembly我相信這個性能應該也是能夠被接受的。
  • 他須要用瀏覽器來編譯WebAssembly,這會有一些性能消耗。
  • 瀏覽器的調試工具就不能用了。

看起來好像不錯,可是他做爲一個線上項目的表現到底怎麼樣呢?一個javascript引擎來執行另一個引擎?WebAssembly自己也是比較新的一個東西,咱們是否是真的須要一個相對複雜的解決方案?有沒有更簡單的方法了?

方案三:Realms

這個技術能夠建立一個沙盒環境來支持插件,當我看到他readme文檔的時候,就一會兒提起了個人興趣,Intuitions

  • sandbox
  • iframe without DOM
  • principled version of Node's 'vm' module
  • sync Worker

這不就是咱們須要的嗎?他的代碼看起來是這個樣子

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.global.Function.prototype // true
複製代碼

是否是很酷。這個技術其實能夠用如今已有的可是不經常使用的一個javascript功能來實現。代碼想這樣

function simplifiedEval(scopeProxy, code) {
 'use strict'
  with (scopeProxy) {
    eval(code)
  }
}
複製代碼

這個就像一個簡單版本的Realms,可是管中窺豹,咱們能夠看見兩個關鍵代碼withProxy對象。

with(obj)表達式建立了一個做用域,當尋找變量的時候,可使用這個obj的屬性.看個例子:

with (Math) {
  a = PI * r * r
  x = r * cos(PI)
  y = r * sin(PI)
  console.log(x,  y)
}
複製代碼

在這個例子裏,當咱們訪問PI,cos,sin的時候,就會找到Math的屬性。可是console由於Math沒有就仍然會找到全局對象。

知道了with表達式,接下來就是Proxy對象,這個對象有下面幾個特性

  • 他是一個普通的javascript對象,能夠經過obj.x訪問對象的屬性值.
  • 咱們能夠實現一個對象屬性的get方法來實現obj.x操做,實際上只執行這個get方法.
const scopeProxy = new Proxy(whitelist, {
  get(target, prop) {
    // target === whitelist
    if (prop in target) {
      return target[prop]
    }
    return undefined
  }
}
複製代碼

接下來咱們就能夠把這個scopeProxy對象做爲參數傳入with中,他就捕獲做用域全部的變量查找,在這個scopeProxy的get方法中進行查找這個變量:

with (scopeProxy) {
  document // undefined!
  eval("xhr") // undefined!
}
複製代碼

這裏只有whitelist的屬性會被返回,其餘都會返回undefined.可是其實利用一些相似({}).constructor表達式仍是有可能訪問全局對象的.此外,這個沙盒其實仍是須要訪問一些全局對象的方法的,相似Object.keys

爲要給咱們的插件系統訪問受限全局api的方法而後又不會把window搞亂,Realms沙盒經過建立一個和主程序同源的iframe用來拷貝須要用到的全局API。這個iframe和咱們第一種實現中建立的iframe不同,他不是做爲運行程序的沙盒。當你建立一個和主程序同源的iframe之後

  1. 他會拷貝一份分開的全局對象(好比:Object.prototype)。
  2. 這些全局對象能夠從父文檔中訪問,也就是說咱們能夠在Realms訪問這些全局對象.

咱們將這些全局對象放入到Proxy的白名單(whitelist)中,這樣在插件代碼中就能夠訪問這些全局對象了。經過建立iframe來拷貝全局對象有一個很重要的好處:即便是經過({}).constructor對象訪問到的全局對象,也會是iframe中拷貝的全局對象。這樣的實現方式有這些優勢:

  • 他在主程序中運行。
  • 由於他自己仍是javascript,因此他仍然用JIT編譯解析,瀏覽器對javascript的優化仍是有效。
  • 瀏覽器開發工具也仍是有效的。

那麼就剩下最後一個問題.他真的夠安全了嗎?

這樣看起來結合了iframe的Realms看起來彷佛已經挺不錯的了,並且他自己也是tc39下面的項目,因此可靠性應該也不錯。可是光有一個安全的沙盒環境是不夠的,你的插件確定須要和主程序進行交互,那麼咱們就確定要爲咱們的插件系統提供API,提供給插件的API系統也必定要是安全的。

舉個例子,console.log是瀏覽器的api是否是javascript功能,那麼咱們要爲插件系統提供一個console.log方法,咱們能夠這樣寫:

realm.evaluate(USER_CODE, { log: console.log })
複製代碼

或者爲了隱藏方法自己,咱們能夠要求他只傳參數

realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })
複製代碼

看起來是這麼回事,很惋惜,這實際上是一個安全漏洞,即便是第二種方法咱們仍是在Realms外面建立了一個匿名方法,而後直接傳入到Realms中。這意味着插件能夠經過方法的原型鏈訪問到外部。

正確建立console.log方法的方法是,將這個方法經過Realms包裹起來在Realms內部建立像這樣

// 建立一個工廠方法
// 這個工廠方法返回一個新的方法他保存一個閉包
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須要在realm內部操做外部對象的時候。這對於開發api的開發人員來講是否是有點太不友好了,一不當心就產生了安全隱患,(todo:完善起來)。

結論

若是咱們的主程序不是特別複雜並且龐大的話,第一種經過iframe的實現方式應該是最爲簡單的。

若是咱們的主程序自己就是經過WebAssembly建立的例如CAD網頁版,咱們想第二種方式多是比較適合他們的,或者他們提供更加優秀的基於WebAssembly的解決方案

最後一種方式若是咱們能提供一種簡單又安全的開發API的辦法,這應該是一種性價比比較高的解決方案。

相關文章
相關標籤/搜索